@-tagging (backend): medspillere/venner i runde-feeden
All checks were successful
Backend-tester / test (push) Successful in 21s
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:
parent
17e47930c0
commit
206beefbc5
7 changed files with 508 additions and 8 deletions
50
064_message_tags.sql
Normal file
50
064_message_tags.sql
Normal 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;
|
||||
|
|
@ -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å
|
||||
|
|
|
|||
45
CHANGELOG.md
45
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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
128
tests/test_message_tags.py
Normal 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 == []
|
||||
Loading…
Reference in a new issue