vinindkoeb/CLAUDE.md
carsten 53a5138d45 Update CLAUDE.md to match actual implementation (opgave 1-7)
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>
2026-09-28 20:54:31 +02:00

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æ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
  • 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
    • 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. 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

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 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