RoundsPage (liste + opret) og RoundDetailPage (/runder/:roundId — redigér, status-overgange, slet, "kopiér denne runde", nestet vinliste-CRUD). Ingen backend-ændringer nødvendige, al CRUD fandtes allerede (opgave 6). 409-fejl fra rundens dato-constraint vises direkte som API'ets fejlbesked i stedet for at duplikere reglen i frontend. To fund fra brugertest, begge rettet: 1. Åbningsdato og bestillingsfrist krævede et tidspunkt (datetime-local) uden reelt behov — skiftet til rene datofelter (dateUtils.ts). Afhentningstidspunktet beholder klokkeslæt. 2. Kun kladde-runder kunne slettes fra UI'et. Lukkede runder kan nu også slettes, når eleveret — samme elevations-mønster som deltager-hård-sletning i del 1 (elevatedToken fra AuthContext). Åbne runder kan fortsat aldrig slettes (håndhævet server-side). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
430 lines
24 KiB
Markdown
430 lines
24 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 + TypeScript admin-UI (`admin-ui/`, opgave 11a —
|
|
scaffold, se "Admin-UI-arkitektur" nedenfor) + 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`
|
|
- `order_number` (opgave 10) — fortløbende nummer **pr. runde**
|
|
(starter på 1 for hver runde), `UNIQUE(purchase_round_id,
|
|
order_number)`. Tildeles atomisk i `POST /public/orders` via en
|
|
`SELECT ... FOR UPDATE`-lås på rundens række + `MAX+1`, så
|
|
samtidige bestillinger lige efter en runde åbner ikke kan kollidere
|
|
eller give huller i nummerrækken. Bruges som sorteringsnøgle og
|
|
stort synligt nummer på afhentningslisten (se nedenfor), og er
|
|
tilgængelig som `{{order_number}}` i mail-skabeloner
|
|
- 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.
|
|
|
|
## Afhentningsliste (opgave 10)
|
|
|
|
`GET /purchase-rounds/{round_id}/pickup-list` — admin, org-scoped via
|
|
den eksisterende `get_round_in_organization`. Genskaber det gamle
|
|
systems eksporterede afhentningsliste (delt af brugeren som
|
|
reference: `Bestillinger.html`) mod den nye datamodel: én printside
|
|
pr. ordre (`page-break-before` i CSS), sorteret efter `order_number`
|
|
(se Order ovenfor), med ordrenummeret stort i øverste højre hjørne af
|
|
hver side. Pr. ordre vises kontaktinfo, flaske-/kasseantal (`Kasser =
|
|
flasker / 6`, afrundet til 2 decimaler — originalen viste et urundet
|
|
float som fx `0.8333333333333334`, det er rettet), og én tabel pr.
|
|
vinkategori (kategorinavn + antal+vinnavn pr. linje, ingen priser —
|
|
det er en pakkeliste, ikke en kvittering). Almindelig `HTMLResponse`;
|
|
admin printer siden fra browseren. Renderes via
|
|
`app/templates/pickup_list.html` (samme Jinja2-mønster som den
|
|
offentlige side, egen `Jinja2Templates`-instans i
|
|
`purchase_rounds.py`).
|
|
|
|
## Admin-UI-arkitektur (opgave 11)
|
|
|
|
Opgave 11 (React admin-UI) er delt op ligesom opgave 8: **11a**
|
|
(scaffold + hosting-mekanisme, ✅). **11b** viste sig under afklaring
|
|
at være for stort til én omgang og er selv delt i tre dele, alle
|
|
stadig under "11b" (ingen ny bogstav-opdeling): **del 1** (delt
|
|
frontend-infrastruktur + Deltagere-CRUD, ✅, se nedenfor), **del 2**
|
|
(runder + vinliste, ikke bygget) og **del 3** (mail-skabelon-editor +
|
|
annoncér-knap, ikke bygget — "annoncér" er reelt ubrugelig uden en
|
|
måde at oprette en `round_announced`-skabelon på). **11c**
|
|
(ordreoversigt/markér betalt) er heller ikke bygget endnu.
|
|
|
|
**Hosting-beslutning:** Admin-UI'et (`admin-ui/`, React + TypeScript,
|
|
almindelig CSS — ingen framework) serveres af **samme FastAPI-app**
|
|
som API'et og den offentlige side, men på sit **eget dedikerede
|
|
domæne** (`ADMIN_DOMAIN`-settingen, default `admin.localhost`,
|
|
sættes til fx `admin.vinindkoeb.dk` i produktion — reverse proxy'en
|
|
skal have en tilsvarende indgang, ligesom organisationernes
|
|
`public_domain`'er). Begrundelse: den offentlige side
|
|
(`public_site.py`) ejer allerede roden `/` domæneopløst via
|
|
`Host`-headeren (8b) — admin-UI'et kan derfor ikke også ligge på `/`
|
|
på et organisations-domæne uden at kollidere. Ved at give admin-UI'et
|
|
sit eget domæne, håndteret af samme `Host`-header-teknik, opnås desuden
|
|
**ingen CORS-behov**: admin-UI'ets `fetch`-kald til `/auth`,
|
|
`/participants` osv. går til samme origin siden selv blev hentet fra.
|
|
|
|
**Mekanismen** (`app/admin_site.py`): `AdminDomainDispatch`, en rå
|
|
ASGI-middleware registreret via `app.add_middleware(...)` i
|
|
`app/main.py`. Pr. request: hvis `Host` matcher `ADMIN_DOMAIN` **og**
|
|
stien ikke er et af de faste API-præfikser (`/auth`, `/participants`,
|
|
osv. — se `_API_PREFIXES`, skal holdes i sync med routerne i
|
|
`app/main.py`), serveres i stedet fra `app/admin_dist/` (Vites
|
|
build-output) via `SPAStaticFiles` (falder tilbage til `index.html`
|
|
for ukendte stier, så React Router's client-side routing virker).
|
|
Ellers går requesten uændret videre til den almindelige app (API'et
|
|
er allerede host-agnostisk — virker på ethvert domæne — og den
|
|
offentlige side fortsætter uændret på organisations-domænerne).
|
|
Et sikkerhedsnet: hvis `app/admin_dist/` ikke findes (frisk clone,
|
|
frontend'en er ikke bygget endnu), deaktiveres dispatch'en stille i
|
|
stedet for at crashe hele appen ved opstart.
|
|
|
|
**Build:** `admin-ui/vite.config.ts` skriver direkte til
|
|
`../app/admin_dist` (`npm run build` i `admin-ui/`, intet manuelt
|
|
kopi-trin). Både `admin-ui/node_modules/` og `app/admin_dist/` er
|
|
gitignored — en frisk clone skal køre `npm install && npm run build`
|
|
i `admin-ui/` før admin-domænet virker (API'et og den offentlige side
|
|
fungerer også uden). Lokal udvikling: `npm run dev` i `admin-ui/`
|
|
bruger Vites egen dev-server med `server.proxy` videresendt til
|
|
`http://127.0.0.1:8001`, uafhængigt af dispatch-mekanismen.
|
|
|
|
**11a's indhold er bevidst minimalt** (kun nok til at bevise hele
|
|
kæden virker): en login-side, email+password → `POST /auth/login`
|
|
form-encoded per `OAuth2PasswordRequestForm`, JWT gemmes i
|
|
`localStorage`. Login og log ud er browser-testet af brugeren
|
|
(ModHeader, `Host: admin.localhost`) og virker.
|
|
|
|
**11b del 1** (frontend-infrastruktur + Deltagere-CRUD) bygger videre
|
|
på dette:
|
|
- **`admin-ui/src/api.ts`** — delt `apiFetch`-wrapper (sætter
|
|
`Authorization: Bearer`, kaster en fejl med API'ets `detail`-besked
|
|
ved ikke-2xx), samt `elevate()`/`decodeJwtExpiry()`.
|
|
- **`admin-ui/src/AuthContext.tsx`** — React Context: session
|
|
overlever nu en sideopdatering (kalder `GET /auth/me` med det
|
|
gemte token ved appstart, i stedet for at nulstille til login-siden
|
|
— en mangel fra 11a). Holder desuden en **eleveret sessions-
|
|
tilstand** (`isElevated`, `elevatedToken`, `requestElevation()`):
|
|
eleveringen er bevidst en **synlig, separat handling** ("Forhøj
|
|
rettigheder"-knap i navbaren, ✅ browser-testet) — destruktive
|
|
knapper som "Slet permanent" er kun synlige/aktive mens
|
|
`isElevated` er sand (udløber automatisk efter ~5 minutter, samme
|
|
som backend'ens `elevation_expire_minutes`). Dette er bevidst
|
|
strengere end blot at kunne, teknisk set, kalde `/auth/elevate`
|
|
usynligt i baggrunden ved hvert destruktivt klik — elevering skal
|
|
*føles* som en bevidst handling, ikke ske transparent bag et enkelt
|
|
klik (fundet under brugertest: et enkelt klik + kun en
|
|
browser-`confirm()` var utilstrækkeligt).
|
|
- **`react-router-dom`** — `Layout.tsx` (navbar: Deltagere-link,
|
|
elevations-knap for superadmins, brugernavn, log ud) + en
|
|
**`ParticipantsPage`**: liste, opret, redigér (inkl. aktiv/inaktiv),
|
|
hård-sletning (kun synlig for superadmins der er eleverede).
|
|
- **Ny backend-route:** `GET /routes` (`app/routers/routes.py`,
|
|
`RoutePublic`-model) — minimal, kun læsning, org-scoped. UI'et har
|
|
brug for rutens id for at oprette deltagere; dette er **ikke** den
|
|
udskudte rute-indstillings-redigeringsskærm (se Fase 1 nedenfor).
|
|
- **Fundet og rettet under brugertest:** `GET /participants` uden et
|
|
eksplicit `limit` rammer API'ets default (100) — med 308 rigtige
|
|
deltagere (sorteret efter stigende id) betød det at hverken alle
|
|
deltagere eller nyoprettede deltagere (som altid får det højeste id)
|
|
kunne ses. `ParticipantsPage` sender nu eksplicit `limit=500`
|
|
(API'ets maksimum). Rigtig pagination er udskudt til Fase 2, hvis
|
|
listen en dag vokser forbi 500.
|
|
|
|
**11b del 2** (runder + vinliste) bygger videre på samme
|
|
infrastruktur — ingen backend-ændringer var nødvendige, al CRUD
|
|
fandtes allerede (opgave 6):
|
|
- **`RoundsPage`** (liste + opret) og **`RoundDetailPage`**
|
|
(`/runder/:roundId` — redigér alle felter, status-overgange, slet,
|
|
"kopiér denne runde", samt en nestet vinliste-sektion:
|
|
`GET /wine-offerings` + `GET /wine-categories` joines client-side
|
|
for kategorinavn, da `WineOfferingPublic` kun har `category_id`).
|
|
`409`-fejl fra rundens dato-constraint (påkrævet så snart status
|
|
ikke er `draft`) vises direkte som API'ets fejlbesked — ingen
|
|
duplikeret validering i frontend.
|
|
- **`dateUtils.ts`** — konverterer mellem backend'ens ISO-datoer og
|
|
`<input type="date">`/`type="datetime-local">`. `Åbner` og
|
|
`Bestillingsfrist` bruger kun dato (ingen tidspunkt — fundet under
|
|
brugertest); `Afhentning` beholder tidspunkt (afhentning sker på et
|
|
konkret klokkeslæt).
|
|
- **Lukkede runder kan nu også slettes fra UI'et**, når eleveret
|
|
(fundet under brugertest — kun kladde-sletning var understøttet
|
|
først): samme mønster som deltager-hård-sletning i del 1
|
|
(`elevatedToken` fra `AuthContext` i stedet for det almindelige
|
|
token). Åbne runder kan aldrig slettes (håndhævet server-side,
|
|
ingen UI-vej udenom).
|
|
- **Bevidst udeladt** (matcher 8/11-opdelingen): annoncér-knap og
|
|
mail-skabeloner (del 3); link til opgave 10's afhentningsliste
|
|
(autentificeret HTML-endpoint — kræver en særskilt løsning, da et
|
|
almindeligt `<a href>` ikke sender `Authorization`-headeren).
|
|
|
|
## 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-10, 11a, 11b del 1-2):**
|
|
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.
|
|
10. ✅ Fortløbende ordrenummer pr. runde + afhentningsliste — se Order
|
|
og "Afhentningsliste" ovenfor.
|
|
11. Simpelt React admin-UI til pkt. 5, 6, 7, 9 — delt i tre:
|
|
- **11a**: ✅ Scaffold + domæne-baseret hosting-mekanisme — se
|
|
"Admin-UI-arkitektur" ovenfor.
|
|
- **11b**: Deltagere, runder/vinliste, annoncering — delt i tre
|
|
(se "Admin-UI-arkitektur" ovenfor): **del 1** ✅
|
|
(frontend-infrastruktur + Deltagere-CRUD), **del 2** ✅
|
|
(runder + vinliste), **del 3** (mail-skabelon-editor +
|
|
annoncér-knap, ikke bygget).
|
|
- **11c**: Ordreoversigt/markér betalt — ikke bygget.
|
|
|
|
**Resterende opgaver:**
|
|
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
|
|
- Rigtig pagination i admin-UI'ets deltagerliste, hvis antallet af
|
|
deltagere vokser forbi API'ets maksimale sidestørrelse (500) — se
|
|
"Admin-UI-arkitektur" ovenfor
|
|
- Rute-indstillings-skærm i admin-UI'et (navn, mødested,
|
|
afsender-navn/email, `public_domain`) — kun én rute findes i dag,
|
|
direkte database-redigering er fint indtil videre
|
|
- 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.
|