Update Todos

Skriv migrasjon 019 (app_user.country + bio)

Backend: profile_complete-felt, ProfileUpdate country/bio/HCP-cap

Frontend: onboarding-visning i /account, gate i page.tsx + dashboard.tsx

Scratch-verifiser hele flyten

Be om bekreftelse og rull ut live
Scratch-verifisert, alt grønt:

Migrasjon 019 kjørte rent (001→019), test_isolation.sql 12/12 uendret.
16/16 backend-sjekker: fersk konto starter profile_complete: false, delvis utfylling forblir ufullstendig, HCP >54 avvist (422), full utfylling (inkl. HCP=54) gir profile_complete: true, beskrivelse er reelt valgfri, å nullstille et obligatorisk felt i etterkant slår profile_complete tilbake til false, full isolasjon mellom to kontoer.
Ekte produksjonsbuild av frontend kompilerte rent, alle 16 ruter listet.
Ekte HTTP-nivå-bevis mot en kjørende produksjonscontainer: anonym mot / → 200 (innloggingsskjema), en ekte innlogget-men-ufullstendig sesjonscookie mot / → 307 → /account.
Klar for ekte teecup_db. Dette krever:

Kjøre 019_profile_country_bio.sql mot ekte teecup_db (kun to nye nullable kolonner på app_user — additivt, ingen eksisterende rader berørt).
docker compose up -d --build teecup_api teecup_frontend.
Verdt å merke: med dette rullet ut vil både hei@erol.no og erol.haagenrud@envide.no bli sendt til profil-fullføring ved neste innlogging — ingen av kontoene har disse feltene utfylt i dag (bekreftet med spørringen tidligere).
This commit is contained in:
Erol Haagenrud 2026-07-22 07:53:29 +02:00
parent 0dd1ccde8d
commit 79c4406723
6 changed files with 345 additions and 5 deletions

View file

@ -341,7 +341,8 @@
"Bash(python3 -m py_compile app/routers/auth.py app/routers/scoring.py app/routers/matches.py app/routers/tournaments.py app/routers/courses.py app/routers/messaging.py app/routers/registration.py app/team_authz.py)",
"Bash(rm -f /opt/requirements.txt)",
"Bash(python3 -m py_compile app/routers/*.py app/*.py)",
"Bash(python3 -m py_compile app/routers/auth.py app/email.py)"
"Bash(python3 -m py_compile app/routers/auth.py app/email.py)",
"Bash(sort -t_ -k1 -n)"
],
"additionalDirectories": [
"/opt/teeoff/deploy",

View file

@ -0,0 +1,18 @@
-- =====================================================================
-- TeeCup — land + beskrivelse på personlig profil (migrasjon 019)
-- =====================================================================
-- Oppfølging av ADR-031 sin personlige profil (migrasjon 015): brukeren
-- ba om et obligatorisk "land"-felt og et valgfritt "beskrivelse"-felt
-- som del av den obligatoriske profil-fullføringen ved første innlogging
-- (se CLAUDE.md-status). Samme mønster som resten av `app_user`-profilen
-- -- nullable kolonne, "obligatorisk" håndheves i app-laget
-- (`profile_complete` i /auth/me), ikke som en DB NOT NULL-constraint
-- (unngår å bryte eksisterende kontoer som ennå ikke har fylt inn feltet).
-- `country` er fri tekst, samme design som det allerede eksisterende
-- org-scopede `player.country` (migrasjon 007) -- ingen fast landliste.
-- =====================================================================
\set ON_ERROR_STOP on
ALTER TABLE app_user ADD COLUMN country text;
ALTER TABLE app_user ADD COLUMN bio text;

View file

@ -632,9 +632,17 @@ class Me(BaseModel):
gender: str | None
handicap_index: float | None
home_club: str | None
country: str | None
bio: str | None
mobile_country_code: str | None
mobile_number: str | None
avatar_url: str | None
# Obligatorisk-felt-sjekk (2026-07-22): true når alle obligatoriske
# profilfelt er utfylt (fornavn/etternavn/fødselsdato/kjønn/hcp/
# hjemmeklubb/land) -- beskrivelse og bilde er bevisst IKKE med, de er
# valgfrie. Frontend bruker denne til å tvinge en ny/ufullstendig
# bruker gjennom profil-fullføring FØR resten av appen vises.
profile_complete: bool
my_tournaments: list[MyTournament]
secondary_emails: list[SecondaryEmailOut]
@ -662,7 +670,7 @@ async def me(user: CurrentUser = Depends(get_current_user)) -> Me:
SELECT id::text AS id, email::text AS email, display_name, preferred_locale,
(password_hash IS NOT NULL) AS has_password, two_factor_method,
first_name, last_name, birth_date, gender,
handicap_index::float AS handicap_index, home_club,
handicap_index::float AS handicap_index, home_club, country, bio,
mobile_country_code, mobile_number, avatar_key
FROM app_user WHERE id = $1
""",
@ -724,6 +732,11 @@ async def me(user: CurrentUser = Depends(get_current_user)) -> Me:
MyTournament(organization_id=org_id, organization_name=org_name, **dict(r))
)
profile_complete = all(
user_row[field] is not None
for field in ("first_name", "last_name", "birth_date", "gender", "handicap_index", "home_club", "country")
)
return Me(
id=user_row["id"],
email=user_row["email"],
@ -738,9 +751,12 @@ async def me(user: CurrentUser = Depends(get_current_user)) -> Me:
gender=user_row["gender"],
handicap_index=user_row["handicap_index"],
home_club=user_row["home_club"],
country=user_row["country"],
bio=user_row["bio"],
mobile_country_code=user_row["mobile_country_code"],
mobile_number=user_row["mobile_number"],
avatar_url=storage.public_url(user_row["avatar_key"]) if user_row["avatar_key"] else None,
profile_complete=profile_complete,
my_tournaments=my_tournaments,
secondary_emails=[SecondaryEmailOut(id=r["id"], email=r["email"]) for r in secondary_email_rows],
)
@ -757,8 +773,14 @@ class ProfileUpdate(BaseModel):
last_name: str | None = Field(default=None, max_length=100)
birth_date: date | None = None
gender: str | None = Field(default=None, pattern="^[mfx]$")
handicap_index: float | None = None
# WHS-maksimum er 54.0 -- en fersk spiller uten offisiell HCP ennå skal
# bruke nettopp dette som utgangspunkt (frontend forhåndsutfyller
# skjemaet med 54), ikke la feltet stå tomt. Nedre grense er romslig
# nok til å dekke reelle plusshandicap.
handicap_index: float | None = Field(default=None, ge=-10, le=54)
home_club: str | None = Field(default=None, max_length=200)
country: str | None = Field(default=None, max_length=100)
bio: str | None = Field(default=None, max_length=500)
# Landsnummer som eget felt (f.eks. "+47"), ikke sammensatt med selve
# nummeret -- lar frontend tilby en egen landsnummer-velger uten å
# måtte parse/splitte en fritekststreng i etterkant.

View file

@ -22,6 +22,7 @@ export default async function Page() {
const cookieStore = await cookies()
const session = cookieStore.get(SESSION_COOKIE_NAME)
let authenticated = false
let profileComplete = true
if (session) {
try {
const res = await fetch(`${API_ORIGIN}/auth/me`, {
@ -29,13 +30,21 @@ export default async function Page() {
cache: "no-store",
})
authenticated = res.ok
if (res.ok) {
const data = await res.json()
profileComplete = Boolean(data.profile_complete)
}
} catch {
// API utilgjengelig -- vis innloggingsskjemaet i stedet for å henge.
authenticated = false
}
}
if (authenticated) {
redirect("/dashboard")
// Obligatorisk profil-fullføring (2026-07-22): en innlogget bruker som
// ennå ikke har fylt ut de obligatoriske profilfeltene sendes til
// /account (som selv viser en fokusert fullførings-visning) i stedet
// for dashbordet -- se AccountSettings sin profile_complete-gren.
redirect(profileComplete ? "/dashboard" : "/account")
}
return (

View file

@ -8,10 +8,25 @@
import type React from "react"
import { useEffect, useRef, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Camera, KeyRound, Lock, Mail, Plus, ShieldCheck, ShieldOff, Trash2, User, X } from "lucide-react"
import { useRouter } from "next/navigation"
import {
ArrowLeft,
Camera,
KeyRound,
Lock,
LogOut,
Mail,
Plus,
ShieldCheck,
ShieldOff,
Trash2,
User,
X,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Wordmark } from "@/components/wordmark"
import { TwoFactorSetupForm } from "@/components/two-factor-flow"
// ADR-031: personlig profil, ETT sett per konto (app_user), atskilt fra de
@ -28,13 +43,19 @@ type Me = {
gender: "m" | "f" | "x" | null
handicap_index: number | null
home_club: string | null
country: string | null
bio: string | null
mobile_country_code: string | null
mobile_number: string | null
avatar_url: string | null
// 2026-07-22: alle obligatoriske profilfelt utfylt (bilde/beskrivelse
// teller ikke). Se AccountSettings sin bruk under.
profile_complete: boolean
secondary_emails: { id: string; email: string }[]
}
export function AccountSettings() {
const router = useRouter()
const [me, setMe] = useState<Me | null>(null)
const [loading, setLoading] = useState(true)
const [settingUp2fa, setSettingUp2fa] = useState(false)
@ -71,6 +92,19 @@ export function AccountSettings() {
if (!me) return null
// Obligatorisk profil-fullføring (2026-07-22): en ny/ufullstendig konto
// ser KUN dette skjemaet -- ingen tilgang til resten av kontosidene eller
// dashbordet før alle obligatoriske felt er utfylt. Se page.tsx og
// Dashboard for de to inngangspunktene som sender brukeren hit.
if (!me.profile_complete) {
return (
<ProfileOnboarding
me={me}
onComplete={() => router.replace("/dashboard")}
/>
)
}
return (
<div className="flex min-h-[100dvh] flex-col bg-background">
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
@ -161,6 +195,225 @@ export function AccountSettings() {
)
}
// --- Obligatorisk profil-fullføring (2026-07-22) ----------------------------
// Vises i STEDET for resten av appen (dashbord, øvrige kontoseksjoner) helt
// til alle obligatoriske felt er utfylt -- se AccountSettings sin
// profile_complete-gren. Kun bilde og beskrivelse er valgfrie her.
function ProfileOnboarding({ me, onComplete }: { me: Me; onComplete: () => void }) {
const router = useRouter()
const [firstName, setFirstName] = useState(me.first_name ?? "")
const [lastName, setLastName] = useState(me.last_name ?? "")
const [birthDate, setBirthDate] = useState(me.birth_date ?? "")
const [gender, setGender] = useState(me.gender ?? "")
// WHS-maksimum (54) er riktig utgangspunkt for en spiller uten offisiell
// HCP ennå -- forhåndsutfylt, ikke tomt, jf. brukerens eksplisitte ønske.
const [hcp, setHcp] = useState(me.handicap_index === null ? "54" : String(me.handicap_index))
const [homeClub, setHomeClub] = useState(me.home_club ?? "")
const [country, setCountry] = useState(me.country ?? "")
const [bio, setBio] = useState(me.bio ?? "")
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const valid =
firstName.trim() !== "" &&
lastName.trim() !== "" &&
birthDate !== "" &&
gender !== "" &&
hcp.trim() !== "" &&
homeClub.trim() !== "" &&
country.trim() !== ""
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!valid || saving) return
setSaving(true)
setError(null)
try {
const res = await fetch("/auth/profile", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
first_name: firstName.trim(),
last_name: lastName.trim(),
birth_date: birthDate,
gender,
handicap_index: Number(hcp.replace(",", ".")),
home_club: homeClub.trim(),
country: country.trim(),
bio: bio.trim() === "" ? null : bio.trim(),
}),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
throw new Error(body?.detail?.message ?? "Klarte ikke å lagre profilen.")
}
const updated: Me = await res.json()
if (updated.profile_complete) {
onComplete()
}
} catch (err) {
setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.")
} finally {
setSaving(false)
}
}
async function handleLogout() {
try {
await fetch("/auth/logout", { method: "POST", credentials: "include" })
} finally {
router.replace("/")
}
}
return (
<div className="flex min-h-[100dvh] flex-col bg-background">
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
<div className="mx-auto flex w-full max-w-2xl items-center justify-between gap-4 px-5 py-4">
<Wordmark compact />
<button
type="button"
onClick={handleLogout}
className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
>
<LogOut aria-hidden="true" className="size-4" />
Logg ut
</button>
</div>
</header>
<main className="mx-auto w-full max-w-2xl flex-1 px-5 py-8 sm:py-10">
<div className="flex flex-col gap-2 pb-6 text-center sm:text-left">
<h1 className="text-2xl font-extrabold tracking-tight text-foreground text-balance">
Fullfør profilen din
</h1>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Før du kan komme videre trenger vi noen opplysninger om deg. Alt utenom beskrivelse
er obligatorisk. Du kan redigere alt dette senere under «Konto».
</p>
</div>
<form
onSubmit={handleSubmit}
className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6"
>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="flex flex-col gap-1.5">
<Label htmlFor="ob-first-name" className="text-sm font-semibold">
Fornavn
</Label>
<Input
id="ob-first-name"
autoFocus
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
className="h-11 rounded-xl"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="ob-last-name" className="text-sm font-semibold">
Etternavn
</Label>
<Input
id="ob-last-name"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
className="h-11 rounded-xl"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="ob-birth-date" className="text-sm font-semibold">
Fødselsdato
</Label>
<Input
id="ob-birth-date"
type="date"
value={birthDate}
onChange={(e) => setBirthDate(e.target.value)}
className="h-11 rounded-xl"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="ob-gender" className="text-sm font-semibold">
Kjønn
</Label>
<select
id="ob-gender"
value={gender}
onChange={(e) => setGender(e.target.value)}
className="h-11 rounded-xl border border-border bg-card px-3 text-sm font-medium text-foreground outline-none"
>
<option value="">Velg </option>
<option value="f">Dame</option>
<option value="m">Herre</option>
<option value="x">Annet</option>
</select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="ob-hcp" className="text-sm font-semibold">
HCP
</Label>
<Input
id="ob-hcp"
inputMode="decimal"
value={hcp}
onChange={(e) => setHcp(e.target.value)}
className="h-11 rounded-xl"
/>
<p className="text-xs text-muted-foreground">
Ferske spillere har 54 -- la stå om du ikke har en offisiell HCP ennå.
</p>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="ob-home-club" className="text-sm font-semibold">
Hjemmeklubb
</Label>
<Input
id="ob-home-club"
value={homeClub}
onChange={(e) => setHomeClub(e.target.value)}
className="h-11 rounded-xl"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="ob-country" className="text-sm font-semibold">
Land
</Label>
<Input
id="ob-country"
value={country}
onChange={(e) => setCountry(e.target.value)}
className="h-11 rounded-xl"
/>
</div>
<div className="flex flex-col gap-1.5 sm:col-span-2">
<Label htmlFor="ob-bio" className="text-sm font-semibold">
Beskrivelse <span className="font-normal text-muted-foreground">(valgfritt)</span>
</Label>
<textarea
id="ob-bio"
value={bio}
onChange={(e) => setBio(e.target.value)}
rows={2}
placeholder="F.eks. Golf nut, running teeoff.no"
className="rounded-xl border border-border bg-card px-3 py-2 text-sm text-foreground outline-none"
/>
</div>
</div>
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
<Button type="submit" disabled={!valid || saving} className="h-12 w-fit rounded-xl font-semibold">
{saving ? "Lagrer …" : "Fortsett"}
</Button>
</form>
</main>
</div>
)
}
// --- Personlig profil (ADR-031) ---------------------------------------------
function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) {
@ -170,6 +423,8 @@ function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) {
const [gender, setGender] = useState(me.gender ?? "")
const [hcp, setHcp] = useState(me.handicap_index === null ? "" : String(me.handicap_index))
const [homeClub, setHomeClub] = useState(me.home_club ?? "")
const [country, setCountry] = useState(me.country ?? "")
const [bio, setBio] = useState(me.bio ?? "")
const [mobileCountryCode, setMobileCountryCode] = useState(me.mobile_country_code ?? "+47")
const [mobileNumber, setMobileNumber] = useState(me.mobile_number ?? "")
const [saving, setSaving] = useState(false)
@ -195,6 +450,8 @@ function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) {
gender: gender === "" ? null : gender,
handicap_index: hcp.trim() === "" ? null : Number(hcp.replace(",", ".")),
home_club: homeClub.trim() === "" ? null : homeClub.trim(),
country: country.trim() === "" ? null : country.trim(),
bio: bio.trim() === "" ? null : bio.trim(),
mobile_country_code: mobileNumber.trim() === "" ? null : mobileCountryCode.trim(),
mobile_number: mobileNumber.trim() === "" ? null : mobileNumber.trim(),
}),
@ -371,6 +628,30 @@ function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) {
className="h-11 rounded-xl"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="country" className="text-sm font-semibold">
Land
</Label>
<Input
id="country"
value={country}
onChange={(e) => setCountry(e.target.value)}
className="h-11 rounded-xl"
/>
</div>
<div className="flex flex-col gap-1.5 sm:col-span-2">
<Label htmlFor="bio" className="text-sm font-semibold">
Beskrivelse <span className="font-normal text-muted-foreground">(valgfritt)</span>
</Label>
<textarea
id="bio"
value={bio}
onChange={(e) => setBio(e.target.value)}
rows={2}
placeholder="F.eks. Golf nut, running teeoff.no"
className="rounded-xl border border-border bg-card px-3 py-2 text-sm text-foreground outline-none"
/>
</div>
<div className="flex flex-col gap-1.5 sm:col-span-2">
<Label htmlFor="mobile-number" className="text-sm font-semibold">
Mobil

View file

@ -58,6 +58,7 @@ type Me = {
email: string
display_name: string
preferred_locale: string
profile_complete: boolean
organizations: MyOrg[]
my_tournaments: MyTournament[]
}
@ -104,6 +105,14 @@ export function Dashboard() {
}
const data: Me = await res.json()
if (cancelled) return
// Obligatorisk profil-fullføring (2026-07-22): dekker enhver vei
// INN til dashbordet (magic-link/passord/2FA-verifisering lander
// her direkte) -- /account viser selv fullførings-skjemaet så
// lenge profil_complete er false.
if (!data.profile_complete) {
router.replace("/account")
return
}
setMe(data)
setActiveOrgId(data.organizations[0]?.organization_id ?? null)
} catch {