round.visibility_mode (privat som trygg standard) + ny tabell for hvilke venne-kategorier som får se en gitt runde. Kjernesjekken (_can_view_round) viste seg å være en ren utvidelse av den eksisterende eier/medspiller-sjekken, så fire lese-endepunkter ble omstrukturert til delte funksjoner og gjenbrukt av sju nye offentlige endepunkter + et nytt offentlig sanntids-WS — i stedet for å bygge alt parallelt fra bunnen. Ny vennprofil-side (/my-friends/[id]) — navn/avatar/HCP/hjemmeklubb + liste over personens synlige runder, "pågår nå" øverst. Ny read-only live-visning (/watch/[id]) for tredjeparter — matchstatus, skins-tavle eller individuell rangering avhengig av spilleform. Synlighetsvelger lagt til både i opprett-runde og rediger-runde. Verifisert grundig: 191 automatiserte sjekker (inkl. full regresjon av to eksisterende testsuiter) + en fullstendig nettleser-gjennomgang med tre reelle brukere i separate innloggingskontekster — inkludert en helt anonym leser som beviste at "offentlig" faktisk betyr offentlig, og en reell venn/kategori-negativ-kontroll som beviste at feil kategori korrekt nekter tilgang. Migrasjon kjørt mot ekte database (kun additiv), begge containere rullet ut, teeoff.no upåvirket.
387 lines
16 KiB
Python
387 lines
16 KiB
Python
"""
|
|
Venner-kjernen (ADR-036, fase 1).
|
|
|
|
Eid av BRUKERE (`app_user.id`), ikke organisasjon -- INGEN RLS på
|
|
`friendship`/`friend_categorization`, samme `plain_connection()`-mønster som
|
|
personlig profil/HCP-historikk/frittstående runder (ADR-033 Beslutning A).
|
|
|
|
Vennskap er GJENSIDIG (forespørsel + aksept). Kategorisering
|
|
("Golfvenner" osv.) er derimot HELT SEPARAT og PRIVAT -- eid av den som
|
|
kategoriserer, den andre parten får aldri vite hvilken/hvilke kategorier de
|
|
er satt i (ADR-036 Beslutning A). `/people/search` er tiered (venner -> samme
|
|
hjemmeklubb -> samme land -> globalt) og navnerekkefølge-uavhengig, delt
|
|
mellom "finn en venn" her og en fremtidig "legg til medspiller på en runde"
|
|
(ADR-036 fase 3, ikke bygget ennå).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Literal
|
|
|
|
from fastapi import APIRouter, Depends, Query
|
|
from pydantic import BaseModel
|
|
|
|
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 .notifications import create_notification
|
|
|
|
router = APIRouter(tags=["friends"])
|
|
|
|
Category = Literal[
|
|
"spouse",
|
|
"close_family",
|
|
"extended_family",
|
|
"close_friends",
|
|
"golf_friends",
|
|
"colleagues",
|
|
"business",
|
|
"classmates",
|
|
"acquaintances",
|
|
"other",
|
|
]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Personsøk -- delt mellom "finn venn" og (senere) "legg til medspiller"
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class PersonMatch(BaseModel):
|
|
id: str
|
|
first_name: str
|
|
last_name: str
|
|
avatar_url: str | None
|
|
home_club: str | None
|
|
# HCP-indeks (2026-07-26) -- brukt av "legg til medspiller"-søket på en
|
|
# frittstående runde, slik at organisatoren kan se HCP FØR de velger en
|
|
# spiller, ikke bare etterpå. None hvis personen ikke har registrert HCP.
|
|
handicap_index: float | None
|
|
|
|
|
|
def _avatar_url(avatar_key: str | None) -> str | None:
|
|
return storage.public_url(avatar_key) if avatar_key else None
|
|
|
|
|
|
@router.get("/people/search", response_model=list[PersonMatch])
|
|
async def search_people(
|
|
q: str = Query(default=""),
|
|
user: CurrentUser = Depends(get_current_user),
|
|
) -> list[PersonMatch]:
|
|
# Minimum 2 tegn før noe returneres -- hindrer triviell enumerering av
|
|
# alle brukere via ett enkelt bokstavsøk (ADR-036, foreslått default).
|
|
trimmed = q.strip()
|
|
tokens = [t for t in trimmed.split() if t][:4]
|
|
if len(trimmed) < 2 or not tokens:
|
|
return []
|
|
|
|
async with plain_connection() as conn:
|
|
me_row = await conn.fetchrow("SELECT home_club, country FROM app_user WHERE id = $1", user.user_id)
|
|
home_club = me_row["home_club"] if me_row else None
|
|
country = me_row["country"] if me_row else None
|
|
|
|
# Navnerekkefølge-uavhengig: for HVER token må minst ett av
|
|
# for-/etternavn matche -- ALLE tokens må matche (AND på tvers av
|
|
# tokens, OR på tvers av feltene per token). Gjør at "Erol
|
|
# Haagenrud" og "Haagenrud Erol" gir identisk treff.
|
|
params: list[object] = [user.user_id]
|
|
token_conditions = []
|
|
for t in tokens:
|
|
params.append(f"{t}%")
|
|
idx = len(params)
|
|
token_conditions.append(f"(u.first_name ILIKE ${idx} OR u.last_name ILIKE ${idx})")
|
|
token_sql = " AND ".join(token_conditions)
|
|
|
|
params.append(home_club)
|
|
home_club_idx = len(params)
|
|
params.append(country)
|
|
country_idx = len(params)
|
|
|
|
rows = await conn.fetch(
|
|
f"""
|
|
SELECT u.id::text AS id, u.first_name, u.last_name, u.avatar_key, u.home_club,
|
|
u.handicap_index::float AS handicap_index,
|
|
CASE
|
|
WHEN f.id IS NOT NULL THEN 0
|
|
WHEN u.home_club = ${home_club_idx} THEN 1
|
|
WHEN u.country = ${country_idx} THEN 2
|
|
ELSE 3
|
|
END AS priority
|
|
FROM app_user u
|
|
LEFT JOIN friendship f
|
|
ON f.status = 'accepted'
|
|
AND ((f.requester_user_id = $1 AND f.addressee_user_id = u.id)
|
|
OR (f.addressee_user_id = $1 AND f.requester_user_id = u.id))
|
|
WHERE u.id <> $1
|
|
AND u.first_name IS NOT NULL AND u.last_name IS NOT NULL
|
|
AND {token_sql}
|
|
ORDER BY priority, u.first_name, u.last_name
|
|
LIMIT 20
|
|
""",
|
|
*params,
|
|
)
|
|
|
|
return [
|
|
PersonMatch(
|
|
id=r["id"], first_name=r["first_name"], last_name=r["last_name"],
|
|
avatar_url=_avatar_url(r["avatar_key"]), home_club=r["home_club"],
|
|
handicap_index=r["handicap_index"],
|
|
)
|
|
for r in rows
|
|
]
|
|
|
|
|
|
@router.get("/people/{user_id}", response_model=PersonMatch)
|
|
async def get_person(
|
|
user_id: str,
|
|
user: CurrentUser | None = Depends(get_current_user_optional),
|
|
) -> PersonMatch:
|
|
"""Enkeltperson-oppslag (ADR-036 fase 2, 2026-07-28) -- brukt av
|
|
vennprofil-siden til å vise navn/avatar/hjemmeklubb/HCP i headeren.
|
|
Samme felt-sett/sensitivitetsnivå som /people/search (aldri e-post/
|
|
mobil/fødselsdato) -- derfor OPTIONALT autentisert, ikke
|
|
`get_current_user`: en runde satt til 'public' (ADR-036 Beslutning B)
|
|
skal kunne nås av en anonym leser via en delt lenke, og profilsiden som
|
|
leder dit må da også fungere anonymt, ellers ville "offentlig" runde
|
|
vært offentlig i praksis, men uoppdagelig uten innlogging."""
|
|
async with plain_connection() as conn:
|
|
row = await conn.fetchrow(
|
|
"SELECT id::text AS id, first_name, last_name, avatar_key, home_club, "
|
|
"handicap_index::float AS handicap_index FROM app_user WHERE id = $1",
|
|
user_id,
|
|
)
|
|
if row is None or row["first_name"] is None or row["last_name"] is None:
|
|
raise app_error(404, "NOT_FOUND", "Fant ikke brukeren.")
|
|
return PersonMatch(
|
|
id=row["id"], first_name=row["first_name"], last_name=row["last_name"],
|
|
avatar_url=_avatar_url(row["avatar_key"]), home_club=row["home_club"],
|
|
handicap_index=row["handicap_index"],
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Vennskap -- forespørsel/aksept/fjern
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class FriendRequestCreate(BaseModel):
|
|
addressee_user_id: str
|
|
|
|
|
|
class FriendEntry(BaseModel):
|
|
friendship_id: str
|
|
user_id: str
|
|
first_name: str | None
|
|
last_name: str | None
|
|
avatar_url: str | None
|
|
home_club: str | None
|
|
categories: list[str]
|
|
|
|
|
|
class PendingRequest(BaseModel):
|
|
friendship_id: str
|
|
user_id: str
|
|
first_name: str | None
|
|
last_name: str | None
|
|
avatar_url: str | None
|
|
created_at: str
|
|
|
|
|
|
class FriendsOut(BaseModel):
|
|
friends: list[FriendEntry]
|
|
incoming_requests: list[PendingRequest]
|
|
outgoing_requests: list[PendingRequest]
|
|
|
|
|
|
@router.post("/friends", status_code=201)
|
|
async def send_friend_request(body: FriendRequestCreate, user: CurrentUser = Depends(get_current_user)) -> dict[str, str]:
|
|
if body.addressee_user_id == user.user_id:
|
|
raise app_error(400, "VALIDATION_FAILED", "Du kan ikke sende en venneforespørsel til deg selv.")
|
|
async with plain_connection() as conn:
|
|
target = await conn.fetchrow("SELECT id FROM app_user WHERE id = $1", body.addressee_user_id)
|
|
if target is None:
|
|
raise app_error(404, "NOT_FOUND", "Fant ikke brukeren.")
|
|
async with translate_db_errors():
|
|
row = await conn.fetchrow(
|
|
"INSERT INTO friendship (requester_user_id, addressee_user_id) VALUES ($1, $2) RETURNING id::text AS id",
|
|
user.user_id,
|
|
body.addressee_user_id,
|
|
)
|
|
requester_name = await conn.fetchval("SELECT display_name FROM app_user WHERE id = $1", user.user_id)
|
|
await create_notification(
|
|
conn,
|
|
user_id=body.addressee_user_id,
|
|
type="friend",
|
|
message=f"{requester_name} har sendt deg en venneforespørsel.",
|
|
link_path="/my-friends",
|
|
)
|
|
return {"id": row["id"]}
|
|
|
|
|
|
@router.post("/friends/{friendship_id}/accept")
|
|
async def accept_friend_request(friendship_id: str, user: CurrentUser = Depends(get_current_user)) -> dict[str, bool]:
|
|
async with plain_connection() as conn:
|
|
row = await conn.fetchrow(
|
|
"SELECT addressee_user_id::text AS addressee_user_id, status FROM friendship WHERE id = $1",
|
|
friendship_id,
|
|
)
|
|
if row is None:
|
|
raise app_error(404, "NOT_FOUND", "Forespørselen finnes ikke.")
|
|
if row["addressee_user_id"] != user.user_id:
|
|
raise app_error(403, "NOT_AUTHORIZED", "Du kan ikke svare på denne forespørselen.")
|
|
if row["status"] == "accepted":
|
|
raise app_error(409, "ALREADY_ACCEPTED", "Forespørselen er allerede akseptert.")
|
|
await conn.execute("UPDATE friendship SET status = 'accepted', responded_at = now() WHERE id = $1", friendship_id)
|
|
addressee_name = await conn.fetchval("SELECT display_name FROM app_user WHERE id = $1", user.user_id)
|
|
requester_id = await conn.fetchval("SELECT requester_user_id::text FROM friendship WHERE id = $1", friendship_id)
|
|
await create_notification(
|
|
conn,
|
|
user_id=requester_id,
|
|
type="friend",
|
|
message=f"{addressee_name} godtok venneforespørselen din.",
|
|
link_path="/my-friends",
|
|
)
|
|
return {"ok": True}
|
|
|
|
|
|
@router.delete("/friends/{friendship_id}", status_code=204)
|
|
async def remove_friendship(friendship_id: str, user: CurrentUser = Depends(get_current_user)) -> None:
|
|
"""Dekker tre tilfeller med samme kall: avslå en innkommende
|
|
forespørsel, kansellere sin egen utgående forespørsel, eller avslutte et
|
|
allerede akseptert vennskap -- alle er "denne raden skal ikke lenger
|
|
finnes", uansett status. Idempotent (ukjent id -> stille 204, samme
|
|
mønster som ellers i appen)."""
|
|
async with plain_connection() as conn:
|
|
row = await conn.fetchrow(
|
|
"SELECT requester_user_id::text AS requester_user_id, addressee_user_id::text AS addressee_user_id FROM friendship WHERE id = $1",
|
|
friendship_id,
|
|
)
|
|
if row is None:
|
|
return
|
|
if user.user_id not in (row["requester_user_id"], row["addressee_user_id"]):
|
|
raise app_error(403, "NOT_AUTHORIZED", "Du er ikke part i dette vennskapet.")
|
|
async with conn.transaction():
|
|
await conn.execute("DELETE FROM friendship WHERE id = $1", friendship_id)
|
|
# friend_categorization refererer til bruker-id-par direkte, ikke
|
|
# friendship.id -- rydder derfor eksplisitt begge veier her.
|
|
await conn.execute(
|
|
"DELETE FROM friend_categorization WHERE (owner_user_id, friend_user_id) IN (($1::uuid,$2::uuid),($2::uuid,$1::uuid))",
|
|
row["requester_user_id"],
|
|
row["addressee_user_id"],
|
|
)
|
|
|
|
|
|
@router.get("/friends", response_model=FriendsOut)
|
|
async def list_friends(user: CurrentUser = Depends(get_current_user)) -> FriendsOut:
|
|
async with plain_connection() as conn:
|
|
accepted_rows = await conn.fetch(
|
|
"""
|
|
SELECT f.id::text AS friendship_id,
|
|
(CASE WHEN f.requester_user_id = $1 THEN f.addressee_user_id ELSE f.requester_user_id END)::text AS user_id
|
|
FROM friendship f
|
|
WHERE f.status = 'accepted' AND (f.requester_user_id = $1 OR f.addressee_user_id = $1)
|
|
""",
|
|
user.user_id,
|
|
)
|
|
incoming_rows = await conn.fetch(
|
|
"SELECT id::text AS friendship_id, requester_user_id::text AS user_id, created_at FROM friendship WHERE status = 'pending' AND addressee_user_id = $1",
|
|
user.user_id,
|
|
)
|
|
outgoing_rows = await conn.fetch(
|
|
"SELECT id::text AS friendship_id, addressee_user_id::text AS user_id, created_at FROM friendship WHERE status = 'pending' AND requester_user_id = $1",
|
|
user.user_id,
|
|
)
|
|
|
|
all_ids = [r["user_id"] for r in (*accepted_rows, *incoming_rows, *outgoing_rows)]
|
|
user_rows: dict[str, object] = {}
|
|
if all_ids:
|
|
urows = await conn.fetch(
|
|
"SELECT id::text AS id, first_name, last_name, avatar_key, home_club FROM app_user WHERE id = ANY($1::uuid[])",
|
|
all_ids,
|
|
)
|
|
user_rows = {r["id"]: r for r in urows}
|
|
|
|
categories_by_friend: dict[str, list[str]] = {}
|
|
if accepted_rows:
|
|
crows = await conn.fetch(
|
|
"SELECT friend_user_id::text AS friend_user_id, category FROM friend_categorization WHERE owner_user_id = $1",
|
|
user.user_id,
|
|
)
|
|
for c in crows:
|
|
categories_by_friend.setdefault(c["friend_user_id"], []).append(c["category"])
|
|
|
|
friends = [
|
|
FriendEntry(
|
|
friendship_id=r["friendship_id"],
|
|
user_id=r["user_id"],
|
|
first_name=u["first_name"],
|
|
last_name=u["last_name"],
|
|
avatar_url=_avatar_url(u["avatar_key"]),
|
|
home_club=u["home_club"],
|
|
categories=categories_by_friend.get(r["user_id"], []),
|
|
)
|
|
for r in accepted_rows
|
|
if (u := user_rows.get(r["user_id"])) is not None
|
|
]
|
|
incoming = [
|
|
PendingRequest(
|
|
friendship_id=r["friendship_id"], user_id=r["user_id"],
|
|
first_name=u["first_name"], last_name=u["last_name"],
|
|
avatar_url=_avatar_url(u["avatar_key"]), created_at=r["created_at"].isoformat(),
|
|
)
|
|
for r in incoming_rows
|
|
if (u := user_rows.get(r["user_id"])) is not None
|
|
]
|
|
outgoing = [
|
|
PendingRequest(
|
|
friendship_id=r["friendship_id"], user_id=r["user_id"],
|
|
first_name=u["first_name"], last_name=u["last_name"],
|
|
avatar_url=_avatar_url(u["avatar_key"]), created_at=r["created_at"].isoformat(),
|
|
)
|
|
for r in outgoing_rows
|
|
if (u := user_rows.get(r["user_id"])) is not None
|
|
]
|
|
return FriendsOut(friends=friends, incoming_requests=incoming, outgoing_requests=outgoing)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Kategorisering -- privat, ensidig, flere kategorier samtidig
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class CategorizationUpdate(BaseModel):
|
|
categories: list[Category]
|
|
|
|
|
|
@router.put("/friends/{friend_user_id}/categories")
|
|
async def set_friend_categories(
|
|
friend_user_id: str,
|
|
body: CategorizationUpdate,
|
|
user: CurrentUser = Depends(get_current_user),
|
|
) -> dict[str, list[str]]:
|
|
async with plain_connection() as conn:
|
|
friendship = await conn.fetchrow(
|
|
"""
|
|
SELECT 1 FROM friendship
|
|
WHERE status = 'accepted'
|
|
AND ((requester_user_id = $1 AND addressee_user_id = $2)
|
|
OR (requester_user_id = $2 AND addressee_user_id = $1))
|
|
""",
|
|
user.user_id,
|
|
friend_user_id,
|
|
)
|
|
if friendship is None:
|
|
raise app_error(404, "NOT_FOUND", "Dere er ikke venner.")
|
|
async with conn.transaction():
|
|
await conn.execute(
|
|
"DELETE FROM friend_categorization WHERE owner_user_id = $1 AND friend_user_id = $2",
|
|
user.user_id,
|
|
friend_user_id,
|
|
)
|
|
categories = sorted(set(body.categories))
|
|
for category in categories:
|
|
await conn.execute(
|
|
"INSERT INTO friend_categorization (owner_user_id, friend_user_id, category) VALUES ($1, $2, $3)",
|
|
user.user_id,
|
|
friend_user_id,
|
|
category,
|
|
)
|
|
return {"categories": categories}
|