vinindkoeb/CLAUDE.md
carsten 3592fbf845 Opgave 9: admin-ordreoversigt + "markér betalt"
GET /orders (filtre: purchase_round_id, route_id, payment_status)
giver admin en oversigt pr. ordre med deltagerinfo, vinlinjer og
beregnet total. POST /orders/{id}/mark-paid registrerer betalingen
(payment_status=paid, paid_at) og udløser mail-event #3
(payment_confirmed) — afviser med 409 hvis ordren allerede er betalt.

Udtrukket render/send/log-logikken fra 8a's _send_order_confirmation
til en delt app/services/order_mail.py::send_order_event_mail,
parameteriseret over MailEventType, så order_confirmed og
payment_confirmed ikke længere duplikerer den samme kode. Som med
order_confirmed er mail-afsendelsen best-effort: en manglende
skabelon eller afsenderkonfiguration blokerer aldrig selve
betalingsregistreringen.

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

269 lines
14 KiB
Markdown

# 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
<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
- **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" (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.
## 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-9):**
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.
**Resterende opgaver:**
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.