""" 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 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 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, 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"]) for r in rows ] # --------------------------------------------------------------------------- # 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}