Order.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. Nummeret
starter på 1 for hver runde (UNIQUE(purchase_round_id, order_number))
og er nu tilgængeligt som {{order_number}} i mail-skabeloner.
GET /purchase-rounds/{id}/pickup-list genskaber den gamle systems
afhentningsliste (delt af brugeren som reference) mod den nye
datamodel: én printside pr. ordre, sorteret efter order_number (vist
stort i øverste højre hjørne), kontaktinfo, flaske-/kasseantal
(pænt afrundet — originalen viste et urundet float), og
vinlinjer grupperet pr. kategori.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
16 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 (admin-UI, ikke bygget endnu) + den offentlige
bestillings-/tilmeldingsside (opgave 8c) — server-renderet af
FastAPI selv (Jinja2-templates i
app/templates/+ vanilla JS/CSS iapp/static/, intet build-step). Fortsat intet login for deltagere. - Mail: Postal (selvhostet),
postal.carsteng.dk, domænevinindkoeb.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 viapwdlib)- Passkey/WebAuthn kan tilføjes senere som en separat
credentials-tabel uden ændringer på
Userselv
-
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 (fxvinindkoeb.dk). Opgave 8b: en reverse proxy sender alle organisationers domæner til samme backend, som selv afgør rute ud fra request'ensHost-header (ingenroute_idi URL'en for de offentlige endpoints) — ét frontend-build kan dermed betjene flere organisationer
-
Participant — tilhører en Route
name,email,phone,is_activeemailer påkrævet ved oprettelse af nye deltagere;phoneer 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_activeer den normale vej (bevarer historik for deltagere der forlader og kommer tilbage); en rigtigDELETEfindes 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_activefra 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 aldrigphone, 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/closedopens_at,order_deadline_at,pickup_at— alle tre påkrævede så snart status ikke erdraft(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:
draftkan slettes af enhver admin;openkan aldrig slettes;closedkræ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_atorder_number(opgave 10) — fortløbende nummer pr. runde (starter på 1 for hver runde),UNIQUE(purchase_round_id, order_number). Tildeles atomisk iPOST /public/ordersvia enSELECT ... 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) ogPOST /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_textsom rå HTML — admin- betroet fritekst, ligesomMailTemplate.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— viserRoute.meeting_infosom 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 fradetail-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 hvorflex-basis(sat til brug for bredde i to-kolonne-layoutet) blev fejlagtigt fortolket som højde efterflex-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).
Mail-events
- Runde åbnes → mail til alle aktive deltagere på ruten. Implementeret
(opgave 7a-c): skabelon pr. rute (
MailTemplate,event_type=round_announced), udløses manuelt viaPOST /purchase-rounds/{id}/announce, logges iMailLog, statusopdateres løbende via Postal-webhook (POST /webhooks/postal, RSA-SHA256-signatur verificeret mod DKIM-nøglen fra DNS). - Ordre afgivet → kvittering til deltager. Implementeret (opgave
8a, omlagt i 8b):
POST /public/orders(ingen login, ingenroute_idi URL'en — ruten afgøres afHost-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. - Admin markerer ordre "betalt" → automatisk kvitteringsmail til
deltager. Implementeret (opgave 9):
POST /orders/{id}/mark-paidsætterpayment_status=paid+paid_at, og forsøger derefter at sendepayment_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-10):
- ✅ FastAPI + SQLModel + PostgreSQL + Alembic scaffolding
- ✅ Datamodellerne (Organization/Route-hierarki)
- ✅ Deltager-migrering fra MongoDB (308 deltagere importeret,
dedupliceret på
(route, email)) - ✅ Login til User (JWT) + efterfølgende "sudo-stil" elevation til superadmin-handlinger
- ✅ Admin CRUD: deltagere (inkl. aktiv/inaktiv, unik email pr. rute, GDPR-hård-sletning)
- ✅ Admin CRUD: runder + vinliste, "kopiér fra forrige runde" (inkl. vinliste og kategori)
- ✅ "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)
- 7a:
- "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_idfjernet fra alle offentlige URL'er (var forældet, før nogen rigtig frontend var bygget mod det) til fordel forRoute.public_domain+Host-header-opslag (se Route ovenfor).GET /public/current-roundreturnerer nu altid200(current_round: nullnår ingen runde er åben) i stedet for en404, så frontenden kan skifte mellem bestillings- og tilmeldingsvisning uden fejlhåndtering. NytPOST /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).
- 8a: ✅ Backend-API (
- ✅ 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) ogtotal_price_eur.POST /orders/{id}/mark-paider en almindelig admin-handling (ikke superadmin-eleveret — hverken destruktiv eller irreversibel) der afviser med409hvis ordren allerede er betalt. - ✅ Fortløbende ordrenummer pr. runde + afhentningsliste — se Order og "Afhentningsliste" ovenfor.
Resterende opgaver: 11. Simpelt React admin-UI til pkt. 5, 6, 7, 9 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
- 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_infom.fl.) håndteres på flere sprog.