2026-08-06 07:01:22 +02:00
|
|
|
"""
|
|
|
|
|
Kommentarer/bilder på frittstående runder (ADR-044) -- "Banter Board" for
|
|
|
|
|
en enkelt runde (`round`/migrasjon 020), pluss en samlet `/feed`-side som
|
|
|
|
|
aggregerer disse på tvers av runder brukeren har innsyn i.
|
|
|
|
|
|
|
|
|
|
To bevisste forskjeller fra org-feeden (`messaging.py`, ADR-025):
|
|
|
|
|
|
|
|
|
|
- Egen `round_message`-tabell (migrasjon 058), IKKE en utvidelse av den
|
|
|
|
|
org-scopede `message`-tabellen -- `round` har verken `organization_id`
|
|
|
|
|
eller RLS (ADR-033 Beslutning A), så all autorisasjon her skjer i
|
|
|
|
|
app-laget via `plain_connection()`, ikke database-policy.
|
|
|
|
|
- Skriverett = leserett: ALLE som kan SE runden (`_can_view_round`,
|
|
|
|
|
gjenbrukt uendret fra `rounds.py`, ADR-036 fase 2) kan også POSTE --
|
|
|
|
|
samme "kan se = kan bidra"-modell som org-feeden, men her er
|
|
|
|
|
synlighetssjekken allerede bygget og trenger ingen egen
|
|
|
|
|
medlemskaps-/deltakelses-sjekk i tillegg.
|
|
|
|
|
|
|
|
|
|
Sanntid: INGEN egen WebSocket-kanal. Gjenbruker `broadcast_round_update`
|
|
|
|
|
(app/realtime.py), som allerede kringkastes ved hver hull-endring og
|
|
|
|
|
allerede konsumeres av `/ws/rounds/{id}/live` / `/ws/public/rounds/{id}/
|
|
|
|
|
live` -- klienten reagerer med å hente meldingslisten på nytt, samme
|
|
|
|
|
"noe endret seg"-mønster som resten av runde-sanntiden.
|
|
|
|
|
"""
|
|
|
|
|
|
2026-08-11 09:10:17 +02:00
|
|
|
import json
|
2026-08-06 07:01:22 +02:00
|
|
|
from datetime import datetime
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, Depends, File, Form, Query, UploadFile
|
2026-08-10 07:00:11 +02:00
|
|
|
from pydantic import BaseModel, Field
|
2026-08-06 07:01:22 +02:00
|
|
|
|
|
|
|
|
from .. import storage
|
|
|
|
|
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
|
2026-08-11 09:10:17 +02:00
|
|
|
from .notifications import create_notification
|
2026-08-07 22:02:56 +02:00
|
|
|
from .rounds import _get_viewable_round_or_404, _resolve_round_message_author_name
|
2026-08-06 07:01:22 +02:00
|
|
|
|
|
|
|
|
router = APIRouter(tags=["round-messages"])
|
|
|
|
|
|
|
|
|
|
_ROUND_MESSAGE_COLUMNS = """
|
|
|
|
|
id::text, round_id::text, author_user_id::text, author_display_name, body, image_key, created_at
|
|
|
|
|
"""
|
|
|
|
|
|
2026-08-07 22:02:56 +02:00
|
|
|
# ADR-046: ett fast, kuratert reaksjonssett -- "like (eller andre emojier)",
|
|
|
|
|
# ikke et fritt emoji-utvalg. Delt med messaging.py (importert derfra).
|
|
|
|
|
ALLOWED_REACTION_EMOJIS = {"👍", "❤️", "😂", "😮", "😢", "🙏"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ReactionIn(BaseModel):
|
|
|
|
|
emoji: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ReactionSummary(BaseModel):
|
|
|
|
|
emoji: str
|
|
|
|
|
count: int
|
|
|
|
|
reacted_by_me: bool
|
|
|
|
|
# Fulle navn (roster-kontekst, ikke direkte adressering -- CLAUDE.md
|
|
|
|
|
# navneformat-regel), i rekkefølgen de reagerte -- brukeren ba
|
|
|
|
|
# eksplisitt 2026-08-07 om å kunne se HVEM som reagerte, ikke bare et
|
|
|
|
|
# antall. Hentes i SAMME grupperte batch-spørring som selve
|
|
|
|
|
# oppsummeringen (array_agg), ingen ekstra tur-retur ved visning.
|
|
|
|
|
reactors: list[str] = []
|
|
|
|
|
|
2026-08-06 07:01:22 +02:00
|
|
|
|
2026-08-11 09:10:17 +02:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# @-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
|
|
|
|
|
|
|
|
|
|
|
2026-08-06 07:01:22 +02:00
|
|
|
class RoundMessageOut(BaseModel):
|
|
|
|
|
id: str
|
|
|
|
|
round_id: str
|
|
|
|
|
author_user_id: str
|
|
|
|
|
author_display_name: str
|
|
|
|
|
body: str | None
|
|
|
|
|
image_url: str | None
|
|
|
|
|
created_at: str
|
2026-08-07 22:02:56 +02:00
|
|
|
reactions: list[ReactionSummary] = []
|
|
|
|
|
comment_count: int = 0
|
2026-08-11 09:10:17 +02:00
|
|
|
tags: list[TagOut] = []
|
2026-08-06 07:01:22 +02:00
|
|
|
|
|
|
|
|
|
2026-08-07 22:02:56 +02:00
|
|
|
def _row_to_round_message(
|
2026-08-11 09:10:17 +02:00
|
|
|
row, reactions: list[ReactionSummary] | None = None, comment_count: int = 0, tags: list[TagOut] | None = None
|
2026-08-07 22:02:56 +02:00
|
|
|
) -> RoundMessageOut:
|
2026-08-06 07:01:22 +02:00
|
|
|
return RoundMessageOut(
|
|
|
|
|
id=row["id"],
|
|
|
|
|
round_id=row["round_id"],
|
|
|
|
|
author_user_id=row["author_user_id"],
|
|
|
|
|
author_display_name=row["author_display_name"],
|
|
|
|
|
body=row["body"],
|
|
|
|
|
image_url=storage.public_url(row["image_key"]) if row["image_key"] else None,
|
|
|
|
|
created_at=row["created_at"].isoformat(),
|
2026-08-07 22:02:56 +02:00
|
|
|
reactions=reactions or [],
|
|
|
|
|
comment_count=comment_count,
|
2026-08-11 09:10:17 +02:00
|
|
|
tags=tags or [],
|
2026-08-06 07:01:22 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-08-07 22:02:56 +02:00
|
|
|
async def _round_message_reactions(
|
|
|
|
|
conn, message_ids: list[str], viewer_user_id: str | None
|
|
|
|
|
) -> dict[str, list[ReactionSummary]]:
|
|
|
|
|
"""Grupperte reaksjonsoppsummeringer for en batch med round_message-ID-er
|
|
|
|
|
-- unngår N+1 når en meldingsliste rendres."""
|
|
|
|
|
if not message_ids:
|
|
|
|
|
return {}
|
|
|
|
|
rows = await conn.fetch(
|
|
|
|
|
"""
|
|
|
|
|
SELECT rmr.round_message_id::text AS message_id, rmr.emoji, count(*) AS count,
|
|
|
|
|
COALESCE(bool_or(rmr.user_id = $2), false) AS reacted_by_me,
|
|
|
|
|
array_agg(
|
|
|
|
|
COALESCE(NULLIF(btrim(au.first_name || ' ' || au.last_name), ''), au.display_name)
|
|
|
|
|
ORDER BY rmr.created_at
|
|
|
|
|
) AS reactors
|
|
|
|
|
FROM round_message_reaction rmr
|
|
|
|
|
JOIN app_user au ON au.id = rmr.user_id
|
|
|
|
|
WHERE rmr.round_message_id = ANY($1::uuid[])
|
|
|
|
|
GROUP BY rmr.round_message_id, rmr.emoji
|
|
|
|
|
""",
|
|
|
|
|
message_ids,
|
|
|
|
|
viewer_user_id,
|
|
|
|
|
)
|
|
|
|
|
result: dict[str, list[ReactionSummary]] = {}
|
|
|
|
|
for r in rows:
|
|
|
|
|
result.setdefault(r["message_id"], []).append(
|
|
|
|
|
ReactionSummary(
|
|
|
|
|
emoji=r["emoji"],
|
|
|
|
|
count=r["count"],
|
|
|
|
|
reacted_by_me=r["reacted_by_me"],
|
|
|
|
|
reactors=list(r["reactors"]),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _round_message_comment_counts(conn, message_ids: list[str]) -> dict[str, int]:
|
|
|
|
|
if not message_ids:
|
|
|
|
|
return {}
|
|
|
|
|
rows = await conn.fetch(
|
|
|
|
|
"""
|
|
|
|
|
SELECT round_message_id::text AS message_id, count(*) AS count
|
|
|
|
|
FROM round_message_comment
|
|
|
|
|
WHERE round_message_id = ANY($1::uuid[])
|
|
|
|
|
GROUP BY round_message_id
|
|
|
|
|
""",
|
|
|
|
|
message_ids,
|
2026-08-06 07:01:22 +02:00
|
|
|
)
|
2026-08-07 22:02:56 +02:00
|
|
|
return {r["message_id"]: r["count"] for r in rows}
|
2026-08-06 07:01:22 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Runde-nivå kommentarer/bilder
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/rounds/{round_id}/messages", response_model=list[RoundMessageOut])
|
|
|
|
|
async def list_round_messages(
|
|
|
|
|
round_id: str,
|
|
|
|
|
user: CurrentUser | None = Depends(get_current_user_optional),
|
|
|
|
|
) -> list[RoundMessageOut]:
|
|
|
|
|
async with plain_connection() as conn:
|
|
|
|
|
await _get_viewable_round_or_404(conn, round_id, user.user_id if user else None)
|
2026-08-07 22:02:56 +02:00
|
|
|
# Nyeste øverst (2026-08-06, brukerens eksplisitte ønske) -- dette
|
|
|
|
|
# er en oppdateringsstrøm om runden, ikke en samtale man leser
|
|
|
|
|
# kronologisk fra start (ulikt lag-chatten, som beholder eldst-
|
|
|
|
|
# først -- se app/routers/messaging.py).
|
2026-08-06 07:01:22 +02:00
|
|
|
rows = await conn.fetch(
|
2026-08-07 22:02:56 +02:00
|
|
|
f"SELECT {_ROUND_MESSAGE_COLUMNS} FROM round_message WHERE round_id = $1 ORDER BY created_at DESC",
|
2026-08-06 07:01:22 +02:00
|
|
|
round_id,
|
|
|
|
|
)
|
2026-08-07 22:02:56 +02:00
|
|
|
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)
|
2026-08-11 09:10:17 +02:00
|
|
|
tags_by_id = await _tags_for_messages(conn, "round_message_tag", "round_message_id", message_ids)
|
2026-08-07 22:02:56 +02:00
|
|
|
return [
|
2026-08-11 09:10:17 +02:00
|
|
|
_row_to_round_message(r, reactions_by_id.get(r["id"]), comment_counts.get(r["id"], 0), tags_by_id.get(r["id"]))
|
2026-08-07 22:02:56 +02:00
|
|
|
for r in rows
|
|
|
|
|
]
|
2026-08-06 07:01:22 +02:00
|
|
|
|
|
|
|
|
|
2026-08-11 09:10:17 +02:00
|
|
|
@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]
|
|
|
|
|
|
|
|
|
|
|
2026-08-06 07:01:22 +02:00
|
|
|
@router.post("/rounds/{round_id}/messages", response_model=RoundMessageOut, status_code=201)
|
|
|
|
|
async def post_round_message(
|
|
|
|
|
round_id: str,
|
2026-08-10 07:00:11 +02:00
|
|
|
body: str | None = Form(default=None, max_length=2000),
|
2026-08-06 07:01:22 +02:00
|
|
|
image: UploadFile | None = File(default=None),
|
2026-08-11 09:10:17 +02:00
|
|
|
# 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),
|
2026-08-06 07:01:22 +02:00
|
|
|
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)
|
2026-08-11 09:10:17 +02:00
|
|
|
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.")
|
2026-08-06 07:01:22 +02:00
|
|
|
|
|
|
|
|
async with plain_connection() as conn, translate_db_errors():
|
|
|
|
|
# Selve synlighetssjekken ER post-sjekken -- "kan se = kan bidra",
|
|
|
|
|
# ingen snevrere gate i tillegg (ADR-044 Beslutning A).
|
|
|
|
|
await _get_viewable_round_or_404(conn, round_id, user.user_id)
|
|
|
|
|
|
|
|
|
|
image_key = None
|
|
|
|
|
if raw is not None:
|
|
|
|
|
try:
|
|
|
|
|
image_key = await storage.upload_image("round_messages", round_id, raw)
|
|
|
|
|
except storage.InvalidImageError:
|
|
|
|
|
raise app_error(400, "VALIDATION_FAILED", "Filen er ikke et gyldig bilde.")
|
|
|
|
|
|
|
|
|
|
display_name = await _resolve_round_message_author_name(conn, user.user_id)
|
2026-08-11 09:10:17 +02:00
|
|
|
valid_tags = await _validate_and_prepare_tags(conn, round_id, user.user_id, body or "", raw_tags)
|
2026-08-06 07:01:22 +02:00
|
|
|
|
|
|
|
|
row = await conn.fetchrow(
|
|
|
|
|
f"""
|
|
|
|
|
INSERT INTO round_message (round_id, author_user_id, author_display_name, body, image_key)
|
|
|
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
|
|
|
RETURNING {_ROUND_MESSAGE_COLUMNS}
|
|
|
|
|
""",
|
|
|
|
|
round_id,
|
|
|
|
|
user.user_id,
|
|
|
|
|
display_name,
|
|
|
|
|
body,
|
|
|
|
|
image_key,
|
|
|
|
|
)
|
2026-08-11 09:10:17 +02:00
|
|
|
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)
|
2026-08-06 07:01:22 +02:00
|
|
|
await broadcast_round_update(round_id)
|
|
|
|
|
return message
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/rounds/{round_id}/messages/{message_id}", status_code=204)
|
|
|
|
|
async def delete_round_message(
|
|
|
|
|
round_id: str,
|
|
|
|
|
message_id: str,
|
|
|
|
|
user: CurrentUser = Depends(get_current_user),
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Forfatteren selv, ELLER rundens eier (moderering -- rundens analog
|
|
|
|
|
til org-admin), samme modell som org-feedens `delete_feed_message`."""
|
|
|
|
|
async with plain_connection() as conn:
|
|
|
|
|
row = await conn.fetchrow(
|
|
|
|
|
"""
|
|
|
|
|
SELECT rm.author_user_id::text AS author_user_id, r.owner_user_id::text AS owner_user_id
|
|
|
|
|
FROM round_message rm JOIN round r ON r.id = rm.round_id
|
|
|
|
|
WHERE rm.id = $1 AND rm.round_id = $2
|
|
|
|
|
""",
|
|
|
|
|
message_id,
|
|
|
|
|
round_id,
|
|
|
|
|
)
|
|
|
|
|
if row is None:
|
|
|
|
|
raise app_error(404, "NOT_FOUND", "Innlegget finnes ikke.")
|
|
|
|
|
if user.user_id not in (row["author_user_id"], row["owner_user_id"]):
|
|
|
|
|
raise app_error(
|
|
|
|
|
403,
|
|
|
|
|
"NOT_MESSAGE_AUTHOR",
|
|
|
|
|
"Du kan kun slette dine egne innlegg (eller som rundeeier).",
|
|
|
|
|
)
|
|
|
|
|
await conn.execute("DELETE FROM round_message WHERE id = $1", message_id)
|
|
|
|
|
await broadcast_round_update(round_id)
|
|
|
|
|
|
|
|
|
|
|
2026-08-07 22:02:56 +02:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Reaksjoner (ADR-046) -- "kan se = kan bidra", samme som selve innlegget.
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _get_round_message_or_404(conn, round_id: str, message_id: str) -> None:
|
|
|
|
|
exists = await conn.fetchval(
|
|
|
|
|
"SELECT id FROM round_message WHERE id = $1 AND round_id = $2", message_id, round_id
|
|
|
|
|
)
|
|
|
|
|
if exists is None:
|
|
|
|
|
raise app_error(404, "NOT_FOUND", "Innlegget finnes ikke.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/rounds/{round_id}/messages/{message_id}/reaction", response_model=list[ReactionSummary])
|
|
|
|
|
async def set_round_message_reaction(
|
|
|
|
|
round_id: str,
|
|
|
|
|
message_id: str,
|
|
|
|
|
payload: ReactionIn,
|
|
|
|
|
user: CurrentUser = Depends(get_current_user),
|
|
|
|
|
) -> list[ReactionSummary]:
|
|
|
|
|
if payload.emoji not in ALLOWED_REACTION_EMOJIS:
|
|
|
|
|
raise app_error(400, "VALIDATION_FAILED", "Ugyldig emoji.")
|
|
|
|
|
async with plain_connection() as conn:
|
|
|
|
|
await _get_viewable_round_or_404(conn, round_id, user.user_id)
|
|
|
|
|
await _get_round_message_or_404(conn, round_id, message_id)
|
|
|
|
|
# Én reaksjon per bruker per innlegg -- bytte av emoji erstatter,
|
|
|
|
|
# stables ikke (ADR-046).
|
|
|
|
|
await conn.execute(
|
|
|
|
|
"""
|
|
|
|
|
INSERT INTO round_message_reaction (round_message_id, user_id, emoji)
|
|
|
|
|
VALUES ($1, $2, $3)
|
|
|
|
|
ON CONFLICT (round_message_id, user_id) DO UPDATE SET emoji = EXCLUDED.emoji, created_at = now()
|
|
|
|
|
""",
|
|
|
|
|
message_id,
|
|
|
|
|
user.user_id,
|
|
|
|
|
payload.emoji,
|
|
|
|
|
)
|
|
|
|
|
summaries = await _round_message_reactions(conn, [message_id], user.user_id)
|
|
|
|
|
return summaries.get(message_id, [])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/rounds/{round_id}/messages/{message_id}/reaction", response_model=list[ReactionSummary])
|
|
|
|
|
async def remove_round_message_reaction(
|
|
|
|
|
round_id: str,
|
|
|
|
|
message_id: str,
|
|
|
|
|
user: CurrentUser = Depends(get_current_user),
|
|
|
|
|
) -> list[ReactionSummary]:
|
|
|
|
|
async with plain_connection() as conn:
|
|
|
|
|
await _get_viewable_round_or_404(conn, round_id, user.user_id)
|
|
|
|
|
await _get_round_message_or_404(conn, round_id, message_id)
|
|
|
|
|
await conn.execute(
|
|
|
|
|
"DELETE FROM round_message_reaction WHERE round_message_id = $1 AND user_id = $2",
|
|
|
|
|
message_id,
|
|
|
|
|
user.user_id,
|
|
|
|
|
)
|
|
|
|
|
summaries = await _round_message_reactions(conn, [message_id], user.user_id)
|
|
|
|
|
return summaries.get(message_id, [])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Trådede kommentarer (ADR-046) -- flat lagring (parent_comment_id), frontend
|
|
|
|
|
# bygger tre-strukturen. Kronologisk (eldst først) -- en samtaletråd leses
|
|
|
|
|
# top-til-bunn, ulikt de kronologisk OMVENDTE innleggs-/meldingsstrømmene
|
|
|
|
|
# ("nyeste først, over alt", 2026-08-06) som dette henger under.
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
_ROUND_MESSAGE_COMMENT_COLUMNS = """
|
|
|
|
|
id::text, round_message_id::text, parent_comment_id::text, author_user_id::text,
|
|
|
|
|
author_display_name, body, created_at
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class CommentIn(BaseModel):
|
2026-08-10 07:00:11 +02:00
|
|
|
body: str = Field(min_length=1, max_length=2000)
|
2026-08-07 22:02:56 +02:00
|
|
|
parent_comment_id: str | None = None
|
2026-08-11 09:10:17 +02:00
|
|
|
tags: list[TagIn] = []
|
2026-08-07 22:02:56 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class CommentOut(BaseModel):
|
|
|
|
|
id: str
|
|
|
|
|
round_message_id: str
|
|
|
|
|
parent_comment_id: str | None
|
|
|
|
|
author_user_id: str
|
|
|
|
|
author_display_name: str
|
|
|
|
|
body: str
|
|
|
|
|
created_at: str
|
2026-08-11 09:10:17 +02:00
|
|
|
tags: list[TagOut] = []
|
2026-08-07 22:02:56 +02:00
|
|
|
|
|
|
|
|
|
2026-08-11 09:10:17 +02:00
|
|
|
def _row_to_round_message_comment(row, tags: list[TagOut] | None = None) -> CommentOut:
|
2026-08-07 22:02:56 +02:00
|
|
|
return CommentOut(
|
|
|
|
|
id=row["id"],
|
|
|
|
|
round_message_id=row["round_message_id"],
|
|
|
|
|
parent_comment_id=row["parent_comment_id"],
|
|
|
|
|
author_user_id=row["author_user_id"],
|
|
|
|
|
author_display_name=row["author_display_name"],
|
|
|
|
|
body=row["body"],
|
|
|
|
|
created_at=row["created_at"].isoformat(),
|
2026-08-11 09:10:17 +02:00
|
|
|
tags=tags or [],
|
2026-08-07 22:02:56 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/rounds/{round_id}/messages/{message_id}/comments", response_model=list[CommentOut])
|
|
|
|
|
async def list_round_message_comments(
|
|
|
|
|
round_id: str,
|
|
|
|
|
message_id: str,
|
|
|
|
|
user: CurrentUser | None = Depends(get_current_user_optional),
|
|
|
|
|
) -> list[CommentOut]:
|
|
|
|
|
async with plain_connection() as conn:
|
|
|
|
|
await _get_viewable_round_or_404(conn, round_id, user.user_id if user else None)
|
|
|
|
|
await _get_round_message_or_404(conn, round_id, message_id)
|
|
|
|
|
rows = await conn.fetch(
|
|
|
|
|
f"""
|
|
|
|
|
SELECT {_ROUND_MESSAGE_COMMENT_COLUMNS} FROM round_message_comment
|
|
|
|
|
WHERE round_message_id = $1 ORDER BY created_at ASC
|
|
|
|
|
""",
|
|
|
|
|
message_id,
|
|
|
|
|
)
|
2026-08-11 09:10:17 +02:00
|
|
|
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]
|
2026-08-07 22:02:56 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post(
|
|
|
|
|
"/rounds/{round_id}/messages/{message_id}/comments", response_model=CommentOut, status_code=201
|
|
|
|
|
)
|
|
|
|
|
async def post_round_message_comment(
|
|
|
|
|
round_id: str,
|
|
|
|
|
message_id: str,
|
|
|
|
|
payload: CommentIn,
|
|
|
|
|
user: CurrentUser = Depends(get_current_user),
|
|
|
|
|
) -> CommentOut:
|
|
|
|
|
body = payload.body.strip()
|
|
|
|
|
if not body:
|
|
|
|
|
raise app_error(400, "VALIDATION_FAILED", "Kommentaren kan ikke være tom.")
|
|
|
|
|
|
|
|
|
|
async with plain_connection() as conn, translate_db_errors():
|
|
|
|
|
await _get_viewable_round_or_404(conn, round_id, user.user_id)
|
|
|
|
|
await _get_round_message_or_404(conn, round_id, message_id)
|
|
|
|
|
if payload.parent_comment_id is not None:
|
|
|
|
|
parent_exists = await conn.fetchval(
|
|
|
|
|
"SELECT id FROM round_message_comment WHERE id = $1 AND round_message_id = $2",
|
|
|
|
|
payload.parent_comment_id,
|
|
|
|
|
message_id,
|
|
|
|
|
)
|
|
|
|
|
if parent_exists is None:
|
|
|
|
|
raise app_error(404, "NOT_FOUND", "Kommentaren du svarer på finnes ikke.")
|
|
|
|
|
|
|
|
|
|
display_name = await _resolve_round_message_author_name(conn, user.user_id)
|
2026-08-11 09:10:17 +02:00
|
|
|
valid_tags = await _validate_and_prepare_tags(conn, round_id, user.user_id, body, payload.tags)
|
2026-08-07 22:02:56 +02:00
|
|
|
row = await conn.fetchrow(
|
|
|
|
|
f"""
|
|
|
|
|
INSERT INTO round_message_comment
|
|
|
|
|
(round_message_id, parent_comment_id, author_user_id, author_display_name, body)
|
|
|
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
|
|
|
RETURNING {_ROUND_MESSAGE_COMMENT_COLUMNS}
|
|
|
|
|
""",
|
|
|
|
|
message_id,
|
|
|
|
|
payload.parent_comment_id,
|
|
|
|
|
user.user_id,
|
|
|
|
|
display_name,
|
|
|
|
|
body,
|
|
|
|
|
)
|
2026-08-11 09:10:17 +02:00
|
|
|
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)
|
2026-08-07 22:02:56 +02:00
|
|
|
# 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
|
|
|
|
|
# "noe endret seg"-refetch for ALLE meldinger på siden ved hver eneste
|
|
|
|
|
# kommentar, og med det kollapset enhver allerede utvidet tråd andre
|
|
|
|
|
# steder på siden -- utilsiktet UX-regresjon oppdaget under
|
|
|
|
|
# nettleserverifisering.
|
|
|
|
|
return comment
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/rounds/{round_id}/messages/{message_id}/comments/{comment_id}", status_code=204)
|
|
|
|
|
async def delete_round_message_comment(
|
|
|
|
|
round_id: str,
|
|
|
|
|
message_id: str,
|
|
|
|
|
comment_id: str,
|
|
|
|
|
user: CurrentUser = Depends(get_current_user),
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Forfatteren selv, ELLER rundens eier -- samme modell som selve
|
|
|
|
|
innlegget (`delete_round_message`). Kaskade-sletter eventuelle svar
|
|
|
|
|
under (FK ON DELETE CASCADE, ADR-046)."""
|
|
|
|
|
async with plain_connection() as conn:
|
|
|
|
|
row = await conn.fetchrow(
|
|
|
|
|
"""
|
|
|
|
|
SELECT c.author_user_id::text AS author_user_id, r.owner_user_id::text AS owner_user_id
|
|
|
|
|
FROM round_message_comment c
|
|
|
|
|
JOIN round_message rm ON rm.id = c.round_message_id
|
|
|
|
|
JOIN round r ON r.id = rm.round_id
|
|
|
|
|
WHERE c.id = $1 AND c.round_message_id = $2 AND rm.round_id = $3
|
|
|
|
|
""",
|
|
|
|
|
comment_id,
|
|
|
|
|
message_id,
|
|
|
|
|
round_id,
|
|
|
|
|
)
|
|
|
|
|
if row is None:
|
|
|
|
|
raise app_error(404, "NOT_FOUND", "Kommentaren finnes ikke.")
|
|
|
|
|
if user.user_id not in (row["author_user_id"], row["owner_user_id"]):
|
|
|
|
|
raise app_error(
|
|
|
|
|
403,
|
|
|
|
|
"NOT_COMMENT_AUTHOR",
|
|
|
|
|
"Du kan kun slette dine egne kommentarer (eller som rundeeier).",
|
|
|
|
|
)
|
|
|
|
|
await conn.execute("DELETE FROM round_message_comment WHERE id = $1", comment_id)
|
|
|
|
|
# INGEN broadcast_round_update her -- se samme begrunnelse i
|
|
|
|
|
# post_round_message_comment over.
|
|
|
|
|
|
|
|
|
|
|
2026-08-06 07:01:22 +02:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Samlet feed -- egne runder + venners synlige runder (ADR-036 fase 2)
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FeedEntryOut(BaseModel):
|
|
|
|
|
id: str
|
|
|
|
|
round_id: str
|
|
|
|
|
round_owner_user_id: str
|
|
|
|
|
round_owner_display_name: str
|
|
|
|
|
course_name: str
|
|
|
|
|
played_at: str
|
|
|
|
|
author_user_id: str
|
|
|
|
|
author_display_name: str
|
|
|
|
|
body: str | None
|
|
|
|
|
image_url: str | None
|
|
|
|
|
created_at: str
|
2026-08-07 22:02:56 +02:00
|
|
|
reactions: list[ReactionSummary] = []
|
|
|
|
|
comment_count: int = 0
|
@-tagging (frontend): egen-implementert etter V0-credits tomme
Fullfører ADR-063 -- backend var allerede committet (206beef). Bruker
ba eksplisitt om at Claude selv implementerer frontend-promptet siden
V0-kreditter var oppbrukt.
lib/mentions.ts: ren offset-/diff-logikk (deteksjon, innsetting,
diff-justering ved redigering, tekst-segmentering), 15 enhetstester.
components/mention-input.tsx: MentionTextarea (autocomplete-dropdown,
tastaturnavigasjon) + TaggedText (lenker til /my-friends/{id}). Koblet
inn i round-messages.tsx (innlegg) og post-engagement.tsx (kommentarer,
delt av flere meldingssystemer -- roundId/tags gjort valgfrie for å
ikke bryte de org-scopede callerne uten tagging).
Rettet også et hull oppdaget underveis: backendens GET /feed manglet
tags (kun list_round_messages/comments hadde det) -- det er nettopp
/my-feed-siden som var det opprinnelige brukseksempelet.
Scratch-verifisert (egen DB/rolle/MinIO/API-/frontend-container, to
ekte testbrukere, venn-relasjon, felles runde): autocomplete, tagging
i både innlegg og kommentar, rendring som lenke i rundevisning OG i
aggregert feed, varsel opprettet, personvern-scoping bekreftet (kun
faktiske relasjoner tilbys, ikke seg selv, ikke fremmede), lys+mørk.
tsc --noEmit rent, 45/45 vitest grønt. teecup_db urørt.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 10:15:02 +02:00
|
|
|
tags: list[TagOut] = []
|
2026-08-06 07:01:22 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/feed", response_model=list[FeedEntryOut])
|
|
|
|
|
async def get_feed(
|
|
|
|
|
before: datetime | None = Query(default=None),
|
|
|
|
|
limit: int = Query(default=20, ge=1, le=50),
|
|
|
|
|
user: CurrentUser = Depends(get_current_user),
|
|
|
|
|
) -> list[FeedEntryOut]:
|
|
|
|
|
# Reverst av `_can_view_round` (starter fra VIEWEREN, ikke fra
|
|
|
|
|
# rundeeieren) -- samme retning og samme 'friends'-predikat som
|
|
|
|
|
# `list_friends_on_course` (rounds.py), kopiert inn her i stedet for
|
|
|
|
|
# faktorisert ut, samme bevisste "grei duplisering på denne skalaen"
|
|
|
|
|
# begrunnelse som der (se rounds.py sin kommentar over
|
|
|
|
|
# `list_friends_on_course`). Justeringer mot den funksjonen: egne
|
|
|
|
|
# runder er ALLTID med (uansett visibility_mode), ingen "spiller
|
|
|
|
|
# nå/siste 24t"-filter -- dette er en all-time, paginert liste av
|
|
|
|
|
# round_message-rader, ikke en "hvem er live nå"-widget.
|
|
|
|
|
async with plain_connection() as conn:
|
|
|
|
|
rows = await conn.fetch(
|
|
|
|
|
"""
|
|
|
|
|
SELECT rm.id::text AS id, rm.round_id::text AS round_id,
|
|
|
|
|
r.owner_user_id::text AS round_owner_user_id,
|
|
|
|
|
COALESCE(owner.first_name || ' ' || owner.last_name, owner.display_name) AS round_owner_display_name,
|
|
|
|
|
r.course_name_snapshot AS course_name, r.played_at,
|
|
|
|
|
rm.author_user_id::text AS author_user_id, rm.author_display_name,
|
|
|
|
|
rm.body, rm.image_key, rm.created_at
|
|
|
|
|
FROM round_message rm
|
|
|
|
|
JOIN round r ON r.id = rm.round_id
|
|
|
|
|
JOIN app_user owner ON owner.id = r.owner_user_id
|
|
|
|
|
WHERE (
|
|
|
|
|
r.owner_user_id = $1
|
|
|
|
|
OR EXISTS (
|
|
|
|
|
SELECT 1 FROM round_participant rp
|
|
|
|
|
WHERE rp.round_id = r.id AND rp.user_id = $1
|
|
|
|
|
)
|
|
|
|
|
OR r.visibility_mode = 'public'
|
|
|
|
|
OR (
|
|
|
|
|
r.visibility_mode = 'friends'
|
|
|
|
|
AND EXISTS (
|
|
|
|
|
SELECT 1 FROM friendship f
|
|
|
|
|
WHERE f.status = 'accepted'
|
|
|
|
|
AND ((f.requester_user_id = $1 AND f.addressee_user_id = r.owner_user_id)
|
|
|
|
|
OR (f.requester_user_id = r.owner_user_id AND f.addressee_user_id = $1))
|
|
|
|
|
)
|
|
|
|
|
AND EXISTS (
|
|
|
|
|
SELECT 1 FROM friend_categorization fc
|
|
|
|
|
WHERE fc.owner_user_id = r.owner_user_id AND fc.friend_user_id = $1
|
2026-08-08 12:37:09 +02:00
|
|
|
AND fc.category IN (
|
2026-08-06 07:01:22 +02:00
|
|
|
SELECT category FROM round_visible_category WHERE round_id = r.id
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
AND ($2::timestamptz IS NULL OR rm.created_at < $2::timestamptz)
|
|
|
|
|
ORDER BY rm.created_at DESC
|
|
|
|
|
LIMIT $3
|
|
|
|
|
""",
|
|
|
|
|
user.user_id,
|
|
|
|
|
before,
|
|
|
|
|
limit,
|
|
|
|
|
)
|
2026-08-07 22:02:56 +02:00
|
|
|
entry_ids = [r["id"] for r in rows]
|
|
|
|
|
reactions_by_id = await _round_message_reactions(conn, entry_ids, user.user_id)
|
|
|
|
|
comment_counts = await _round_message_comment_counts(conn, entry_ids)
|
@-tagging (frontend): egen-implementert etter V0-credits tomme
Fullfører ADR-063 -- backend var allerede committet (206beef). Bruker
ba eksplisitt om at Claude selv implementerer frontend-promptet siden
V0-kreditter var oppbrukt.
lib/mentions.ts: ren offset-/diff-logikk (deteksjon, innsetting,
diff-justering ved redigering, tekst-segmentering), 15 enhetstester.
components/mention-input.tsx: MentionTextarea (autocomplete-dropdown,
tastaturnavigasjon) + TaggedText (lenker til /my-friends/{id}). Koblet
inn i round-messages.tsx (innlegg) og post-engagement.tsx (kommentarer,
delt av flere meldingssystemer -- roundId/tags gjort valgfrie for å
ikke bryte de org-scopede callerne uten tagging).
Rettet også et hull oppdaget underveis: backendens GET /feed manglet
tags (kun list_round_messages/comments hadde det) -- det er nettopp
/my-feed-siden som var det opprinnelige brukseksempelet.
Scratch-verifisert (egen DB/rolle/MinIO/API-/frontend-container, to
ekte testbrukere, venn-relasjon, felles runde): autocomplete, tagging
i både innlegg og kommentar, rendring som lenke i rundevisning OG i
aggregert feed, varsel opprettet, personvern-scoping bekreftet (kun
faktiske relasjoner tilbys, ikke seg selv, ikke fremmede), lys+mørk.
tsc --noEmit rent, 45/45 vitest grønt. teecup_db urørt.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 10:15:02 +02:00
|
|
|
tags_by_id = await _tags_for_messages(conn, "round_message_tag", "round_message_id", entry_ids)
|
2026-08-06 07:01:22 +02:00
|
|
|
return [
|
|
|
|
|
FeedEntryOut(
|
|
|
|
|
id=r["id"],
|
|
|
|
|
round_id=r["round_id"],
|
|
|
|
|
round_owner_user_id=r["round_owner_user_id"],
|
|
|
|
|
round_owner_display_name=r["round_owner_display_name"],
|
|
|
|
|
course_name=r["course_name"],
|
|
|
|
|
played_at=r["played_at"].isoformat(),
|
|
|
|
|
author_user_id=r["author_user_id"],
|
|
|
|
|
author_display_name=r["author_display_name"],
|
|
|
|
|
body=r["body"],
|
|
|
|
|
image_url=storage.public_url(r["image_key"]) if r["image_key"] else None,
|
|
|
|
|
created_at=r["created_at"].isoformat(),
|
2026-08-07 22:02:56 +02:00
|
|
|
reactions=reactions_by_id.get(r["id"], []),
|
|
|
|
|
comment_count=comment_counts.get(r["id"], 0),
|
@-tagging (frontend): egen-implementert etter V0-credits tomme
Fullfører ADR-063 -- backend var allerede committet (206beef). Bruker
ba eksplisitt om at Claude selv implementerer frontend-promptet siden
V0-kreditter var oppbrukt.
lib/mentions.ts: ren offset-/diff-logikk (deteksjon, innsetting,
diff-justering ved redigering, tekst-segmentering), 15 enhetstester.
components/mention-input.tsx: MentionTextarea (autocomplete-dropdown,
tastaturnavigasjon) + TaggedText (lenker til /my-friends/{id}). Koblet
inn i round-messages.tsx (innlegg) og post-engagement.tsx (kommentarer,
delt av flere meldingssystemer -- roundId/tags gjort valgfrie for å
ikke bryte de org-scopede callerne uten tagging).
Rettet også et hull oppdaget underveis: backendens GET /feed manglet
tags (kun list_round_messages/comments hadde det) -- det er nettopp
/my-feed-siden som var det opprinnelige brukseksempelet.
Scratch-verifisert (egen DB/rolle/MinIO/API-/frontend-container, to
ekte testbrukere, venn-relasjon, felles runde): autocomplete, tagging
i både innlegg og kommentar, rendring som lenke i rundevisning OG i
aggregert feed, varsel opprettet, personvern-scoping bekreftet (kun
faktiske relasjoner tilbys, ikke seg selv, ikke fremmede), lys+mørk.
tsc --noEmit rent, 45/45 vitest grønt. teecup_db urørt.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 10:15:02 +02:00
|
|
|
tags=tags_by_id.get(r["id"], []),
|
2026-08-06 07:01:22 +02:00
|
|
|
)
|
|
|
|
|
for r in rows
|
|
|
|
|
]
|