vinindkoeb/CLAUDE.md
carsten d360e704df Tilføj permanent test-rute + rute-vælger i admin-UI'et
Opretter en ny, rigtig rute ("Fælles Vinindkøb (test)", id 9,
test.vinindkoeb.dk, afsender "Test - Fælles Vinindkøb"
<test@vinindkoeb.dk>) direkte i databasen, så brugeren kan teste hele
flowet — inkl. rigtig mail-udsendelse via Postal — uden at røre de
308 rigtige deltagere. Mere robust end de midlertidige isolerede
sandboxes brugt til verifikation indtil nu.

Med to ruter under samme organisation kan admin-UI'et ikke længere
antage "der er kun én rute": RouteContext.tsx (samme mønster som
AuthContext) henter ruterne én gang efter login og holder det valgte
rute-id (persisteret i localStorage). Layout.tsx får en rute-vælger i
navbaren (kun vist når der er mere end én rute). ParticipantsPage,
RoundsPage og TemplatesPage havde hver deres egen "GET /routes, brug
routes[0]"-logik — omskrevet til at læse fra useRoute() i stedet, så
de automatisk genindlæser når man skifter rute.

DNS-post og reverse proxy-indgang for test.vinindkoeb.dk skal
stadig sættes op manuelt uden for denne kodebase, samme mønster som
vinindkoeb.dk/admin.vinindkoeb.dk.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 03:11:28 +02:00

28 KiB

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 finn@vinindkoeb.dk")
    • 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
    • To rigtige ruter findes nu (id 4, vinindkoeb.dk — den rigtige, med de 308 rigtige deltagere) og en permanent test-rute ("Fælles Vinindkøb (test)", id 9, test.vinindkoeb.dk, afsender "Test - Fælles Vinindkøb" test@vinindkoeb.dk), oprettet direkte i databasen (ingen rute-opret-skærm i admin-UI'et endnu). Formål: lade brugeren teste hele flowet (inkl. rigtig mail-udsendelse via Postal) uden at røre de rigtige deltagere — mere robust end de midlertidige isolerede sandboxes der er brugt til verifikation indtil nu. Test-ruten er tom (ingen deltagere/runder/skabeloner seedet) — brugeren opretter selv testdata via admin-UI'et. Kræver stadig manuel opsætning uden for denne kodebase: en DNS-post for test.vinindkoeb.dk + en reverse proxy-indgang til samme backend (samme mønster som vinindkoeb.dk/ admin.vinindkoeb.dk).
  • 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 nu også ✅ — hele opgave 11 er dermed færdig.

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 <input type="date">/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 <a href> 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 <textarea>, bevidst ingen rich-text-editor- afhængighed) og et hint om hvilke {{variabler}} der er tilgængelige for netop den event-type (genlæst fra send_order_event_mail/announce_purchase_round). Opret/gem/slet pr. sektion — ingen elevation nødvendig (backend kræver kun CurrentUser).
  • "Annoncér runde"-knap på RoundDetailPage, vist når status === 'open'. Sender rigtig mail til alle aktive deltagere på ruten og er ikke idempotent — derfor et eksplicit window.confirm(...) først. Viser resultatet (sendt/fejlet/deltagere i alt) direkte på siden.
  • Rute 4 har nu rigtige mail-skabeloner for alle tre event-typer (oprettet af brugeren under browser-test af editoren — det tidligere udskudte punkt er dermed løst som en sideeffekt). "Annoncér" er fortsat kun afprøvet i en fuldstændig isoleret sandbox (en midlertidig separat rute, ikke rute 4) for at undgå at sende en utilsigtet rigtig annoncering til de 308 rigtige deltagere under udvikling.

11c (ordreoversigt/markér betalt) afslutter opgave 11. Ingen backend-ændringer nødvendige — alt data kommer fra opgave 9's allerede eksisterende GET /orders?purchase_round_id=<id> (OrderAdminView), al aggregering (flaske-/beløbstotal, udestående, den runde-brede vinliste) beregnes client-side.

  • pages/OrdersPage.tsx (/runder/:roundId/ordrer, linket fra RoundsPage og RoundDetailPage) — en to-niveaus harmonika (bestemt af brugeren): øverste fold viser rundens samlede flasketal, total og udestående (defineret som summen af ordrer der endnu ikke er markeret betalt — ikke bogstaveligt "total minus ubetalte", som ville givet summen af de betalte ordrer i stedet), foldet ud til den samlede, kategori-grupperede vinliste for hele runden. Derunder én fold pr. ordre (sorteret efter order_number, med en rød/grøn kant der viser betalt/ubetalt-status uden at skulle folde ud), foldet ud til en "Markér betalt"-knap (POST /orders/{id}/mark-paid, skjult og erstattet af "Betalt [dato]" hvis allerede betalt) + ordrens egen kategori-grupperede vinliste. Alle folder kan være åbne samtidig.
  • Beløb vises i DKK (rundens eur_dkk_rate, samme omregningsprincip som den offentlige bestillingsside og kvitteringsmailen) — falder tilbage til EUR hvis runden ikke har en kurs sat. Fundet under brugertest: beløb blev oprindeligt vist i EUR, og både den samlede og de individuelle vinlister manglede kategori-gruppering samt havde antal efter vinnavn i stedet for før — alle tre rettet.

Rute-vælger (tilføjet efter 11c, da en anden rigtig test-rute kom til — se Route ovenfor): admin-ui/src/RouteContext.tsx, samme Context-mønster som AuthContext.tsx. Henter GET /routes én gang efter login, holder selectedRouteId (gemt i localStorage, så valget overlever en sideopdatering — falder tilbage til den første rute hvis intet/et ugyldigt valg er gemt). Layout.tsx viser et <select> i navbaren (kun når der er mere end én rute). ParticipantsPage/RoundsPage/TemplatesPage brugte hver deres egen GET /routes-kald + antog "første rute" — de er nu omskrevet til at læse selectedRouteId fra useRoute() i stedet, og genindlæser automatisk når man skifter rute i navbaren. RoundDetailPage/ OrdersPage er uændrede — de arbejder allerede på én bestemt rundes id fra URL'en, uafhængigt af den globalt valgte rute.

Mail-events

  1. Runde åbnes → mail til alle aktive deltagere på ruten. Implementeret (opgave 7a-c): skabelon pr. rute (MailTemplate, event_type=round_announced), udløses manuelt via POST /purchase-rounds/{id}/announce, logges i MailLog, statusopdateres løbende via Postal-webhook (POST /webhooks/postal, RSA-SHA256-signatur verificeret mod DKIM-nøglen fra DNS).
  2. Ordre afgivet → kvittering til deltager. Implementeret (opgave 8a, omlagt i 8b): POST /public/orders (ingen login, ingen route_id i URL'en — ruten afgøres af Host-headeren, se Route ovenfor). Kvitteringen genskaber bevidst det gamle systems fulde vinkatalog-layout (efterligner den fysiske bestillingsseddel ved vinbonden). Notifikation til admin ved ny ordre findes ikke endnu.
  3. Admin markerer ordre "betalt" → automatisk kvitteringsmail til deltager. Implementeret (opgave 9): POST /orders/{id}/mark-paid sætter payment_status=paid + paid_at, og forsøger derefter at sende payment_confirmed-mailen.

Event #2 og #3 deler nu samme render/send/log-logik via app/services/order_mail.py::send_order_event_mail (udtrukket fra 8a's oprindelige lokale helper, da den ville være blevet duplikeret en gang til). Fælles for begge: en manglende skabelon eller manglende sender_name/sender_email på ruten blokerer ikke selve handlingen (ordre-oprettelsen hhv. betalings-registreringen) — kun /announce's admin-flow (event #1) fejler hårdt på det. En konfigurationsfejl må aldrig forhindre en rigtig kundes ordre eller en admins betalingsregistrering; mail-forsøget er altid best-effort og logges/advares om i stedet.

Fase 1 — nuværende scope

Færdige opgaver (1-11):

  1. ✅ FastAPI + SQLModel + PostgreSQL + Alembic scaffolding
  2. ✅ Datamodellerne (Organization/Route-hierarki)
  3. ✅ Deltager-migrering fra MongoDB (308 deltagere importeret, dedupliceret på (route, email))
  4. ✅ Login til User (JWT) + efterfølgende "sudo-stil" elevation til superadmin-handlinger
  5. ✅ Admin CRUD: deltagere (inkl. aktiv/inaktiv, unik email pr. rute, GDPR-hård-sletning)
  6. ✅ Admin CRUD: runder + vinliste, "kopiér fra forrige runde" (inkl. vinliste og kategori)
  7. ✅ "Annoncér runde" — se detaljer under Mail-events og nedenfor:
    • 7a: MailTemplate-CRUD (POST/GET/PATCH/DELETE /mail-templates)
    • 7b: Afsendelse via Postal + MailLog (POST /purchase-rounds/{id}/announce, GET /mail-logs)
    • 7c: Postal-webhook til leveringsstatus (POST /webhooks/postal)
  8. "Flyt offentlig bestillingsformular til nyt API" — delt i tre:
    • 8a: ✅ Backend-API (GET /public/routes/{id}/current-round, POST /public/routes/{id}/orders), ad-hoc deltageroprettelse, kvitteringsmail
    • 8b: ✅ Domænebaseret rute-opløsning + tilmeldings-endpoint. route_id fjernet fra alle offentlige URL'er (var forældet, før nogen rigtig frontend var bygget mod det) til fordel for Route.public_domain + Host-header-opslag (se Route ovenfor). GET /public/current-round returnerer nu altid 200 (current_round: null når ingen runde er åben) i stedet for en 404, så frontenden kan skifte mellem bestillings- og tilmeldingsvisning uden fejlhåndtering. Nyt POST /public/signup (kun navn+email, se Participant ovenfor) — dækker både "ingen runde åben → vis tilmelding" og "runde åben → link til tilmelding uden bestilling" (selve linket/UI-skiftet er en 8c-detalje).
    • 8c: ✅ Selve den nye offentlige frontend — se "Den offentlige side" ovenfor. Server-renderet af FastAPI (Jinja2 + vanilla JS/CSS, intet build-step, intet separat deploy). Layoutet er modelleret efter den gamle sides bestillingsside (delt som skærmbillede under planlægning), og efterfølgende browser-testet og justeret af brugeren (se "Den offentlige side" ovenfor for de to fund/rettelser).
  9. ✅ Admin-ordreoversigt + "markér betalt" — se Order og Mail-events #3 ovenfor. GET /orders (filtre: purchase_round_id, route_id, payment_status) returnerer en admin-visning pr. ordre med deltagernavn/-email/-telefon, vinlinjer (navn+pris) og total_price_eur. POST /orders/{id}/mark-paid er en almindelig admin-handling (ikke superadmin-eleveret — hverken destruktiv eller irreversibel) der afviser med 409 hvis ordren allerede er betalt.
  10. ✅ Fortløbende ordrenummer pr. runde + afhentningsliste — se Order og "Afhentningsliste" ovenfor.
  11. Simpelt React admin-UI til pkt. 5, 6, 7, 9 — delt i tre:
    • 11a: ✅ Scaffold + domæne-baseret hosting-mekanisme — se "Admin-UI-arkitektur" ovenfor.
    • 11b: ✅ Deltagere, runder/vinliste, annoncering — delt i tre (se "Admin-UI-arkitektur" ovenfor), alle færdige: del 1 (frontend-infrastruktur + Deltagere-CRUD), del 2 (runder + vinliste), del 3 (mail-skabelon-editor + annoncér-knap).
    • 11c: ✅ Ordreoversigt/markér betalt — se "Admin-UI-arkitektur" ovenfor. Hele opgave 11 er dermed færdig.

Resterende opgaver: 12. Testkør en rigtig runde gennem hele flowet

Fase 2 — senere

  • enablebanking.com: automatisk match af indbetalinger mod ubetalte ordrer → kalder samme "markér betalt"-logik
  • Passkey-login til admin
  • Rigtig pagination i admin-UI'ets deltagerliste, hvis antallet af deltagere vokser forbi API'ets maksimale sidestørrelse (500) — se "Admin-UI-arkitektur" ovenfor
  • Rute-indstillings-skærm i admin-UI'et (navn, mødested, afsender-navn/email, public_domain) — kun én rute findes i dag, direkte database-redigering er fint indtil videre
  • Rute-vælger på bestillingssiden (når flere ruter er aktive samtidig)
  • sold_only_by_case-flag + validering, hvis det bliver relevant
  • i18n/fransk oversættelse af den offentlige side (8c blev bevidst bygget uden nogen form for i18n-forberedelse — Horcher-ruterne er stadig ikke i drift). Når det bliver aktuelt: afklar om det skal være pr.-rute (via Route) eller pr.-organisation, og hvordan admin-indtastet fritekst (intro_text, meeting_info m.fl.) håndteres på flere sprog.