# 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 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` - Oprettes via den offentlige bestillings-API (opgave 8a/8b, ingen login); admin-ordreoversigt + "markér betalt" er stadig opgave 9 - **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. - Begge formularer sender via `fetch()` (vanilla JS, `app/static/ site.js`) direkte til de eksisterende JSON-endpoints (`/public/orders`/`/public/signup`) — ingen full-page reload, fejlbeskeder fra `detail`-feltet vises 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). ## 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) genbruger 7b's render/send/log-mønster for én deltager. Manglende skabelon blokerer bevidst **ikke** bestillingen (kun `/announce`'s admin-flow fejler hårdt på det) — en konfigurationsfejl må aldrig stoppe en rigtig kundes ordre. 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. **Nyt:** Admin markerer ordre "betalt" → automatisk kvitteringsmail til deltager (opgave 9 — bygger videre på samme mønster) Event #3 er modelleret i `MailEventType` (`payment_confirmed`) og kan allerede have en skabelon oprettet via 7a's CRUD, men selve udløsningen (fra "markér betalt"-handlingen i opgave 9) er ikke bygget endnu. ## Fase 1 — nuværende scope **Færdige opgaver (1-7, 8a-c):** 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). **Bemærk:** den interaktive JS (live totalberegning, fetch-baseret formular-indsendelse) er ikke maskinverificeret (udviklingsmiljøet har ikke browser- adgang) — kun HTML-renderingen er testet. Bør afprøves i en rigtig browser før siden går i produktion. **Resterende opgaver:** 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 - 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.