Backend for fase 1 (venner-kjernen) er bygget og scratch-verifisert (31/31 sjekker, test_isolation.sql fortsatt 12/12). Ny migrasjon 025_friends.sql, nytt app/routers/friends.py med tiered søk, forespørsel/aksept/fjern, og privat kategorisering — bekreftet at kategoriseringen faktisk er privat (B ser aldri hvilke grupper A har satt B i).
Frontend er bevisst ikke hånd-kodet denne gangen — jeg husket korrigeringen fra rundeskjermene tidligere i prosjektet, så jeg har i stedet skrevet en V0-prompt (i FEATURE_BACKLOG.md) for en ny /my-friends-side, klar til å kjøres når du vil.
This commit is contained in:
parent
c8c4ad29b6
commit
345e697e12
8 changed files with 503 additions and 7 deletions
|
|
@ -7,7 +7,8 @@
|
||||||
"Bash(mkdir -p /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/zip18)",
|
"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(unzip -q \"/opt/teecup/tee-cup-login-screen \\(18\\).zip\")",
|
||||||
"Bash(python3 -m zipfile -e \"/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)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
57
025_friends.sql
Normal file
57
025_friends.sql
Normal file
|
|
@ -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;
|
||||||
|
|
@ -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
|
`viewer == owner_user_id OR viewer IN (SELECT user_id FROM
|
||||||
round_participant WHERE round_id = $1 AND user_id IS NOT NULL)`.
|
round_participant WHERE round_id = $1 AND user_id IS NOT NULL)`.
|
||||||
|
|
||||||
**Status: 📋 DESIGNET 2026-07-25, IKKE BYGGET.** Se "Venner og
|
**Status: 🔨 FASE 1 (venner-kjernen) BACKEND BYGGET OG SCRATCH-VERIFISERT
|
||||||
kategorisert deling"-seksjonen i FEATURE_BACKLOG.md for åpne spørsmål og
|
2026-07-25** — migrasjon `025_friends.sql` + `app/routers/friends.py`
|
||||||
videre kontekst.
|
(`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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
30
CLAUDE.md
30
CLAUDE.md
|
|
@ -2632,8 +2632,11 @@ Ferdig og verifisert:
|
||||||
fikk sine listeelementer gjort om til ren visning (ikke lenker) siden
|
fikk sine listeelementer gjort om til ren visning (ikke lenker) siden
|
||||||
API-et ikke eksponerer noen stabil bane-id å lenke til per runde.
|
API-et ikke eksponerer noen stabil bane-id å lenke til per runde.
|
||||||
Ekte typesjekket produksjonsbuild kompilerte rent, alle 21 ruter
|
Ekte typesjekket produksjonsbuild kompilerte rent, alle 21 ruter
|
||||||
listet. **IKKE rullet ut ennå** — venter på brukerens bekreftelse
|
listet. **Rullet ut live 2026-07-25**, bruker bekreftet eksplisitt:
|
||||||
(ren frontend-endring, ingen migrasjon).
|
`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
|
- **Oppfølging samme runde, avklart med bruker:** medspillere skal kunne
|
||||||
registrere score for HELE flighten (ikke bare egen rad) når ADR-036
|
registrere score for HELE flighten (ikke bare egen rad) når ADR-036
|
||||||
fase 3 bygges — oppdatert i ARCHITECTURE_DECISIONS.md/FEATURE_BACKLOG.md.
|
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
|
(f.eks. "min flight + vennenes flight bak oss samme dag") — to
|
||||||
prinsipielt ulike retninger skissert, ingen valgt.
|
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:
|
Neste steg:
|
||||||
1. **Klart for bygging, venter på brukerens go-ahead:** dashbord-redesign
|
1. **Klart for bygging, venter på brukerens go-ahead:** dashbord-redesign
|
||||||
(ADR-035) + venner/kategorisert deling (ADR-036), begge designet
|
(ADR-035) + venner/kategorisert deling (ADR-036), begge designet
|
||||||
|
|
|
||||||
|
|
@ -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
|
Reist av brukeren samme runde som dashbord-forslaget over. Fullt design
|
||||||
skrevet som **ADR-036** i ARCHITECTURE_DECISIONS.md — se der for
|
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
|
## Turneringsoppsett: flytte/slette spillere mellom lag — 📋 NOTERT 2026-07-25, IKKE bygget
|
||||||
|
|
||||||
Reist av brukeren samme runde som dashbord-integreringen. I dag finnes
|
Reist av brukeren samme runde som dashbord-integreringen. I dag finnes
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ from .db import init_pool, close_pool
|
||||||
from .routers import (
|
from .routers import (
|
||||||
auth,
|
auth,
|
||||||
courses,
|
courses,
|
||||||
|
friends,
|
||||||
matches,
|
matches,
|
||||||
messaging,
|
messaging,
|
||||||
organizations,
|
organizations,
|
||||||
|
|
@ -54,6 +55,7 @@ app.include_router(registration.org_router)
|
||||||
app.include_router(messaging.router)
|
app.include_router(messaging.router)
|
||||||
app.include_router(messaging.public_router)
|
app.include_router(messaging.public_router)
|
||||||
app.include_router(rounds.router)
|
app.include_router(rounds.router)
|
||||||
|
app.include_router(friends.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|
|
||||||
332
app/routers/friends.py
Normal file
332
app/routers/friends.py
Normal file
|
|
@ -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}
|
||||||
|
|
@ -42,6 +42,10 @@ const nextConfig = {
|
||||||
{ source: "/rounds", destination: `${API_ORIGIN}/rounds` },
|
{ source: "/rounds", destination: `${API_ORIGIN}/rounds` },
|
||||||
{ source: "/personal-courses/:path*", destination: `${API_ORIGIN}/personal-courses/:path*` },
|
{ source: "/personal-courses/:path*", destination: `${API_ORIGIN}/personal-courses/:path*` },
|
||||||
{ source: "/personal-courses", destination: `${API_ORIGIN}/personal-courses` },
|
{ 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` },
|
{ source: "/health", destination: `${API_ORIGIN}/health` },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue