431 lines
18 KiB
Python
431 lines
18 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, Field
|
|
|
|
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
|
|
# Ingen ukategoriserte venner (2026-07-29, presisert av bruker) --
|
|
# kategorisering er nå obligatorisk fra det ØYEBLIKKET man blir venn,
|
|
# ikke en frivillig, senere handling. Krevd her (avsenderens side) OG i
|
|
# /accept (mottakerens side) -- de to eneste naturlige punktene en
|
|
# ny relasjon oppstår.
|
|
categories: list[Category] = Field(min_length=1)
|
|
|
|
|
|
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 conn.transaction():
|
|
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,
|
|
)
|
|
# Kategorisering settes med det samme -- harmløst inert helt til
|
|
# forespørselen faktisk aksepteres (_can_view_round krever uansett
|
|
# status='accepted' FØRST), men lukker gapet der avsenderen ellers
|
|
# aldri ble bedt om å kategorisere i det hele tatt.
|
|
for category in sorted(set(body.categories)):
|
|
await conn.execute(
|
|
"INSERT INTO friend_categorization (owner_user_id, friend_user_id, category) VALUES ($1, $2, $3)",
|
|
user.user_id,
|
|
body.addressee_user_id,
|
|
category,
|
|
)
|
|
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"]}
|
|
|
|
|
|
class FriendAcceptRequest(BaseModel):
|
|
# Samme "ingen ukategoriserte venner"-regel som FriendRequestCreate,
|
|
# denne gangen for MOTTAKERENS side av relasjonen.
|
|
categories: list[Category] = Field(min_length=1)
|
|
|
|
|
|
@router.post("/friends/{friendship_id}/accept")
|
|
async def accept_friend_request(
|
|
friendship_id: str,
|
|
body: FriendAcceptRequest,
|
|
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, requester_user_id::text AS requester_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.")
|
|
requester_id = row["requester_user_id"]
|
|
async with conn.transaction():
|
|
await conn.execute("UPDATE friendship SET status = 'accepted', responded_at = now() WHERE id = $1", friendship_id)
|
|
await conn.execute(
|
|
"DELETE FROM friend_categorization WHERE owner_user_id = $1 AND friend_user_id = $2",
|
|
user.user_id,
|
|
requester_id,
|
|
)
|
|
for category in sorted(set(body.categories)):
|
|
await conn.execute(
|
|
"INSERT INTO friend_categorization (owner_user_id, friend_user_id, category) VALUES ($1, $2, $3)",
|
|
user.user_id,
|
|
requester_id,
|
|
category,
|
|
)
|
|
addressee_name = await conn.fetchval("SELECT display_name FROM app_user WHERE id = $1", user.user_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):
|
|
# Minst én -- "ingen ukategoriserte venner" gjelder også ved senere
|
|
# redigering, ikke bare ved selve vennskaps-opprettelsen.
|
|
categories: list[Category] = Field(min_length=1)
|
|
|
|
|
|
@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}
|