diff --git a/.claude/settings.json b/.claude/settings.json index 008ad80..efeed24 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -7,7 +7,8 @@ "Bash(mkdir -p /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/zip18)", "Bash(unzip -q \"/opt/teecup/tee-cup-login-screen \\(18\\).zip\")", "Bash(python3 -m zipfile -e \"/opt/teecup/tee-cup-login-screen \\(18\\).zip\" .)", - "Bash(grep -v \"ui/dropdown-menu\\\\|^.*DropdownMenuItem,$\\\\|import\")" + "Bash(grep -v \"ui/dropdown-menu\\\\|^.*DropdownMenuItem,$\\\\|import\")", + "Bash(python3 test_friends.py)" ] } } diff --git a/025_friends.sql b/025_friends.sql new file mode 100644 index 0000000..0115505 --- /dev/null +++ b/025_friends.sql @@ -0,0 +1,57 @@ +-- Venner-kjernen (ADR-036, fase 1), 2026-07-25. +-- +-- Gjensidig vennskap (forespørsel/aksept, samme grunnmønster som +-- organisasjon-invitasjoner) + en PRIVAT, ENSIDIG kategorisering i et fast +-- sett grupper -- vennen vet aldri hvilken/hvilke kategorier den andre har +-- puttet dem i (ADR-036 Beslutning A). +-- +-- Ingen RLS -- samme `plain_connection()`-mønster som personlig profil/ +-- HCP-historikk/frittstående runder (ADR-033 Beslutning A): autorisasjon +-- håndheves i app-laget med eksplisitt `WHERE user_id = $1`, ikke policyer. + +CREATE TABLE friendship ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + requester_user_id uuid NOT NULL REFERENCES app_user(id) ON DELETE CASCADE, + addressee_user_id uuid NOT NULL REFERENCES app_user(id) ON DELETE CASCADE, + -- Bevisst KUN to tilstander -- en avvist/kansellert forespørsel har + -- ingen verdi å beholde som egen rad, den slettes i stedet (se + -- app/routers/friends.py). Et nytt forsøk oppretter da bare en ny rad. + status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'accepted')), + created_at timestamptz NOT NULL DEFAULT now(), + responded_at timestamptz, + CHECK (requester_user_id <> addressee_user_id) +); + +-- Ett vennskap PER PAR, uavhengig av hvem som ba først -- hindrer at A kan +-- be B to ganger, eller at A og B begge har en åpen forespørsel til +-- hverandre samtidig. +CREATE UNIQUE INDEX friendship_unique_pair + ON friendship (LEAST(requester_user_id, addressee_user_id), GREATEST(requester_user_id, addressee_user_id)); + +CREATE INDEX friendship_requester_idx ON friendship (requester_user_id); +CREATE INDEX friendship_addressee_idx ON friendship (addressee_user_id); + +-- Kategori er et FAST, ikke brukerdefinerbart sett (samme mønster som +-- BAG_CLUBS/gender/stat_level ellers i appen) -- norske visningsnavn i +-- app-laget, se ADR-036: spouse=Make, close_family=Nær familie, +-- extended_family=Storfamilie, close_friends=Nære venner, +-- golf_friends=Golfvenner, colleagues=Kollegaer, business= +-- Forretningsforbindelser, classmates=Studiekamerater, acquaintances= +-- Perifere bekjente, other=Ymse. En venn kan ha FLERE rader (flere +-- kategorier samtidig). +CREATE TABLE friend_categorization ( + owner_user_id uuid NOT NULL REFERENCES app_user(id) ON DELETE CASCADE, + friend_user_id uuid NOT NULL REFERENCES app_user(id) ON DELETE CASCADE, + category text NOT NULL CHECK (category IN ( + 'spouse', 'close_family', 'extended_family', 'close_friends', + 'golf_friends', 'colleagues', 'business', 'classmates', + 'acquaintances', 'other' + )), + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (owner_user_id, friend_user_id, category) +); + +CREATE INDEX friend_categorization_friend_idx ON friend_categorization (friend_user_id); + +GRANT SELECT, INSERT, UPDATE, DELETE ON friendship TO teecup_app; +GRANT SELECT, INSERT, UPDATE, DELETE ON friend_categorization TO teecup_app; diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index fd24644..04c937e 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -3038,9 +3038,21 @@ samme "bygg i rekkefølgen ting brukes"-prinsipp som resten av prosjektet: `viewer == owner_user_id OR viewer IN (SELECT user_id FROM round_participant WHERE round_id = $1 AND user_id IS NOT NULL)`. -**Status: 📋 DESIGNET 2026-07-25, IKKE BYGGET.** Se "Venner og -kategorisert deling"-seksjonen i FEATURE_BACKLOG.md for åpne spørsmål og -videre kontekst. +**Status: 🔨 FASE 1 (venner-kjernen) BACKEND BYGGET OG SCRATCH-VERIFISERT +2026-07-25** — migrasjon `025_friends.sql` + `app/routers/friends.py` +(`GET /people/search`, `POST /friends`, `POST /friends/{id}/accept`, +`DELETE /friends/{id}`, `GET /friends`, `PUT /friends/{friend_user_id}/ +categories`). 31 scratch-sjekker bestått, inkl. presist bevist tiered +rangering (venn→klubb→land→globalt) og at kategorisering faktisk er +PRIVAT (B ser aldri kategoriene A har satt B i). `test_isolation.sql` +fortsatt 12/12. **IKKE rullet ut mot ekte systemer ennå** — venter på +brukerens bekreftelse. **Frontend IKKE bygget** — V0-prompt skrevet i +FEATURE_BACKLOG.md, klar til å kjøres (ny rute foreslått som +`/my-friends`, ikke `/friends` — det er nå API-prefikset). Fase 2 +(rundevisibilitet) og fase 3 (ekte medspillere, inkl. den nylig avklarte +"skriv for hele flighten"-regelen) er fortsatt kun designet, ikke bygget. +Se "Venner og kategorisert deling"-seksjonen i FEATURE_BACKLOG.md for +full detalj og V0-prompten. --- diff --git a/CLAUDE.md b/CLAUDE.md index 3c8ff4e..9a62c84 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2632,8 +2632,11 @@ Ferdig og verifisert: fikk sine listeelementer gjort om til ren visning (ikke lenker) siden API-et ikke eksponerer noen stabil bane-id å lenke til per runde. Ekte typesjekket produksjonsbuild kompilerte rent, alle 21 ruter - listet. **IKKE rullet ut ennå** — venter på brukerens bekreftelse - (ren frontend-endring, ingen migrasjon). + listet. **Rullet ut live 2026-07-25**, bruker bekreftet eksplisitt: + `docker compose up -d --build teecup_frontend` (gjenskapte også + `teecup_api` som vanlig bivirkning, ingen backend-kode rørt). Begge + containere boot-et rent, `/health`/`/dashboard`/`/my-rounds` → 200, + `teeoff.no` upåvirket. - **Oppfølging samme runde, avklart med bruker:** medspillere skal kunne registrere score for HELE flighten (ikke bare egen rad) når ADR-036 fase 3 bygges — oppdatert i ARCHITECTURE_DECISIONS.md/FEATURE_BACKLOG.md. @@ -2644,6 +2647,29 @@ Ferdig og verifisert: (f.eks. "min flight + vennenes flight bak oss samme dag") — to prinsipielt ulike retninger skissert, ingen valgt. +- **ADR-036 fase 1 (venner-kjernen) BYGGET OG SCRATCH-VERIFISERT, IKKE + ENNÅ RULLET UT (2026-07-25):** ny migrasjon `025_friends.sql` + (`friendship` + `friend_categorization`, ingen RLS — samme + `plain_connection()`-mønster som runder) og nytt `app/routers/ + friends.py`: `GET /people/search` (tiered venn→klubb→land→globalt, + navnerekkefølge-uavhengig token-matching, min. 2 tegn før noe + returneres), `POST /friends`/`POST /friends/{id}/accept`/ + `DELETE /friends/{id}` (forespørsel/aksept/avslå-kanseller-avvenn — én + DELETE dekker alle tre), `GET /friends`, `PUT /friends/{friend_user_id}/ + categories` (erstatter hele settet, krever akseptert vennskap). Router + registrert i `main.py`, `/people`+`/friends`-rewrites lagt til i + `next.config.mjs` (ny frontend-rute vil hete `/my-friends`, IKKE + `/friends` — unngår samme kollisjonsfelle som `/rounds` fra før). + **Scratch-verifisert grundig** (isolert rolle+MinIO+engangs API- + container, 5 syntetiske brukere): 31 sjekker, inkl. presist bevist + tiered rangering for 4 distinkte brukere samtidig, og at + kategorisering er EKTE PRIVAT (bekreftet: B ser aldri kategoriene A + satte B i). `test_isolation.sql` fortsatt 12/12. Ekte typesjekket + produksjonsbuild kompilerte rent. **IKKE rullet ut ennå** — venter på + brukerens bekreftelse. Frontend bevisst IKKE hånd-kodet denne gangen + (V0-prompt skrevet i FEATURE_BACKLOG.md i stedet, matcher etablert + mønster/tidligere korrigering). + Neste steg: 1. **Klart for bygging, venter på brukerens go-ahead:** dashbord-redesign (ADR-035) + venner/kategorisert deling (ADR-036), begge designet diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index 1758ca0..c1fe2c5 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -1477,7 +1477,7 @@ gjennomgang av blokk-forslaget over først): --- -## Venner, kategorisert deling av runder, og tiered personsøk — 📋 DESIGNET 2026-07-25, IKKE bygget +## Venner, kategorisert deling av runder, og tiered personsøk — 🔨 FASE 1 BACKEND BYGGET 2026-07-25 (se under), fase 2/3 fortsatt kun designet Reist av brukeren samme runde som dashbord-forslaget over. Fullt design skrevet som **ADR-036** i ARCHITECTURE_DECISIONS.md — se der for @@ -1544,6 +1544,68 @@ ikke en byggerunde, på brukerens eksplisitte instruks. --- +### Fase 1 (venner-kjernen) — ✅ BACKEND BYGGET OG SCRATCH-VERIFISERT 2026-07-25, IKKE ennå rullet ut, frontend gjenstår + +Migrasjon `025_friends.sql` (`friendship` + `friend_categorization`, ingen +RLS — samme `plain_connection()`-mønster som runder), nytt +`app/routers/friends.py`: `GET /people/search` (tiered, navnerekkefølge- +uavhengig, min. 2 tegn), `POST /friends` (send forespørsel), +`POST /friends/{id}/accept`, `DELETE /friends/{id}` (avslå/kanseller/ +avvenn — én operasjon dekker alle tre), `GET /friends` (venner + +inn-/utgående forespørsler), `PUT /friends/{friend_user_id}/categories` +(erstatter hele settet, krever akseptert vennskap). + +**Verifisert grundig i scratch** (isolert `teecup_app_scratch`-rolle + +isolert scratch-MinIO + engangs API-container, 5 syntetiske brukere): 31 +sjekker — navnerekkefølge-uavhengig søk begge veier, selv-ekskludering, +anti-enumerering under 2 tegn, dupliserte forespørsler avvist i BEGGE +retninger, kun mottaker kan akseptere, tiered rangering bekreftet presist +(venn → samme klubb → samme land → globalt, i RIKTIG rekkefølge for 4 +distinkte brukere samtidig), kategorisering avvist for ikke-venn, +kategorisering BEKREFTET PRIVAT (B ser ALDRI kategoriene A har satt B i), +uvedkommende kan ikke slette andres vennskap, avvenning + ny forespørsel +etterpå fungerer. `test_isolation.sql` fortsatt 12/12. Ekte typesjekket +produksjonsbuild kompilerte rent (ingen frontend-endring ennå utover +`next.config.mjs` sine nye rewrites for `/people`/`/friends`). + +**Frontend IKKE bygget** — venter bevisst på V0 (etablert mønster for ALL +frontend i prosjektet, jf. brukerens tidligere eksplisitte korrigering av +at rundeskjermene opprinnelig ble hånd-kodet i stedet). Ny frontend-rute +foreslått som `/my-friends` (IKKE `/friends` — det er nå API-prefikset, +samme kollisjonsfelle som `/rounds` vs. `/my-rounds`, unngått fra start +denne gangen). + +**V0-prompt skrevet, klar til å kjøres:** + +> Design en ny "Mine venner"-side for TeeCup (golf-app, Next.js + +> Tailwind + shadcn/ui, mobil-først, eksisterende merkevarefarger). +> +> Innhold: +> 1. Et søkefelt øverst ("Søk etter navn …") — søkbar liste som filtrerer +> live mens man skriver (samme mønster som bane-/klubbsøket ellers i +> appen), viser navn + evt. hjemmeklubb per treff, med en +> "Send forespørsel"-knapp per rad. +> 2. En "Forespørsler"-seksjon (kun synlig når det finnes noen): to +> undergrupper — "Mottatt" (med "Godta"/"Avslå"-knapper per rad) og +> "Sendt" (med en "Kanseller"-knapp per rad, og tekst som "Venter på +> svar"). +> 3. En "Venner"-liste — hver rad har navn, avatar (initialer som +> fallback), hjemmeklubb, og en utvidbar kategori-velger: ti faste +> avkrysningsbare kategorier (Make, Nær familie, Storfamilie, Nære +> venner, Golfvenner, Kollegaer, Forretningsforbindelser, +> Studiekamerater, Perifere bekjente, Ymse) — flere kan velges +> samtidig for samme venn. Vis tydelig, med liten tekst under +> kategori-velgeren: "Kun du ser hvilke kategorier du har satt en venn +> i." En "Fjern venn"-knapp med bekreftelsessteg. +> 4. Tomtilstander for alle tre seksjonene (ingen venner ennå, ingen +> forespørsler). +> +> Tilgjengelighet er et ufravikelig krav: god kontrast, stor nok skrift, +> store trykkflater (min. 44px), aldri ikon-only uten tekstlabel på +> viktige handlinger. + +--- + ## Turneringsoppsett: flytte/slette spillere mellom lag — 📋 NOTERT 2026-07-25, IKKE bygget Reist av brukeren samme runde som dashbord-integreringen. I dag finnes diff --git a/app/main.py b/app/main.py index 3205d6d..c02ce9a 100644 --- a/app/main.py +++ b/app/main.py @@ -18,6 +18,7 @@ from .db import init_pool, close_pool from .routers import ( auth, courses, + friends, matches, messaging, organizations, @@ -54,6 +55,7 @@ app.include_router(registration.org_router) app.include_router(messaging.router) app.include_router(messaging.public_router) app.include_router(rounds.router) +app.include_router(friends.router) @app.get("/health") diff --git a/app/routers/friends.py b/app/routers/friends.py new file mode 100644 index 0000000..d1adba5 --- /dev/null +++ b/app/routers/friends.py @@ -0,0 +1,332 @@ +""" +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 + +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, + ) + 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) + 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} diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index b17d955..de598e6 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -42,6 +42,10 @@ const nextConfig = { { source: "/rounds", destination: `${API_ORIGIN}/rounds` }, { source: "/personal-courses/:path*", destination: `${API_ORIGIN}/personal-courses/:path*` }, { source: "/personal-courses", destination: `${API_ORIGIN}/personal-courses` }, + // Venner-kjernen (ADR-036, fase 1, 2026-07-25). + { source: "/people/:path*", destination: `${API_ORIGIN}/people/:path*` }, + { source: "/friends/:path*", destination: `${API_ORIGIN}/friends/:path*` }, + { source: "/friends", destination: `${API_ORIGIN}/friends` }, { source: "/health", destination: `${API_ORIGIN}/health` }, ] },