@-tagging (backend): medspillere/venner i runde-feeden
All checks were successful
Backend-tester / test (push) Successful in 21s

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 <noreply@anthropic.com>
This commit is contained in:
Erol Haagenrud 2026-08-11 09:10:17 +02:00
parent 17e47930c0
commit 206beefbc5
7 changed files with 508 additions and 8 deletions

50
064_message_tags.sql Normal file
View file

@ -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;

View file

@ -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å

View file

@ -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).

View file

@ -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

View file

@ -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 ."""
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 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

View file

@ -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())

128
tests/test_message_tags.py Normal file
View file

@ -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 == []