@-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
|
fullstendig (containere, images, database, rolle), `teecup_db`s
|
||||||
rolleoppsett bekreftet uendret.
|
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:
|
Disse må avklares før eller under de relevante fasene:
|
||||||
|
|
||||||
1. **Sesjons-secret:** TeeOff lar `PUBLIC_SESSION_SECRET` falle tilbake på
|
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.
|
Putter-aksent, nye putt-avstand-etiketter. Ingen konsollfeil.
|
||||||
Scratch-miljøet ryddet opp fullstendig, `teecup_db` bekreftet uendret.
|
Scratch-miljøet ryddet opp fullstendig, `teecup_db` bekreftet uendret.
|
||||||
|
|
||||||
**Ikke rullet ut ennå** — venter på eksplisitt bekreftelse per
|
**Rullet ut 2026-08-11**, bruker bekreftet eksplisitt ("Kjør på") —
|
||||||
CLAUDE.md.
|
`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
|
kjørt mot ekte `teecup_db`, begge containere redeployet. Se CHANGELOG.md
|
||||||
2026-08-06 for full byggelogg.
|
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
|
## 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.
|
"noe endret seg"-mønster som resten av runde-sanntiden.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, Form, Query, UploadFile
|
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 ..db import plain_connection
|
||||||
from ..errors import app_error, translate_db_errors
|
from ..errors import app_error, translate_db_errors
|
||||||
from ..realtime import broadcast_round_update
|
from ..realtime import broadcast_round_update
|
||||||
|
from .notifications import create_notification
|
||||||
from .rounds import _get_viewable_round_or_404, _resolve_round_message_author_name
|
from .rounds import _get_viewable_round_or_404, _resolve_round_message_author_name
|
||||||
|
|
||||||
router = APIRouter(tags=["round-messages"])
|
router = APIRouter(tags=["round-messages"])
|
||||||
|
|
@ -61,6 +63,119 @@ class ReactionSummary(BaseModel):
|
||||||
reactors: list[str] = []
|
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):
|
class RoundMessageOut(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
round_id: str
|
round_id: str
|
||||||
|
|
@ -71,10 +186,11 @@ class RoundMessageOut(BaseModel):
|
||||||
created_at: str
|
created_at: str
|
||||||
reactions: list[ReactionSummary] = []
|
reactions: list[ReactionSummary] = []
|
||||||
comment_count: int = 0
|
comment_count: int = 0
|
||||||
|
tags: list[TagOut] = []
|
||||||
|
|
||||||
|
|
||||||
def _row_to_round_message(
|
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:
|
) -> RoundMessageOut:
|
||||||
return RoundMessageOut(
|
return RoundMessageOut(
|
||||||
id=row["id"],
|
id=row["id"],
|
||||||
|
|
@ -86,6 +202,7 @@ def _row_to_round_message(
|
||||||
created_at=row["created_at"].isoformat(),
|
created_at=row["created_at"].isoformat(),
|
||||||
reactions=reactions or [],
|
reactions=reactions or [],
|
||||||
comment_count=comment_count,
|
comment_count=comment_count,
|
||||||
|
tags=tags or [],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -163,22 +280,53 @@ async def list_round_messages(
|
||||||
message_ids = [r["id"] for r in rows]
|
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)
|
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)
|
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 [
|
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
|
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)
|
@router.post("/rounds/{round_id}/messages", response_model=RoundMessageOut, status_code=201)
|
||||||
async def post_round_message(
|
async def post_round_message(
|
||||||
round_id: str,
|
round_id: str,
|
||||||
body: str | None = Form(default=None, max_length=2000),
|
body: str | None = Form(default=None, max_length=2000),
|
||||||
image: UploadFile | None = File(default=None),
|
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),
|
user: CurrentUser = Depends(get_current_user),
|
||||||
) -> RoundMessageOut:
|
) -> RoundMessageOut:
|
||||||
if not body and image is None:
|
if not body and image is None:
|
||||||
raise app_error(400, "VALIDATION_FAILED", "Meldingen må ha tekst eller bilde.")
|
raise app_error(400, "VALIDATION_FAILED", "Meldingen må ha tekst eller bilde.")
|
||||||
raw = await storage.read_optional_image(image)
|
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():
|
async with plain_connection() as conn, translate_db_errors():
|
||||||
# Selve synlighetssjekken ER post-sjekken -- "kan se = kan bidra",
|
# 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.")
|
raise app_error(400, "VALIDATION_FAILED", "Filen er ikke et gyldig bilde.")
|
||||||
|
|
||||||
display_name = await _resolve_round_message_author_name(conn, user.user_id)
|
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(
|
row = await conn.fetchrow(
|
||||||
f"""
|
f"""
|
||||||
|
|
@ -206,7 +355,18 @@ async def post_round_message(
|
||||||
body,
|
body,
|
||||||
image_key,
|
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)
|
await broadcast_round_update(round_id)
|
||||||
return message
|
return message
|
||||||
|
|
||||||
|
|
@ -316,6 +476,7 @@ _ROUND_MESSAGE_COMMENT_COLUMNS = """
|
||||||
class CommentIn(BaseModel):
|
class CommentIn(BaseModel):
|
||||||
body: str = Field(min_length=1, max_length=2000)
|
body: str = Field(min_length=1, max_length=2000)
|
||||||
parent_comment_id: str | None = None
|
parent_comment_id: str | None = None
|
||||||
|
tags: list[TagIn] = []
|
||||||
|
|
||||||
|
|
||||||
class CommentOut(BaseModel):
|
class CommentOut(BaseModel):
|
||||||
|
|
@ -326,9 +487,10 @@ class CommentOut(BaseModel):
|
||||||
author_display_name: str
|
author_display_name: str
|
||||||
body: str
|
body: str
|
||||||
created_at: 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(
|
return CommentOut(
|
||||||
id=row["id"],
|
id=row["id"],
|
||||||
round_message_id=row["round_message_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"],
|
author_display_name=row["author_display_name"],
|
||||||
body=row["body"],
|
body=row["body"],
|
||||||
created_at=row["created_at"].isoformat(),
|
created_at=row["created_at"].isoformat(),
|
||||||
|
tags=tags or [],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -356,7 +519,9 @@ async def list_round_message_comments(
|
||||||
""",
|
""",
|
||||||
message_id,
|
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(
|
@router.post(
|
||||||
|
|
@ -385,6 +550,7 @@ async def post_round_message_comment(
|
||||||
raise app_error(404, "NOT_FOUND", "Kommentaren du svarer på finnes ikke.")
|
raise app_error(404, "NOT_FOUND", "Kommentaren du svarer på finnes ikke.")
|
||||||
|
|
||||||
display_name = await _resolve_round_message_author_name(conn, user.user_id)
|
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(
|
row = await conn.fetchrow(
|
||||||
f"""
|
f"""
|
||||||
INSERT INTO round_message_comment
|
INSERT INTO round_message_comment
|
||||||
|
|
@ -398,7 +564,16 @@ async def post_round_message_comment(
|
||||||
display_name,
|
display_name,
|
||||||
body,
|
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
|
# INGEN broadcast_round_update her (ADR-046) -- kommentarer/reaksjoner
|
||||||
# har bevisst ingen live-push til andre samtidige seere i v1 (kun egen
|
# har bevisst ingen live-push til andre samtidige seere i v1 (kun egen
|
||||||
# handling refetcher). Ville ellers trigget RoundMessages sin
|
# handling refetcher). Ville ellers trigget RoundMessages sin
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,17 @@ async def create_participant(conn, round_id: str, **overrides) -> str:
|
||||||
return participant_id
|
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,
|
async def create_hole(conn, participant_id: str, hole_number: int, par: int = 4,
|
||||||
stroke_index: int = 9, **overrides) -> str:
|
stroke_index: int = 9, **overrides) -> str:
|
||||||
hole_id = overrides.get("id", new_id())
|
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