# Fælles Vinindkøb — Projektplan > **Proces:** Når en opgave (eller delopgave, fx 7a/8a) er > gennemført, skal denne fil opdateres til at afspejle det faktisk > byggede — datamodel, endpoints, og status i Fase 1-listen. Filen er > den løbende sandhed om projektet, ikke kun den oprindelige plan. > Når en opgave er afsluttet og committet, skal der desuden gås > tilbage i plan mode, i stedet for at fortsætte til næste opgave uden > at spørge. ## Formål Erstatte manuel administration (regneark til betalingstracking, direkte databaseredigering for nye runder/deltagere/vine) med en rigtig admin-backend, samt automatisk betalings-mail til deltagere. Arkitekturen designes fra start til at kunne understøtte flere organisationer (fx vinbonden Horcher-familiens egne ruter i Frankrig/Belgien), selvom kun én organisation/rute er i drift i dag. ## Tech stack - Backend: FastAPI + SQLModel + PostgreSQL + Alembic (migrations) - Frontend: React + TypeScript admin-UI (`admin-ui/`, opgave 11a — scaffold, se "Admin-UI-arkitektur" nedenfor) + den offentlige bestillings-/tilmeldingsside (opgave 8c) — server-renderet af FastAPI selv (Jinja2-templates i `app/templates/` + vanilla JS/CSS i `app/static/`, intet build-step). Fortsat intet login for deltagere. - Mail: Postal (selvhostet), `postal.carsteng.dk`, domæne `vinindkoeb.dk` - Auth: JWT (`Authorization: Bearer`), 2 timers levetid. Superadmin- handlinger kræver derudover en kortlivet (5 min) "eleveret" session (`POST /auth/elevate`, ingen ny password-indtastning) — en bevidst "ja, jeg mener det"-bekræftelse mod ved-uheld-handlinger, ikke et forsvar mod en stjålet session ## Datamodel - **WineCategory** — global, delt af ALLE organisationer/ruter (samme vinbonde, samme kategoristruktur). Seedet via migration med: TRADITION, SELECTION, LES IMPERTINENTS, GRANDS CRUS, VENDANGES TARDIVES, CREMANT, MAGNUM, SANS ALCOOL (i denne rækkefølge). - `name` (unik), `sort_order` - **Organization** — administrativ gruppe (fx "Fælles Vinindkøb DK", "Horcher Frankrig") - `name` (unik) - **User** — admin-login, tilhører en Organization - `email` (unik), `name`, `is_active`, `is_superadmin`, `hashed_password` (argon2id via `pwdlib`) - Passkey/WebAuthn kan tilføjes senere som en separat credentials-tabel uden ændringer på `User` selv - **Route** — tilhører en Organization (fx "Tyskland" for jer; "Belgique" / "Paris" / "Massif Central" for Horcher-familien) - `name`, `meeting_info`/`contact_person`/`contact_phone` (fritekst, forudfyldes fra forrige runde i UI'et senere) - `sender_name`/`sender_email` — afsenderidentitet til mails, forskellig pr. rute (fx "Finn Gram - Fælles Vinindkøb ") - `public_domain` (unik, nullable) — det domæne den offentlige bestillingsside bruger for denne rute (fx `vinindkoeb.dk`). Opgave 8b: en reverse proxy sender alle organisationers domæner til samme backend, som selv afgør rute ud fra request'ens `Host`-header (ingen `route_id` i URL'en for de offentlige endpoints) — ét frontend-build kan dermed betjene flere organisationer - **Participant** — tilhører en Route - `name`, `email`, `phone`, `is_active` - `email` er påkrævet ved oprettelse af nye deltagere; `phone` er påkrævet for nye deltagere via API'et, men nullable i databasen (historiske deltagere fra MongoDB-migreringen mangler det) - `UNIQUE(route_id, email)` — forhindrer dubletter pr. rute - Soft-delete via `is_active` er den normale vej (bevarer historik for deltagere der forlader og kommer tilbage); en rigtig `DELETE` findes også, kun til GDPR-sletningsanmodninger - Oprettes/opdateres også ad-hoc via den offentlige bestillings-API (opgave 8a) — matcher emailen en eksisterende deltager på ruten, **overskrives** `name`/`phone`/`is_active` fra formularen ved hver bestilling (ingen login = bestillingsformularen er selve vejen man holder sine oplysninger opdateret); ny email = ny deltager-række - Kan også oprettes/opdateres via det separate tilmeldings-endpoint (opgave 8b, `POST /public/signup`, kun navn+email) — bevidst forskellig fra bestillingsflowet: **rører aldrig `phone`**, så en ren tilmelding ikke kan slette et telefonnummer givet ved en tidligere bestilling - **PurchaseRound** — tilhører en Route - `name` (frit, fx "Forår 2026" — intet sæson/år-felt, da det ikke er universelt på tværs af fremtidige ruter) - `status`: `draft` / `open` / `closed` - `opens_at`, `order_deadline_at`, `pickup_at` — alle tre påkrævede så snart status ikke er `draft` (håndhævet af en DB-constraint) - `eur_dkk_rate` (valgfri — ikke alle organisationer skal omregne) - `intro_text`, `pickup_info_text` (fritekst, forudfyldes fra forrige runde via "kopiér fra forrige runde") - Sletning: `draft` kan slettes af enhver admin; `open` kan aldrig slettes; `closed` kræver en eleveret superadmin - **WineOffering** — tilhører PurchaseRound + (global) WineCategory - `name` (inkl. størrelse og årgang som fri tekst, fx "SYLVANER 2021 - 75cl" — ingen separate felter for det) - `price` (EUR, `Decimal`), `is_organic` (boolean) - **Order** — deltager + runde - `payment_status` (`unpaid`/`paid`), `paid_at`, `created_at` - `order_number` (opgave 10) — fortløbende nummer **pr. runde** (starter på 1 for hver runde), `UNIQUE(purchase_round_id, order_number)`. Tildeles atomisk i `POST /public/orders` via en `SELECT ... FOR UPDATE`-lås på rundens række + `MAX+1`, så samtidige bestillinger lige efter en runde åbner ikke kan kollidere eller give huller i nummerrækken. Bruges som sorteringsnøgle og stort synligt nummer på afhentningslisten (se nedenfor), og er tilgængelig som `{{order_number}}` i mail-skabeloner - Oprettes via den offentlige bestillings-API (opgave 8a/8b, ingen login). Admin-ordreoversigt + "markér betalt" (opgave 9): `GET /orders` (filtre: `purchase_round_id`, `route_id`, `payment_status`) og `POST /orders/{id}/mark-paid` — se Mail-events #3 nedenfor for selve betalingsbekræftelsen - **OrderLine** — ordre + wine_offering + antal - Samme som Order — skrives af det offentlige bestillings-endpoint - **MailTemplate** — tilhører Route, én pr. `(route, event_type)` - `event_type`: `round_announced` / `order_confirmed` / `payment_confirmed` (matcher de tre mail-events nedenfor) - `subject`, `body_html` — simpel `{{variabel}}`-substitution, ingen loops/logik i skabelonen (lister som vinkataloget genereres i kode og injiceres som færdig HTML) - **MailLog** — ét forsøg pr. deltager pr. afsendelse - `participant_id`, `purchase_round_id`, `mail_template_id`, `event_type`, `rendered_subject` (denormaliseret — viser hvad der faktisk blev sendt, selvom skabelonen ændres senere) - `status`: `sent` (Postal tog imod) → `delivered`/`bounced`/ `held`/`delayed`/`failed` (opdateret asynkront via webhook) - `postal_message_id`/`postal_token` (til korrelation med webhook), `error_message`, `sent_at`, `opened_at`, `clicked_at` ## Den offentlige side (opgave 8c) Server-renderet af samme FastAPI-app som API'et (`app/routers/ public_site.py`, ingen prefix — adskilt fra JSON-API'et under `/public/*`). Genbruger `get_route_from_domain` og en fælles `_build_page_info`-helper fra `app/routers/public_orders.py` (samme domæneopløsning som JSON-API'et), så der ikke er to steder der udleder "hvilken rute/runde gælder denne request". - **`GET /`** — bestillingssiden, hvis en runde er åben (`order.html`: rundeoverskrift, `intro_text` som rå HTML — admin- betroet fritekst, ligesom `MailTemplate.body_html` — vinkatalog grupperet pr. kategori med antal-input pr. linje, sidebar med live-beregnet EUR/DKK-total, navn/email/telefon + "modtag fremtidige mails"-checkbox, samt et link til `/tilmelding`); ellers tilmeldingssiden (`signup.html`, samme skabelon som `/tilmelding`, med en ekstra sætning om at ingen runde er åben). - **`GET /tilmelding`** — samme tilmeldingsformular, altid tilgængelig uanset rundestatus (kun navn+email). - **`GET /afhentning`** — viser `Route.meeting_info` som rå HTML; falder tilbage til en pæn besked hvis feltet er tomt. - **`GET /bestilling-modtaget`** — dedikeret bekræftelsesside efter en gennemført bestilling (ikke en inline besked — bevidst valgt efter brugertest, se nedenfor). Gør det eksplicit at bestillingen først er gyldig ved modtaget betaling, og linker tilbage til `/`. - Begge formularer sender via `fetch()` (vanilla JS, `app/static/ site.js`) direkte til de eksisterende JSON-endpoints (`/public/orders`/`/public/signup`). Ved en succesfuld bestilling omdirigeres browseren til `/bestilling-modtaget` (`window.location. href`); tilmeldingsformularen viser i stedet en inline besked. Fejlbeskeder fra `detail`-feltet vises altid inline. - **Bevidst udeladt:** "Om"-siden (fandtes ikke i det gamle site), i18n/fransk oversættelse (se Fase 2), JS-drevet mobil-hamburgermenu (nav'en er blot CSS-responsiv). - **Browser-testet af brugeren** (ikke kun maskinverificeret HTML): live totalberegning, ordre- og tilmeldingsflow, tom-bestilling- fejlbesked, og responsivt layout ved tre skærmbredder. Fandt og rettede undervejs: (1) den oprindelige inline bestillingsbekræftelse skjulte vinkataloget — erstattet af `/bestilling-modtaget`-siden; (2) et CSS-layoutbug hvor `flex-basis` (sat til brug for bredde i to-kolonne-layoutet) blev fejlagtigt fortolket som højde efter `flex-direction: column`-skiftet under 800px, hvilket gav et stort tomt mellemrum mellem vinkatalog og sidebar på smalle skærme. ## Afhentningsliste (opgave 10) `GET /purchase-rounds/{round_id}/pickup-list` — admin, org-scoped via den eksisterende `get_round_in_organization`. Genskaber det gamle systems eksporterede afhentningsliste (delt af brugeren som reference: `Bestillinger.html`) mod den nye datamodel: én printside pr. ordre (`page-break-before` i CSS), sorteret efter `order_number` (se Order ovenfor), med ordrenummeret stort i øverste højre hjørne af hver side. Pr. ordre vises kontaktinfo, flaske-/kasseantal (`Kasser = flasker / 6`, afrundet til 2 decimaler — originalen viste et urundet float som fx `0.8333333333333334`, det er rettet), og én tabel pr. vinkategori (kategorinavn + antal+vinnavn pr. linje, ingen priser — det er en pakkeliste, ikke en kvittering). Almindelig `HTMLResponse`; admin printer siden fra browseren. Renderes via `app/templates/pickup_list.html` (samme Jinja2-mønster som den offentlige side, egen `Jinja2Templates`-instans i `purchase_rounds.py`). ## Admin-UI-arkitektur (opgave 11) Opgave 11 (React admin-UI) er delt op ligesom opgave 8: **11a** (scaffold + hosting-mekanisme, ✅). **11b** viste sig under afklaring at være for stort til én omgang og er selv delt i tre dele, alle stadig under "11b" (ingen ny bogstav-opdeling), **alle tre nu ✅**: **del 1** (delt frontend-infrastruktur + Deltagere-CRUD), **del 2** (runder + vinliste), **del 3** (mail-skabelon-editor + annoncér-knap). **11c** (ordreoversigt/markér betalt) er ikke bygget endnu. **Hosting-beslutning:** Admin-UI'et (`admin-ui/`, React + TypeScript, almindelig CSS — ingen framework) serveres af **samme FastAPI-app** som API'et og den offentlige side, men på sit **eget dedikerede domæne** (`ADMIN_DOMAIN`-settingen, default `admin.localhost`, sættes til fx `admin.vinindkoeb.dk` i produktion — reverse proxy'en skal have en tilsvarende indgang, ligesom organisationernes `public_domain`'er). Begrundelse: den offentlige side (`public_site.py`) ejer allerede roden `/` domæneopløst via `Host`-headeren (8b) — admin-UI'et kan derfor ikke også ligge på `/` på et organisations-domæne uden at kollidere. Ved at give admin-UI'et sit eget domæne, håndteret af samme `Host`-header-teknik, opnås desuden **ingen CORS-behov**: admin-UI'ets `fetch`-kald til `/auth`, `/participants` osv. går til samme origin siden selv blev hentet fra. **Mekanismen** (`app/admin_site.py`): `AdminDomainDispatch`, en rå ASGI-middleware registreret via `app.add_middleware(...)` i `app/main.py`. Pr. request: hvis `Host` matcher `ADMIN_DOMAIN` **og** stien ikke er et af de faste API-præfikser (`/auth`, `/participants`, osv. — se `_API_PREFIXES`, skal holdes i sync med routerne i `app/main.py`), serveres i stedet fra `app/admin_dist/` (Vites build-output) via `SPAStaticFiles` (falder tilbage til `index.html` for ukendte stier, så React Router's client-side routing virker). Ellers går requesten uændret videre til den almindelige app (API'et er allerede host-agnostisk — virker på ethvert domæne — og den offentlige side fortsætter uændret på organisations-domænerne). Et sikkerhedsnet: hvis `app/admin_dist/` ikke findes (frisk clone, frontend'en er ikke bygget endnu), deaktiveres dispatch'en stille i stedet for at crashe hele appen ved opstart. **Build:** `admin-ui/vite.config.ts` skriver direkte til `../app/admin_dist` (`npm run build` i `admin-ui/`, intet manuelt kopi-trin). Både `admin-ui/node_modules/` og `app/admin_dist/` er gitignored — en frisk clone skal køre `npm install && npm run build` i `admin-ui/` før admin-domænet virker (API'et og den offentlige side fungerer også uden). Lokal udvikling: `npm run dev` i `admin-ui/` bruger Vites egen dev-server med `server.proxy` videresendt til `http://127.0.0.1:8001`, uafhængigt af dispatch-mekanismen. **11a's indhold er bevidst minimalt** (kun nok til at bevise hele kæden virker): en login-side, email+password → `POST /auth/login` form-encoded per `OAuth2PasswordRequestForm`, JWT gemmes i `localStorage`. Login og log ud er browser-testet af brugeren (ModHeader, `Host: admin.localhost`) og virker. **11b del 1** (frontend-infrastruktur + Deltagere-CRUD) bygger videre på dette: - **`admin-ui/src/api.ts`** — delt `apiFetch`-wrapper (sætter `Authorization: Bearer`, kaster en fejl med API'ets `detail`-besked ved ikke-2xx), samt `elevate()`/`decodeJwtExpiry()`. - **`admin-ui/src/AuthContext.tsx`** — React Context: session overlever nu en sideopdatering (kalder `GET /auth/me` med det gemte token ved appstart, i stedet for at nulstille til login-siden — en mangel fra 11a). Holder desuden en **eleveret sessions- tilstand** (`isElevated`, `elevatedToken`, `requestElevation()`): eleveringen er bevidst en **synlig, separat handling** ("Forhøj rettigheder"-knap i navbaren, ✅ browser-testet) — destruktive knapper som "Slet permanent" er kun synlige/aktive mens `isElevated` er sand (udløber automatisk efter ~5 minutter, samme som backend'ens `elevation_expire_minutes`). Dette er bevidst strengere end blot at kunne, teknisk set, kalde `/auth/elevate` usynligt i baggrunden ved hvert destruktivt klik — elevering skal *føles* som en bevidst handling, ikke ske transparent bag et enkelt klik (fundet under brugertest: et enkelt klik + kun en browser-`confirm()` var utilstrækkeligt). - **`react-router-dom`** — `Layout.tsx` (navbar: Deltagere-link, elevations-knap for superadmins, brugernavn, log ud) + en **`ParticipantsPage`**: liste, opret, redigér (inkl. aktiv/inaktiv), hård-sletning (kun synlig for superadmins der er eleverede). - **Ny backend-route:** `GET /routes` (`app/routers/routes.py`, `RoutePublic`-model) — minimal, kun læsning, org-scoped. UI'et har brug for rutens id for at oprette deltagere; dette er **ikke** den udskudte rute-indstillings-redigeringsskærm (se Fase 1 nedenfor). - **Fundet og rettet under brugertest:** `GET /participants` uden et eksplicit `limit` rammer API'ets default (100) — med 308 rigtige deltagere (sorteret efter stigende id) betød det at hverken alle deltagere eller nyoprettede deltagere (som altid får det højeste id) kunne ses. `ParticipantsPage` sender nu eksplicit `limit=500` (API'ets maksimum). Rigtig pagination er udskudt til Fase 2, hvis listen en dag vokser forbi 500. **11b del 2** (runder + vinliste) bygger videre på samme infrastruktur — ingen backend-ændringer var nødvendige, al CRUD fandtes allerede (opgave 6): - **`RoundsPage`** (liste + opret) og **`RoundDetailPage`** (`/runder/:roundId` — redigér alle felter, status-overgange, slet, "kopiér denne runde", samt en nestet vinliste-sektion: `GET /wine-offerings` + `GET /wine-categories` joines client-side for kategorinavn, da `WineOfferingPublic` kun har `category_id`). `409`-fejl fra rundens dato-constraint (påkrævet så snart status ikke er `draft`) vises direkte som API'ets fejlbesked — ingen duplikeret validering i frontend. - **`dateUtils.ts`** — konverterer mellem backend'ens ISO-datoer og ``/`type="datetime-local">`. `Åbner` og `Bestillingsfrist` bruger kun dato (ingen tidspunkt — fundet under brugertest); `Afhentning` beholder tidspunkt (afhentning sker på et konkret klokkeslæt). - **Lukkede runder kan nu også slettes fra UI'et**, når eleveret (fundet under brugertest — kun kladde-sletning var understøttet først): samme mønster som deltager-hård-sletning i del 1 (`elevatedToken` fra `AuthContext` i stedet for det almindelige token). Åbne runder kan aldrig slettes (håndhævet server-side, ingen UI-vej udenom). - **Bevidst udeladt** (matcher 8/11-opdelingen): annoncér-knap og mail-skabeloner (del 3, se nedenfor); link til opgave 10's afhentningsliste (autentificeret HTML-endpoint — kræver en særskilt løsning, da et almindeligt `` ikke sender `Authorization`-headeren). **11b del 3** (mail-skabeloner + annoncér) afslutter 11b. Ingen backend-ændringer nødvendige — opgave 7a's `MailTemplate`-CRUD og 7b's `/announce`-endpoint fandtes allerede, blot uden noget UI: - **`TemplatesPage`** (`/skabeloner`) — én sektion pr. `event_type` (Runde åbnet/Ordre bekræftet/Betaling bekræftet), hver med Emne + Indhold (rå HTML i en `