2026-07-25 08:14:48 +02:00
"""
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
2026-07-28 19:25:37 +02:00
from . . auth import CurrentUser , get_current_user , get_current_user_optional
2026-07-25 08:14:48 +02:00
from . . db import plain_connection
from . . errors import app_error , translate_db_errors
Varsler (fra zip 20): in-app varslingssenter er live — bjelle med uleste-tall i dashbord-headeren, /my-notifications-side. Trigges i dag ved venneforespørsel sendt/akseptert; flere hendelser kan kobles på senere.
Rundeleaderboard: ny GET /rounds/{id}/leaderboard-backend er live (rangering, thru-tall, brutto+netto til par, håndterer 1 til 15+ deltakere). V0-prompten for selve visningen ligger i FEATURE_BACKLOG.md, klar til å limes inn i v0.app — send meg zip-en når du har den, så kobler jeg den på (foreslått rute /my-rounds/[id]/leaderboard, lenket fra rundesiden).
Begge deler scratch-verifisert (39/39 sjekker, inkl. en uavhengig kryssjekk av netto-beregningen mot handicap_engine direkte), rullet ut mot ekte teecup_db/containere, teeoff.no upåvirket.
2026-07-26 06:53:58 +02:00
from . notifications import create_notification
2026-07-25 08:14:48 +02:00
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
2026-07-26 09:18:52 +02:00
# 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
2026-07-25 08:14:48 +02:00
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 ,
2026-07-26 09:18:52 +02:00
u . handicap_index : : float AS handicap_index ,
2026-07-25 08:14:48 +02:00
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 [
2026-07-26 09:18:52 +02:00
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 " ] ,
)
2026-07-25 08:14:48 +02:00
for r in rows
]
2026-07-28 19:25:37 +02:00
@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 " ] ,
)
2026-07-25 08:14:48 +02:00
# ---------------------------------------------------------------------------
# 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 ,
)
Varsler (fra zip 20): in-app varslingssenter er live — bjelle med uleste-tall i dashbord-headeren, /my-notifications-side. Trigges i dag ved venneforespørsel sendt/akseptert; flere hendelser kan kobles på senere.
Rundeleaderboard: ny GET /rounds/{id}/leaderboard-backend er live (rangering, thru-tall, brutto+netto til par, håndterer 1 til 15+ deltakere). V0-prompten for selve visningen ligger i FEATURE_BACKLOG.md, klar til å limes inn i v0.app — send meg zip-en når du har den, så kobler jeg den på (foreslått rute /my-rounds/[id]/leaderboard, lenket fra rundesiden).
Begge deler scratch-verifisert (39/39 sjekker, inkl. en uavhengig kryssjekk av netto-beregningen mot handicap_engine direkte), rullet ut mot ekte teecup_db/containere, teeoff.no upåvirket.
2026-07-26 06:53:58 +02:00
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 " ,
)
2026-07-25 08:14:48 +02:00
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 )
Varsler (fra zip 20): in-app varslingssenter er live — bjelle med uleste-tall i dashbord-headeren, /my-notifications-side. Trigges i dag ved venneforespørsel sendt/akseptert; flere hendelser kan kobles på senere.
Rundeleaderboard: ny GET /rounds/{id}/leaderboard-backend er live (rangering, thru-tall, brutto+netto til par, håndterer 1 til 15+ deltakere). V0-prompten for selve visningen ligger i FEATURE_BACKLOG.md, klar til å limes inn i v0.app — send meg zip-en når du har den, så kobler jeg den på (foreslått rute /my-rounds/[id]/leaderboard, lenket fra rundesiden).
Begge deler scratch-verifisert (39/39 sjekker, inkl. en uavhengig kryssjekk av netto-beregningen mot handicap_engine direkte), rullet ut mot ekte teecup_db/containere, teeoff.no upåvirket.
2026-07-26 06:53:58 +02:00
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 " ,
)
2026-07-25 08:14:48 +02:00
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 }