From 206beefbc5b341dd2152188c44abd0b820801f5a Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Tue, 11 Aug 2026 09:10:17 +0200 Subject: [PATCH] @-tagging (backend): medspillere/venner i runde-feeden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrasjon 064 (round_message_tag/round_message_comment_tag), nytt GET /rounds/{id}/taggable-people-endepunkt, validering (kun faktiske relasjoner er taggbare, ugyldige tags forkastes stille), varsling ved gyldig tag. Se ADR-063/CHANGELOG punkt 72. 8 nye pytest-tester, 31/31 grønt. Frontend (autocomplete + rendering) sendes som eget V0-prompt -- ikke deployet ennå, migrasjon venter til frontend-delen er klar. Co-Authored-By: Claude Sonnet 5 --- 064_message_tags.sql | 50 +++++++++ ARCHITECTURE_DECISIONS.md | 77 ++++++++++++++ CHANGELOG.md | 45 +++++++- FEATURE_BACKLOG.md | 18 ++++ app/routers/round_messages.py | 187 ++++++++++++++++++++++++++++++++-- tests/conftest.py | 11 ++ tests/test_message_tags.py | 128 +++++++++++++++++++++++ 7 files changed, 508 insertions(+), 8 deletions(-) create mode 100644 064_message_tags.sql create mode 100644 tests/test_message_tags.py diff --git a/064_message_tags.sql b/064_message_tags.sql new file mode 100644 index 0000000..4c02283 --- /dev/null +++ b/064_message_tags.sql @@ -0,0 +1,50 @@ +-- ===================================================================== +-- TeeCup — @-tagging av medspillere/venner i runde-feedens innlegg og +-- kommentarer, migrasjon 064 (ADR-063, reist 2026-08-11). +-- +-- Egen tag-tabell per melding-type (round_message/round_message_comment), +-- ikke en delt polymorf tabell -- samme "egen tabell fremfor delt +-- polymorf konstruksjon"-konvensjon som resten av prosjektet (se +-- round_message_reaction vs. message_reaction, migrasjon 059). +-- +-- start_index/end_index peker inn i `body`-teksten SLIK DEN BLE SKREVET +-- (round_message/round_message_comment er IKKE redigerbare -- kun +-- slettbare, se 058/059 -- så teksten kan aldri gå ut av synk med disse +-- offsettene i etterkant). Den viste teksten er dermed alltid nøyaktig +-- det avsenderen skrev; kun LENKEMÅLET (tagged_user_id) løses opp mot +-- brukerens NÅVÆRENDE navn ved lesing -- ikke frosset, ulikt +-- author_display_name -- fordi en tag skal peke på personen, ikke et +-- navn-øyeblikksbilde. +-- +-- INGEN RLS -- samme mønster som round_message/round_message_comment selv +-- (ADR-033 Beslutning A, runder er ikke org-scopet). Autorisasjon (kun +-- faktiske relasjoner -- rundedeltakere ELLER avsenderens venner -- kan +-- tagges) håndheves i app-laget (app/routers/round_messages.py), ikke her. +-- ===================================================================== +\set ON_ERROR_STOP on + +CREATE TABLE round_message_tag ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + round_message_id uuid NOT NULL REFERENCES round_message(id) ON DELETE CASCADE, + tagged_user_id uuid NOT NULL REFERENCES app_user(id) ON DELETE CASCADE, + start_index smallint NOT NULL CHECK (start_index >= 0), + end_index smallint NOT NULL CHECK (end_index > start_index) +); + +CREATE INDEX round_message_tag_message_idx ON round_message_tag (round_message_id); +CREATE INDEX round_message_tag_user_idx ON round_message_tag (tagged_user_id); + +GRANT SELECT, INSERT, DELETE ON round_message_tag TO teecup_app; + +CREATE TABLE round_message_comment_tag ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + round_message_comment_id uuid NOT NULL REFERENCES round_message_comment(id) ON DELETE CASCADE, + tagged_user_id uuid NOT NULL REFERENCES app_user(id) ON DELETE CASCADE, + start_index smallint NOT NULL CHECK (start_index >= 0), + end_index smallint NOT NULL CHECK (end_index > start_index) +); + +CREATE INDEX round_message_comment_tag_comment_idx ON round_message_comment_tag (round_message_comment_id); +CREATE INDEX round_message_comment_tag_user_idx ON round_message_comment_tag (tagged_user_id); + +GRANT SELECT, INSERT, DELETE ON round_message_comment_tag TO teecup_app; diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index ee77ca0..015cc8f 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -5920,6 +5920,83 @@ avstand-etikettene. Ingen konsollfeil. Scratch-miljøet ryddet opp fullstendig (containere, images, database, rolle), `teecup_db`s rolleoppsett bekreftet uendret. +**Oppfølging 2026-08-11 — punkt 5 (ChoiceRow) IKKE løst, brukeren +rapporterte tilbake:** "Avstand første putt" sine seks alternativer +viste seg fortsatt som 5 knapper på én rad + 1 alene, ikke ryddig 3+3 +som antatt. Root cause: `ChoiceRow` (den delte komponenten, IKKE +`NumberPicker`) brukte `flex flex-wrap` + `flex-1` per knapp, som pakker +så mange knapper som teksten tillater per rad i stedet for et fast +antall — tilfeldigvis akkurat 5+1 for disse seks korte etikettene. +Rettet til `grid grid-cols-3` (samme mønster som `NumberPicker` allerede +brukte). Alle FIRE andre `ChoiceRow`-bruk i filen (Kjønn, +Statistikk-nivå × 2) har nøyaktig 3 valg fra før — visuelt uendret for +dem, kun 6-alternativs-tilfellet endrer seg. Browserverifisert lys+mørk. +Committet (`17e4793`), IKKE deployet ennå i skrivende stund. + +--- + +## ADR-063: @-tagging av medspillere/venner i runde-feeden — 2026-08-11 + +Reist av bruker som en av åtte UX-funn (ADR-062, punkt 7), bevisst +utsatt der og bakt inn i FEATURE_BACKLOG.md som egen sak. Bruker ba om å +sette den i gang som egen runde. + +**Beslutning A — kun FAKTISKE relasjoner er taggbare, aldri fritekstsøk +i hele brukerbasen** (personvernbegrunnelsen fra FEATURE_BACKLOG-notatet +fulgt direkte). `_taggable_candidates()` (ny hjelpefunksjon, +`round_messages.py`) returnerer UNION av rundens lenkede deltakere og +avsenderens egne venner (`friendship`, status='accepted', begge +retninger) — ALDRI et generelt personsøk. Håndhevet BÅDE i det nye +søkeendepunktet (`GET /rounds/{id}/taggable-people`) OG server-side ved +innsending (klientens forslag stoles aldri blindt på). + +**Beslutning B — offset-basert tag-span, ikke innebygd tekst-syntaks.** +`round_message_tag`/`round_message_comment_tag` (migrasjon 064) lagrer +`start_index`/`end_index` inn i `body`-teksten SLIK DEN BLE SKREVET, ikke +en `@[navn](id)`-markdown-lignende syntaks i selve teksten. Trygt fordi +verken `round_message` eller `round_message_comment` NOENSINNE kan +redigeres i etterkant (kun slettes, se 058/059) — offsettene kan derfor +aldri gå ut av synk med teksten. Den viste teksten forblir alltid +NØYAKTIG det avsenderen skrev; kun lenkemålet (bruker-ID) løses opp mot +NÅVÆRENDE navn ved lesing, ikke frosset — en tag skal peke på personen, +ikke et navn-øyeblikksbilde (bevisst forskjell fra `author_display_name`, +som ER frosset). + +**Beslutning C — ugyldige tags forkastes stille, hele innlegget avvises +aldri.** En tag mot noen som ikke lenger er taggbar (f.eks. sluttet å +være medspiller mellom autocomplete og innsending), eller med en offset +utenfor tekstens lengde, filtreres bare bort (`_validate_and_prepare_tags`) +— resten av innlegget/kommentaren postes uendret. Unngår at en +kapp-løpstilstand på klientsiden blokkerer en ellers gyldig melding. + +**Beslutning D — varsling gjenbruker eksisterende `type="round"`, +ingen ny notification-kategori.** `notification.type` er begrenset til +fire faste verdier (`friend`/`tournament`/`round`/`result`, migrasjon +026/033, koblet til brukerens egne e-post-preferanser per kategori) — en +ny femte kategori ville krevd UI-endring i varslingsinnstillingene også, +utenfor denne rundens omfang. `"{fullt navn} tagget deg i et innlegg."` +sendes til hver gyldig tagget person (aldri til seg selv), samme +`create_notification()`-vei (in-app + push + evt. e-post) som alt annet +rundevarsel. + +**To parallelle tabeller, ikke én delt polymorf** (`round_message_tag`/ +`round_message_comment_tag`) — samme konvensjon som +`round_message_reaction` vs. `message_reaction` (migrasjon 059). + +**Verifisert:** 8 nye pytest-tester (`tests/test_message_tags.py`) — +kandidatlisten inkluderer venn og rundedeltaker, ekskluderer fremmede og +seg selv, søkefiltrering, gyldig tag på både innlegg og kommentar (med +varsel bekreftet skrevet til `notification`-tabellen), ugyldig tag +(fremmed / utenfor tekstlengde / seg selv) forkastes stille UTEN å +avvise selve meldingen. Alle 31 backend-tester (23 fra før + 8 nye) +grønne i samme kjøring. `teecup_db` bekreftet uendret. + +**Bevisst utenfor omfang denne runden:** frontend (autocomplete-UI + +tag-rendering) sendt som eget V0-prompt, ikke bygget her — se +CHANGELOG.md. Org-turneringenes tilsvarende "Banter Board" +(`message`/`message_comment`) fikk IKKE samme funksjon — helt separat +system, ikke nevnt i den opprinnelige forespørselen. + Disse må avklares før eller under de relevante fasene: 1. **Sesjons-secret:** TeeOff lar `PUBLIC_SESSION_SECRET` falle tilbake på diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a91b0b..6ac190b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10426,5 +10426,46 @@ Neste steg: Putter-aksent, nye putt-avstand-etiketter. Ingen konsollfeil. Scratch-miljøet ryddet opp fullstendig, `teecup_db` bekreftet uendret. - **Ikke rullet ut ennå** — venter på eksplisitt bekreftelse per - CLAUDE.md. + **Rullet ut 2026-08-11**, bruker bekreftet eksplisitt ("Kjør på") — + `docker compose build teecup_frontend && up -d`, ren omstart, + bekreftet live på `teecup.golf`, ingen konsollfeil utover den + harmløse PWA-infomeldingen. + + **Oppfølging samme dag — punkt 5 (ChoiceRow) ikke løst.** Bruker + rapporterte at "Avstand første putt" fortsatt viste 5+1 i stedet for + 3+3. Root cause: `ChoiceRow` (delt komponent) brukte `flex flex-wrap` + fremfor et fast rutenett, pakket knapper etter tekstbredde i stedet + for et forutsigbart antall. Rettet til `grid grid-cols-3` (samme + mønster `NumberPicker` allerede brukte). Visuelt uendret for de fire + andre `ChoiceRow`-bruken (alle har 3 valg fra før). Browserverifisert + lys+mørk. Committet (`17e4793`), ikke deployet ennå. + +72. **@-tagging av medspillere/venner i runde-feeden (backend) — + 2026-08-11, se ADR-063.** Bakt inn i FEATURE_BACKLOG.md som egen sak + under ADR-062, bruker ba om å sette den i gang. + + Migrasjon 064: `round_message_tag`/`round_message_comment_tag` (to + parallelle tabeller, samme mønster som `round_message_reaction` vs. + `message_reaction`). Kun FAKTISKE relasjoner er taggbare (rundens + lenkede deltakere UNION avsenderens venner) — aldri fritekstsøk i + hele brukerbasen, håndhevet både i nytt søkeendepunkt + (`GET /rounds/{id}/taggable-people`) og server-side ved innsending. + Tag-posisjon lagres som tegn-offset inn i den (aldri redigerbare) + body-teksten — trygt, teksten kan aldri gå ut av synk. Ugyldige tags + forkastes stille, hele innlegget avvises aldri av den grunn. Varsel + (`type="round"`, gjenbruker eksisterende kategori) til hver gyldig + tagget person. + + **Verifisert:** 8 nye pytest-tester + (`tests/test_message_tags.py`) — kandidatliste, søkefilter, gyldig + tag + varsel bekreftet, tre typer ugyldig-tag-forkastelse. Alle 31 + backend-tester grønne. `teecup_db` uendret. + + **Bevisst utenfor omfang:** frontend (autocomplete + tag-rendering) + sendt som eget V0-prompt, ikke bygget i denne runden. Org- + turneringenes "Banter Board" (`message`/`message_comment`) fikk IKKE + samme funksjon — separat system. + + **Ikke rullet ut ennå** — venter på V0-leveransen for frontend-delen + før migrasjon 064 kjøres mot ekte `teecup_db` (samme rekkefølge- + prinsipp som alltid: migrasjon før kode som er avhengig av den). diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index 0709f76..1323cc5 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -1989,6 +1989,24 @@ teste dette). Én kritisk rute-/rewrite-kollisjon (`/feed` vs. API-ets kjørt mot ekte `teecup_db`, begge containere redeployet. Se CHANGELOG.md 2026-08-06 for full byggelogg. +### @-tagging av medspillere i feed-innlegg/kommentarer — 💤 utsatt (bevisst), reist 2026-08-11 + +Bruker: "Det bør være mulig å tagge medspillere i feeden. Eks: Fredrik i +farta." — skrive `@Navn`, systemet foreslår faktiske medspillere/venner +med det navnet, tagger riktig person (eller lar deg velge ved flere +treff). + +Vurdert samme dag (ADR-062), IKKE bygget — egen, betydelig funksjon +(datamodell for lagrede tags + søk/autocomplete-UI + rendering av +klikkbare tags i visningen), bakt inn i en runde som ellers dreide seg +om noe helt annet (ny-runde/score-registrering-UX). To ting bør avklares +FØR bygging: +- **Personvern:** treff bør begrenses til faktiske relasjoner (spillere i + samme runde, eller venner) — IKKE fritekstsøk i hele brukerbasen. +- **UX:** en ekte søkbar nedtrekksliste som åpnes ved `@`, ikke + fritekst-tolkning i etterkant av en allerede skrevet kommentar (upålitelig + med flere personer som deler navn). + --- ## Landingssider (turnering + organisasjon) — ADR-018 ✅ HELT FERDIG 2026-07-18 diff --git a/app/routers/round_messages.py b/app/routers/round_messages.py index f67ef25..6b3a2f3 100644 --- a/app/routers/round_messages.py +++ b/app/routers/round_messages.py @@ -22,6 +22,7 @@ live` -- klienten reagerer med å hente meldingslisten på nytt, samme "noe endret seg"-mønster som resten av runde-sanntiden. """ +import json from datetime import datetime from fastapi import APIRouter, Depends, File, Form, Query, UploadFile @@ -32,6 +33,7 @@ from ..auth import CurrentUser, get_current_user, get_current_user_optional from ..db import plain_connection from ..errors import app_error, translate_db_errors from ..realtime import broadcast_round_update +from .notifications import create_notification from .rounds import _get_viewable_round_or_404, _resolve_round_message_author_name router = APIRouter(tags=["round-messages"]) @@ -61,6 +63,119 @@ class ReactionSummary(BaseModel): reactors: list[str] = [] +# --------------------------------------------------------------------------- +# @-tagging (ADR-063, 2026-08-11) -- kun FAKTISKE relasjoner er taggbare +# (rundedeltakere ELLER avsenderens venner), aldri fritekstsøk i hele +# brukerbasen (personvern, se FEATURE_BACKLOG.md-notatet som reiste dette). +# `start_index`/`end_index` er BYTE-uavhengige Python-strengindekser inn i +# `body` SLIK DEN BLE SKREVET -- trygt siden verken round_message eller +# round_message_comment noensinne kan redigeres i etterkant (kun slettes). +# --------------------------------------------------------------------------- + + +class TagIn(BaseModel): + user_id: str + start_index: int = Field(ge=0) + end_index: int = Field(gt=0) + + +class TagOut(BaseModel): + user_id: str + display_name: str + start_index: int + end_index: int + + +async def _taggable_candidates(conn, round_id: str, author_user_id: str) -> dict[str, str]: + """Returnerer {user_id: fullt navn} for alle personer AVSENDEREN faktisk + kan tagge i denne runden -- lenkede medspillere i runden, UNION + avsenderens egne venner (uansett om vennen selv er med i runden). + Brukes BÅDE til søkeendepunktet OG til server-side validering ved + innsending -- klientens forslag stoles aldri blindt på.""" + rows = await conn.fetch( + """ + SELECT DISTINCT au.id::text AS user_id, + COALESCE(NULLIF(btrim(au.first_name || ' ' || au.last_name), ''), au.display_name) AS name + FROM app_user au + WHERE au.id IN ( + SELECT rp.user_id FROM round_participant rp + WHERE rp.round_id = $1 AND rp.user_id IS NOT NULL + ) + OR au.id IN ( + SELECT CASE WHEN f.requester_user_id = $2 THEN f.addressee_user_id ELSE f.requester_user_id END + FROM friendship f + WHERE f.status = 'accepted' AND (f.requester_user_id = $2 OR f.addressee_user_id = $2) + ) + """, + round_id, + author_user_id, + ) + candidates = {r["user_id"]: r["name"] for r in rows} + candidates.pop(author_user_id, None) # kan ikke tagge seg selv + return candidates + + +async def _validate_and_prepare_tags( + conn, round_id: str, author_user_id: str, body: str, raw_tags: list[TagIn] +) -> list[TagIn]: + """Filtrerer ut ugyldige tags fremfor å avvise HELE innlegget -- en + tag med feil offset eller mot en ikke-taggbar person er trolig en + klient-/rase-tilstand-feil (personen som ble tagget sluttet f.eks. å + være medspiller mellom autocomplete og innsending), ikke noe brukeren + bevisst prøver å omgå. Innlegget/kommentaren skal uansett postes.""" + if not raw_tags: + return [] + candidates = await _taggable_candidates(conn, round_id, author_user_id) + valid: list[TagIn] = [] + for t in raw_tags: + if t.user_id not in candidates: + continue + if t.end_index > len(body) or t.start_index >= t.end_index: + continue + valid.append(t) + return valid + + +async def _insert_tags_and_notify( + conn, *, table: str, fk_column: str, message_id: str, round_id: str, + author_user_id: str, author_name: str, tags: list[TagIn], +) -> None: + for t in tags: + await conn.execute( + f"INSERT INTO {table} ({fk_column}, tagged_user_id, start_index, end_index) VALUES ($1, $2, $3, $4)", + message_id, t.user_id, t.start_index, t.end_index, + ) + await create_notification( + conn, + user_id=t.user_id, + type="round", + message=f"{author_name} tagget deg i et innlegg.", + link_path=f"/my-rounds/{round_id}", + ) + + +async def _tags_for_messages(conn, table: str, fk_column: str, message_ids: list[str]) -> dict[str, list[TagOut]]: + if not message_ids: + return {} + rows = await conn.fetch( + f""" + SELECT t.{fk_column}::text AS message_id, t.tagged_user_id::text AS user_id, t.start_index, t.end_index, + COALESCE(NULLIF(btrim(au.first_name || ' ' || au.last_name), ''), au.display_name) AS name + FROM {table} t + JOIN app_user au ON au.id = t.tagged_user_id + WHERE t.{fk_column} = ANY($1::uuid[]) + ORDER BY t.start_index + """, + message_ids, + ) + result: dict[str, list[TagOut]] = {} + for r in rows: + result.setdefault(r["message_id"], []).append( + TagOut(user_id=r["user_id"], display_name=r["name"], start_index=r["start_index"], end_index=r["end_index"]) + ) + return result + + class RoundMessageOut(BaseModel): id: str round_id: str @@ -71,10 +186,11 @@ class RoundMessageOut(BaseModel): created_at: str reactions: list[ReactionSummary] = [] comment_count: int = 0 + tags: list[TagOut] = [] def _row_to_round_message( - row, reactions: list[ReactionSummary] | None = None, comment_count: int = 0 + row, reactions: list[ReactionSummary] | None = None, comment_count: int = 0, tags: list[TagOut] | None = None ) -> RoundMessageOut: return RoundMessageOut( id=row["id"], @@ -86,6 +202,7 @@ def _row_to_round_message( created_at=row["created_at"].isoformat(), reactions=reactions or [], comment_count=comment_count, + tags=tags or [], ) @@ -163,22 +280,53 @@ async def list_round_messages( message_ids = [r["id"] for r in rows] reactions_by_id = await _round_message_reactions(conn, message_ids, user.user_id if user else None) comment_counts = await _round_message_comment_counts(conn, message_ids) + tags_by_id = await _tags_for_messages(conn, "round_message_tag", "round_message_id", message_ids) return [ - _row_to_round_message(r, reactions_by_id.get(r["id"]), comment_counts.get(r["id"], 0)) + _row_to_round_message(r, reactions_by_id.get(r["id"]), comment_counts.get(r["id"], 0), tags_by_id.get(r["id"])) for r in rows ] +@router.get("/rounds/{round_id}/taggable-people") +async def list_taggable_people( + round_id: str, + q: str = Query(default="", max_length=100), + user: CurrentUser = Depends(get_current_user), +) -> list[dict]: + """Kandidater for @-tagging: rundens lenkede medspillere UNION + avsenderens egne venner -- ALDRI et fritekstsøk i hele brukerbasen + (personvern). `q` filtrerer på navn, case-insensitivt delstreng-treff.""" + async with plain_connection() as conn: + await _get_viewable_round_or_404(conn, round_id, user.user_id) + candidates = await _taggable_candidates(conn, round_id, user.user_id) + needle = q.strip().lower() + results = [ + {"user_id": uid, "display_name": name} + for uid, name in candidates.items() + if not needle or needle in name.lower() + ] + results.sort(key=lambda r: r["display_name"]) + return results[:20] + + @router.post("/rounds/{round_id}/messages", response_model=RoundMessageOut, status_code=201) async def post_round_message( round_id: str, body: str | None = Form(default=None, max_length=2000), image: UploadFile | None = File(default=None), + # JSON-kodet liste av {user_id, start_index, end_index} -- Form()-felt + # siden dette endepunktet er multipart (bilde-opplasting), ikke en ren + # JSON-body som CommentIn under kan bruke direkte. + tags: str | None = Form(default=None), user: CurrentUser = Depends(get_current_user), ) -> RoundMessageOut: if not body and image is None: raise app_error(400, "VALIDATION_FAILED", "Meldingen må ha tekst eller bilde.") raw = await storage.read_optional_image(image) + try: + raw_tags = [TagIn(**t) for t in json.loads(tags)] if tags else [] + except (json.JSONDecodeError, TypeError, ValueError): + raise app_error(400, "VALIDATION_FAILED", "Ugyldig tag-data.") async with plain_connection() as conn, translate_db_errors(): # Selve synlighetssjekken ER post-sjekken -- "kan se = kan bidra", @@ -193,6 +341,7 @@ async def post_round_message( raise app_error(400, "VALIDATION_FAILED", "Filen er ikke et gyldig bilde.") display_name = await _resolve_round_message_author_name(conn, user.user_id) + valid_tags = await _validate_and_prepare_tags(conn, round_id, user.user_id, body or "", raw_tags) row = await conn.fetchrow( f""" @@ -206,7 +355,18 @@ async def post_round_message( body, image_key, ) - message = _row_to_round_message(row) + await _insert_tags_and_notify( + conn, table="round_message_tag", fk_column="round_message_id", message_id=row["id"], + round_id=round_id, author_user_id=user.user_id, author_name=display_name, tags=valid_tags, + ) + # `candidates` gir taggede personers EGNE navn (ikke avsenderens) -- + # samme kilde `_validate_and_prepare_tags` allerede validerte mot. + candidates = await _taggable_candidates(conn, round_id, user.user_id) + tag_outs = [ + TagOut(user_id=t.user_id, display_name=candidates.get(t.user_id, ""), start_index=t.start_index, end_index=t.end_index) + for t in valid_tags + ] + message = _row_to_round_message(row, tags=tag_outs) await broadcast_round_update(round_id) return message @@ -316,6 +476,7 @@ _ROUND_MESSAGE_COMMENT_COLUMNS = """ class CommentIn(BaseModel): body: str = Field(min_length=1, max_length=2000) parent_comment_id: str | None = None + tags: list[TagIn] = [] class CommentOut(BaseModel): @@ -326,9 +487,10 @@ class CommentOut(BaseModel): author_display_name: str body: str created_at: str + tags: list[TagOut] = [] -def _row_to_round_message_comment(row) -> CommentOut: +def _row_to_round_message_comment(row, tags: list[TagOut] | None = None) -> CommentOut: return CommentOut( id=row["id"], round_message_id=row["round_message_id"], @@ -337,6 +499,7 @@ def _row_to_round_message_comment(row) -> CommentOut: author_display_name=row["author_display_name"], body=row["body"], created_at=row["created_at"].isoformat(), + tags=tags or [], ) @@ -356,7 +519,9 @@ async def list_round_message_comments( """, message_id, ) - return [_row_to_round_message_comment(r) for r in rows] + comment_ids = [r["id"] for r in rows] + tags_by_id = await _tags_for_messages(conn, "round_message_comment_tag", "round_message_comment_id", comment_ids) + return [_row_to_round_message_comment(r, tags_by_id.get(r["id"])) for r in rows] @router.post( @@ -385,6 +550,7 @@ async def post_round_message_comment( raise app_error(404, "NOT_FOUND", "Kommentaren du svarer på finnes ikke.") display_name = await _resolve_round_message_author_name(conn, user.user_id) + valid_tags = await _validate_and_prepare_tags(conn, round_id, user.user_id, body, payload.tags) row = await conn.fetchrow( f""" INSERT INTO round_message_comment @@ -398,7 +564,16 @@ async def post_round_message_comment( display_name, body, ) - comment = _row_to_round_message_comment(row) + await _insert_tags_and_notify( + conn, table="round_message_comment_tag", fk_column="round_message_comment_id", message_id=row["id"], + round_id=round_id, author_user_id=user.user_id, author_name=display_name, tags=valid_tags, + ) + candidates = await _taggable_candidates(conn, round_id, user.user_id) + tag_outs = [ + TagOut(user_id=t.user_id, display_name=candidates.get(t.user_id, ""), start_index=t.start_index, end_index=t.end_index) + for t in valid_tags + ] + comment = _row_to_round_message_comment(row, tags=tag_outs) # INGEN broadcast_round_update her (ADR-046) -- kommentarer/reaksjoner # har bevisst ingen live-push til andre samtidige seere i v1 (kun egen # handling refetcher). Ville ellers trigget RoundMessages sin diff --git a/tests/conftest.py b/tests/conftest.py index d19a6d2..3fc87c0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -106,6 +106,17 @@ async def create_participant(conn, round_id: str, **overrides) -> str: return participant_id +async def create_friendship(requester_user_id: str, addressee_user_id: str, **overrides) -> str: + friendship_id = overrides.get("id", new_id()) + status = overrides.get("status", "accepted") + async with app_db.plain_connection() as conn: + await conn.execute( + "INSERT INTO friendship (id, requester_user_id, addressee_user_id, status) VALUES ($1, $2, $3, $4)", + friendship_id, requester_user_id, addressee_user_id, status, + ) + return friendship_id + + async def create_hole(conn, participant_id: str, hole_number: int, par: int = 4, stroke_index: int = 9, **overrides) -> str: hole_id = overrides.get("id", new_id()) diff --git a/tests/test_message_tags.py b/tests/test_message_tags.py new file mode 100644 index 0000000..fe407d0 --- /dev/null +++ b/tests/test_message_tags.py @@ -0,0 +1,128 @@ +""" +ADR-063: @-tagging i runde-feedens innlegg/kommentarer -- kun faktiske +relasjoner (rundedeltakere/venner) er taggbare, aldri fritekstsøk i hele +brukerbasen. Kaller de faktiske router-funksjonene direkte. +""" + +import pytest +from fastapi import HTTPException + +from app import db as app_db +from app.auth import CurrentUser +from app.routers.round_messages import ( + CommentIn, + TagIn, + list_taggable_people, + post_round_message, + post_round_message_comment, +) + +from tests.conftest import ( + create_friendship, + create_participant, + create_round, + create_user, +) + + +async def _setup_round_with_owner_and_friend(): + owner_id = await create_user() + friend_id = await create_user() + stranger_id = await create_user() # verken medspiller eller venn + await create_friendship(owner_id, friend_id) + async with app_db.plain_connection() as conn: + round_id = await create_round(conn, owner_id) + await create_participant(conn, round_id, user_id=owner_id, is_owner=True) + return owner_id, friend_id, stranger_id, round_id + + +async def test_taggable_people_includes_friend_not_stranger(pool): + owner_id, friend_id, stranger_id, round_id = await _setup_round_with_owner_and_friend() + candidates = await list_taggable_people(round_id, q="", user=CurrentUser(user_id=owner_id)) + ids = {c["user_id"] for c in candidates} + assert friend_id in ids + assert stranger_id not in ids + assert owner_id not in ids # kan ikke tagge seg selv + + +async def test_taggable_people_includes_round_participant(pool): + owner_id, friend_id, stranger_id, round_id = await _setup_round_with_owner_and_friend() + co_player_id = await create_user() + async with app_db.plain_connection() as conn: + await create_participant(conn, round_id, user_id=co_player_id, is_owner=False) + candidates = await list_taggable_people(round_id, q="", user=CurrentUser(user_id=owner_id)) + ids = {c["user_id"] for c in candidates} + assert co_player_id in ids + + +async def test_taggable_people_filters_by_query(pool): + owner_id, friend_id, stranger_id, round_id = await _setup_round_with_owner_and_friend() + friend_name = (await list_taggable_people(round_id, q="", user=CurrentUser(user_id=owner_id)))[0]["display_name"] + matched = await list_taggable_people(round_id, q=friend_name[:3], user=CurrentUser(user_id=owner_id)) + assert any(c["user_id"] == friend_id for c in matched) + unmatched = await list_taggable_people(round_id, q="zzz-ingen-treff-zzz", user=CurrentUser(user_id=owner_id)) + assert unmatched == [] + + +async def test_post_message_with_valid_tag_creates_tag_and_notification(pool): + owner_id, friend_id, stranger_id, round_id = await _setup_round_with_owner_and_friend() + body = "God runde med @Venn i dag" + tag_json = f'[{{"user_id": "{friend_id}", "start_index": 14, "end_index": 18}}]' + + out = await post_round_message(round_id, body=body, image=None, tags=tag_json, user=CurrentUser(user_id=owner_id)) + + assert len(out.tags) == 1 + assert out.tags[0].user_id == friend_id + + async with app_db.plain_connection() as conn: + notif = await conn.fetchrow( + "SELECT type, message FROM notification WHERE user_id = $1", friend_id + ) + assert notif is not None + assert notif["type"] == "round" + + +async def test_post_message_with_stranger_tag_is_silently_dropped(pool): + """Ugyldig tag (mot noen som verken er medspiller eller venn) skal + IKKE avvise hele innlegget -- kun selve tag-forsøket forkastes.""" + owner_id, friend_id, stranger_id, round_id = await _setup_round_with_owner_and_friend() + body = "Hyggelig runde" + tag_json = f'[{{"user_id": "{stranger_id}", "start_index": 0, "end_index": 9}}]' + + out = await post_round_message(round_id, body=body, image=None, tags=tag_json, user=CurrentUser(user_id=owner_id)) + + assert out.body == body + assert out.tags == [] + async with app_db.plain_connection() as conn: + notif = await conn.fetchval("SELECT count(*) FROM notification WHERE user_id = $1", stranger_id) + assert notif == 0 + + +async def test_post_message_with_out_of_bounds_tag_is_dropped(pool): + owner_id, friend_id, stranger_id, round_id = await _setup_round_with_owner_and_friend() + body = "Kort" + tag_json = f'[{{"user_id": "{friend_id}", "start_index": 0, "end_index": 999}}]' + + out = await post_round_message(round_id, body=body, image=None, tags=tag_json, user=CurrentUser(user_id=owner_id)) + assert out.tags == [] + + +async def test_post_comment_with_valid_tag(pool): + owner_id, friend_id, stranger_id, round_id = await _setup_round_with_owner_and_friend() + message = await post_round_message(round_id, body="Innlegg", image=None, tags=None, user=CurrentUser(user_id=owner_id)) + + comment_body = "Bra jobba @Venn" + payload = CommentIn(body=comment_body, tags=[TagIn(user_id=friend_id, start_index=10, end_index=14)]) + comment = await post_round_message_comment(round_id, message.id, payload, user=CurrentUser(user_id=owner_id)) + + assert len(comment.tags) == 1 + assert comment.tags[0].user_id == friend_id + assert comment.tags[0].display_name + + +async def test_cannot_tag_self(pool): + owner_id, friend_id, stranger_id, round_id = await _setup_round_with_owner_and_friend() + body = "Meg selv" + tag_json = f'[{{"user_id": "{owner_id}", "start_index": 0, "end_index": 4}}]' + out = await post_round_message(round_id, body=body, image=None, tags=tag_json, user=CurrentUser(user_id=owner_id)) + assert out.tags == []