diff --git a/CLAUDE.md b/CLAUDE.md index dd8ba93..3ed4374 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,65 +12,128 @@ vinbonden Horcher-familiens egne ruter i Frankrig/Belgien), selvom kun - Backend: FastAPI + SQLModel + PostgreSQL + Alembic (migrations) - Frontend: React (admin-UI) + eksisterende offentlige bestillingsside (fortsat intet login for deltagere) -- Mail: SMTP-udbyder — afklares +- 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: TRADITION, SELECTION, - LES IMPERTINENTS, GRANDS CRUS, VENDANGES TARDIVES, CREMANT, MAGNUM, - SANS ALCOOL osv.) - - navn, sortering +- **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") - - navn + - `name` (unik) - **User** — admin-login, tilhører en Organization - - simpelt login v1 (password-hash + session/JWT), struktureret så - passkey/WebAuthn kan tilføjes senere uden brud + - `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) - - navn, mødested/kontaktperson/telefon (fritekst, forudfyldes fra - forrige runde) + - `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 + ") - **Participant** — tilhører en Route - - navn, mail, telefon, is_active + - `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 - **PurchaseRound** — tilhører en Route - - sæson (forår/efterår), år, status (kladde/åben/lukket) - - eur_dkk_rate (valgfri — ikke alle organisationer skal omregne) - - intro_text, pickup_info_text (forudfyldes fra forrige runde, - kun dato/sæson skal typisk rettes) + - `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 - - navn (inkl. størrelse, fx "75cl" — ikke separat felt v1) - - vintage/årgang, pris (EUR) - - is_organic (boolean — sat via checkbox, kilde: AB-mærket på - producentens prisliste) + - `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 (ubetalt/betalt), paid_at + - `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 -1. Runde åbnes → mail til alle aktive deltagere på ruten -2. Ordre afgivet → kvittering til deltager + notifikation til admin (findes allerede) -3. **Nyt:** Admin markerer ordre "betalt" → automatisk kvitteringsmail til deltager -SMTP: Postal (selvhostet) +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 + notifikation til admin + (findes allerede i den gamle offentlige bestillingsside — flyttes i + opgave 8) +3. **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 -1. Sæt FastAPI + SQLModel + PostgreSQL op med Alembic -2. Definér modellerne ovenfor (inkl. Organization/Route-hierarki, - selvom kun én organisation/rute findes i dag) -3. Manuel dataindtastning: deltagere (og evt. en aktiv runde) fra MongoDB til PostgreSQL -4. Simpelt login til User -5. Admin CRUD: deltagere (inkl. aktiv/inaktiv — erstatter "tom bestilling"-tricket) -6. Admin CRUD: runder + vinliste, med "kopiér fra forrige runde" - (alle felter, inkl. kategori, forudfyldt og frit redigerbare) -7. "Annoncér runde": skabelonmail til alle aktive deltagere på ruten + +**Færdige opgaver (1-7):** +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`) + +**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