Datamodel section now reflects reality: PurchaseRound's season/year was replaced with opens_at/order_deadline_at/pickup_at + a free-text name (per user feedback during task 2); WineOffering's vintage was dropped (folded into name); added the MailTemplate/MailLog models and Route's sender_name/sender_email that opgave 7 introduced; noted Order/OrderLine exist as models but have no API yet. Fase 1 task list marks 1-7 done and expands 7 into its actual 7a/7b/7c shape. Added a short auth line (JWT + sudo-style elevation) and resolved the "Mail: SMTP-udbyder — afklares" placeholder now that Postal is wired up. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
7.1 KiB
Fælles Vinindkøb — Projektplan
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) + eksisterende offentlige bestillingsside (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")
-
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
-
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_at- Model findes, men ingen CRUD/API endnu — kommer med opgave 8/9
-
OrderLine — ordre + wine_offering + antal
- Model findes, men ingen CRUD/API endnu — kommer med opgave 8/9
-
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
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 + notifikation til admin (findes allerede i den gamle offentlige bestillingsside — flyttes i opgave 8)
- Nyt: Admin markerer ordre "betalt" → automatisk kvitteringsmail til deltager (opgave 9)
Event #2 og #3 er modelleret i MailEventType
(order_confirmed/payment_confirmed) og kan allerede have skabeloner
oprettet via 7a's CRUD, men selve udløsningen (fra ordre-flowet i
opgave 8/9) er ikke bygget endnu — 7b's afsendelses-/logmønster
(rendering, send_mail, MailLog) er genbrugbart når det sker.
Fase 1 — nuværende scope
Færdige opgaver (1-7):
- ✅ 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:
Resterende opgaver: 8. Flyt offentlig bestillingsformular til nyt API (fortsat uden login) 9. Admin-ordreoversigt pr. runde med "markér betalt" → trigger mail #3 10. Genskab afhentningsliste (HTML-udtræk til print) mod ny datamodel 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