Order/OrderLine now have a real write path (the public order API), so their "model exists, no API yet" notes are replaced with what's actually there. Participant's entry documents the ad-hoc-create/ always-update-on-order behavior. Mail-events #2 marked implemented with the same detail level as #1. Fase 1 task 8 split into 8a (done)/ 8b (pending, own stack decision) matching the 7a/7b/7c precedent. Added a short process note at the top, per explicit user request: this file should be updated after each (sub)task, not just on request. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
170 lines
8.5 KiB
Markdown
170 lines
8.5 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.
|
|
|
|
## 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æ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>")
|
|
|
|
- **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
|
|
|
|
- **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, ingen
|
|
login); admin-ordreoversigt + "markér betalt" er stadig opgave 9
|
|
|
|
- **OrderLine** — ordre + wine_offering + antal
|
|
- Samme som Order — skrives af opgave 8a's 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`
|
|
|
|
## 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): `POST /public/routes/{route_id}/orders` (ingen login) 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):**
|
|
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 to:
|
|
- **8a**: ✅ Backend-API (`GET /public/routes/{id}/current-round`,
|
|
`POST /public/routes/{id}/orders`), ad-hoc deltageroprettelse,
|
|
kvitteringsmail
|
|
- **8b**: Selve den nye offentlige frontend — brugeren har ikke
|
|
adgang til den gamle frontends kode (hårdt koblet til det gamle
|
|
API), så en ny bygges. Egen stack-beslutning, ikke taget endnu.
|
|
|
|
**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
|