Compare commits

...

10 commits

Author SHA1 Message Date
d3e6b92d9f Opdatér INSTALL.md til Proxmox LXC-container-topologi
Tilføjer trin 1 (opret containeren: unprivileged LXC, Debian 12,
statisk IP) og retter netværks-antagelsen: da reverse proxy'en
(CloudPanel) kører i en anden container/VM på samme Proxmox-host end
appen, skal backend'en bindes til 0.0.0.0 i stedet for 127.0.0.1 og
være tilgængelig over det interne Proxmox-netværk — låst ned med
Proxmox' egen container-firewall (kun CloudPanel-containerens IP får
adgang) i stedet for 127.0.0.1-only. Reverse proxy-trinnet peger nu på
containerens IP, og PostgreSQL installeres nativt i samme container
som appen (bekræftet valg).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 10:31:41 +02:00
6b5680af07 Henvis til INSTALL.md og bootstrap-scriptet fra CLAUDE.md
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 10:17:58 +02:00
e337c4a49e Tilføj produktions-installationsvejledning + bootstrap-script
INSTALL.md dækker hele vejen fra en frisk server til en kørende
løsning: forudsætninger, database, .env, migrations, admin-UI-build,
en systemd-unit til backend'en, domæne-/reverse proxy-krav (holdt
værktøjs-uafhængigt, med en kort CloudPanel-note), DNS, Postal-webhook,
rute-opsætning, verifikation og fremtidige opdateringer.

scripts/create_admin_user.py lukker en reel mangel: der findes ingen
selvbetjenings-registrering, så uden en bruger i databasen kan ingen
logge ind på en frisk installation. Scriptet opretter (eller genbruger)
en organisation + rute, samt en ny superadmin-bruger med korrekt
hashet adgangskode — samme mønster som det eksisterende
scripts/import_participants.py. Verificeret i en isoleret test
(oprettet bruger kunne logge ind via POST /auth/login), testdata
ryddet op igen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 10:17:36 +02:00
d360e704df Tilføj permanent test-rute + rute-vælger i admin-UI'et
Opretter en ny, rigtig rute ("Fælles Vinindkøb (test)", id 9,
test.vinindkoeb.dk, afsender "Test - Fælles Vinindkøb"
<test@vinindkoeb.dk>) direkte i databasen, så brugeren kan teste hele
flowet — inkl. rigtig mail-udsendelse via Postal — uden at røre de
308 rigtige deltagere. Mere robust end de midlertidige isolerede
sandboxes brugt til verifikation indtil nu.

Med to ruter under samme organisation kan admin-UI'et ikke længere
antage "der er kun én rute": RouteContext.tsx (samme mønster som
AuthContext) henter ruterne én gang efter login og holder det valgte
rute-id (persisteret i localStorage). Layout.tsx får en rute-vælger i
navbaren (kun vist når der er mere end én rute). ParticipantsPage,
RoundsPage og TemplatesPage havde hver deres egen "GET /routes, brug
routes[0]"-logik — omskrevet til at læse fra useRoute() i stedet, så
de automatisk genindlæser når man skifter rute.

DNS-post og reverse proxy-indgang for test.vinindkoeb.dk skal
stadig sættes op manuelt uden for denne kodebase, samme mønster som
vinindkoeb.dk/admin.vinindkoeb.dk.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 03:11:28 +02:00
16d89db527 Opgave 11c: Ordreoversigt + markér betalt (harmonika-UI)
OrdersPage (/runder/:roundId/ordrer, linket fra RoundsPage og
RoundDetailPage) — en to-niveaus harmonika designet af brugeren:
øverste fold viser rundens flasketotal, total og udestående beløb
(sum af ordrer der endnu ikke er betalt), foldet ud til rundens
samlede, kategori-grupperede vinliste. Derunder én fold pr. ordre
(sorteret efter order_number, rød/grøn kant for betalt/ubetalt-status),
foldet ud til "Markér betalt" (POST /orders/{id}/mark-paid) + ordrens
egen kategori-grupperede vinliste. Ingen backend-ændringer nødvendige
— alt data kommer fra opgave 9's eksisterende GET /orders, al
aggregering sker client-side.

Verificeret i en fuldstændig isoleret sandbox (midlertidig separat
rute) for slet ikke at røre de 308 rigtige deltageres data.

To fund fra brugertest, begge rettet:
1. Beløb blev vist i EUR — vises nu i DKK via rundens eur_dkk_rate
   (falder tilbage til EUR hvis runden ikke har en kurs), samme
   omregningsprincip som den offentlige side/kvitteringsmailen.
2. Vinlisterne manglede kategori-gruppering (kun den samlede liste
   havde det først) og viste antal efter vinnavn i stedet for før —
   begge lister er nu kategori-grupperede med antal først.

Dette afslutter opgave 11 (hele admin-UI'et).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 02:57:55 +02:00
cae8657ec8 Opgave 11b (del 3): Mail-skabeloner + annoncér
TemplatesPage (/skabeloner) — én sektion pr. event_type
(round_announced/order_confirmed/payment_confirmed), hver med
opret/gem/slet og et hint om hvilke {{variabler}} der er tilgængelige
for netop den mailtype. Ingen backend-ændringer nødvendige, al CRUD
fandtes allerede (opgave 7a).

"Annoncér runde"-knap på RoundDetailPage (kun ved status "open"), med
et eksplicit confirm() da handlingen sender rigtig mail til alle
aktive deltagere på ruten og ikke er idempotent. Kalder det
eksisterende /announce-endpoint (opgave 7b) og viser resultatet.

Verificeret i en fuldstændig isoleret sandbox (midlertidig separat
rute) for at undgå en utilsigtet rigtig annoncering til de 308
rigtige deltagere under udvikling. Brugeren oprettede under
browser-test rigtige mail-skabeloner for alle tre event-typer på
rute 4 — det tidligere udskudte punkt er dermed løst.

Dette afslutter opgave 11b (alle tre dele).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 02:34:07 +02:00
588888cd9e Opgave 11b (del 2): Runder + vinliste
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>
2026-09-29 01:59:46 +02:00
62d464fda2 Opgave 11b (del 1): frontend-infrastruktur + Deltagere-CRUD
Deler 11b op i tre dele under afklaring (runder/vinliste og
mail-skabeloner/annoncering er egne skærme). Denne del bygger den
delte infrastruktur alle senere admin-skærme skal bruge:

- api.ts: autentificeret fetch-wrapper + elevate()/decodeJwtExpiry().
- AuthContext.tsx: session overlever nu en sideopdatering (GET
  /auth/me ved appstart). Holder en eksplicit "eleveret
  session"-tilstand — react-router-dom + Layout (navbar) +
  ParticipantsPage (liste/opret/redigér/hård-sletning).
- Ny GET /routes (minimal, kun læsning, org-scoped) — UI'et skal
  kende rutens id for at oprette deltagere; ikke en rute-
  indstillings-skærm (den er bevidst udskudt).

To fund fra brugertest, begge rettet:
1. "Slet permanent" krævede kun et klik + en browser-confirm(), og
   elevered sig selv usynligt i baggrunden ved hvert klik — det
   underminerede hele pointen med en bevidst elevations-handling.
   Elevation er nu en synlig, separat handling ("Forhøj
   rettigheder"-knap i navbaren); destruktive knapper vises kun mens
   en eleveret session er aktiv (udløber automatisk efter ~5 min).
2. GET /participants uden et eksplicit limit ramte API'ets default på
   100 — med 308 rigtige deltagere (sorteret efter stigende id)
   kunne hverken alle deltagere eller nyoprettede deltagere ses.
   ParticipantsPage sender nu limit=500 (API'ets maksimum).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 01:24:40 +02:00
fb4a3fb732 Noter at 11a's login/logud er browser-verificeret
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 00:45:18 +02:00
60fb7db119 Opgave 11a: React admin-scaffold + domænebaseret hosting
Scaffolder admin-ui/ (Vite + React + TypeScript, almindelig CSS) med
en minimal login-side (POST /auth/login + GET /auth/me) der beviser
hele kæden virker. Vites build skriver direkte til app/admin_dist/.

Admin-UI'et serveres af samme FastAPI-app som API'et og den
offentlige side, men på sit eget dedikerede domæne (ADMIN_DOMAIN,
default admin.localhost) via en ny AdminDomainDispatch-middleware
(app/admin_site.py) der genbruger Host-header-teknikken fra 8b: den
offentlige side ejer allerede roden "/" domæneopløst, så admin-UI'et
kan ikke dele det domæne uden at kollidere. Samme trick undgår CORS
helt, da admin-UI'ets fetch-kald går til samme origin. API-kald
(/auth, /participants, ...) passerer uændret gennem til det
almindelige API uanset domæne; alt andet på admin-domænet serveres
fra den byggede SPA med index.html-fallback for client-side routing.
Et sikkerhedsnet deaktiverer dispatch'en stille hvis admin_dist/
mangler (frisk clone uden frontend-build), i stedet for at crashe
hele appen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 00:41:03 +02:00
36 changed files with 4068 additions and 4 deletions

View file

@ -8,3 +8,7 @@ POSTAL_API_KEY=change-me
# Find med: dig +short TXT <dkim-selector>._domainkey.<dit-domæne>
# og brug værdien af "p=" (base64 DER, ingen PEM-indpakning nødvendig)
POSTAL_WEBHOOK_PUBLIC_KEY_B64=change-me
# Domænet React admin-UI'et (admin-ui/) serveres på — reverse proxy'en
# skal sende dette domæne til samme backend, ligesom organisationernes
# public_domain gør
ADMIN_DOMAIN=admin.vinindkoeb.dk

4
.gitignore vendored
View file

@ -26,3 +26,7 @@ dist/
# Raw personal-data exports used for one-off migrations — not for version control
old_participants.json
old-emails/
# React admin-UI (admin-ui/) build artefact + dependencies — genskabes af npm run build
app/admin_dist/
admin-ui/node_modules/

225
CLAUDE.md
View file

@ -16,9 +16,16 @@ 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.
Se [INSTALL.md](INSTALL.md) for produktionsopsætning på en server
(database, `.env`, admin-UI-build, domæner/reverse proxy, DNS,
Postal-webhook m.m.), og `scripts/create_admin_user.py` for at
oprette den første organisation/rute/admin-bruger — der findes ingen
selvbetjenings-registrering i systemet.
## Tech stack
- Backend: FastAPI + SQLModel + PostgreSQL + Alembic (migrations)
- Frontend: React (admin-UI, ikke bygget endnu) + den offentlige
- 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.
@ -61,6 +68,20 @@ vinbonden Horcher-familiens egne ruter i Frankrig/Belgien), selvom kun
`Host`-header (ingen `route_id` i URL'en for de offentlige
endpoints) — ét frontend-build kan dermed betjene flere
organisationer
- **To rigtige ruter findes nu** (id 4, `vinindkoeb.dk` — den
rigtige, med de 308 rigtige deltagere) og en permanent test-rute
("Fælles Vinindkøb (test)", id 9, `test.vinindkoeb.dk`, afsender
"Test - Fælles Vinindkøb" <test@vinindkoeb.dk>), oprettet direkte
i databasen (ingen rute-opret-skærm i admin-UI'et endnu). Formål:
lade brugeren teste hele flowet (inkl. rigtig mail-udsendelse via
Postal) uden at røre de rigtige deltagere — mere robust end de
midlertidige isolerede sandboxes der er brugt til verifikation
indtil nu. Test-ruten er tom (ingen deltagere/runder/skabeloner
seedet) — brugeren opretter selv testdata via admin-UI'et.
**Kræver stadig manuel opsætning uden for denne kodebase:** en
DNS-post for `test.vinindkoeb.dk` + en reverse proxy-indgang til
samme backend (samme mønster som `vinindkoeb.dk`/
`admin.vinindkoeb.dk`).
- **Participant** — tilhører en Route
- `name`, `email`, `phone`, `is_active`
@ -197,6 +218,190 @@ admin printer siden fra browseren. Renderes via
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), **alle tre nu ✅**:
**del 1** (delt frontend-infrastruktur + Deltagere-CRUD), **del 2**
(runder + vinliste), **del 3** (mail-skabelon-editor +
annoncér-knap). **11c** (ordreoversigt/markér betalt) er nu også ✅ —
hele opgave 11 er dermed færdig.
**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, se nedenfor); 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).
**11b del 3** (mail-skabeloner + annoncér) afslutter 11b. Ingen
backend-ændringer nødvendige — opgave 7a's `MailTemplate`-CRUD og
7b's `/announce`-endpoint fandtes allerede, blot uden noget UI:
- **`TemplatesPage`** (`/skabeloner`) — én sektion pr. `event_type`
(Runde åbnet/Ordre bekræftet/Betaling bekræftet), hver med Emne +
Indhold (rå HTML i en `<textarea>`, bevidst ingen rich-text-editor-
afhængighed) og et hint om hvilke `{{variabler}}` der er
tilgængelige for netop den event-type (genlæst fra
`send_order_event_mail`/`announce_purchase_round`). Opret/gem/slet
pr. sektion — ingen elevation nødvendig (backend kræver kun
`CurrentUser`).
- **"Annoncér runde"-knap** på `RoundDetailPage`, vist når
`status === 'open'`. Sender rigtig mail til **alle aktive
deltagere på ruten** og er ikke idempotent — derfor et eksplicit
`window.confirm(...)` først. Viser resultatet
(sendt/fejlet/deltagere i alt) direkte på siden.
- **Rute 4 har nu rigtige mail-skabeloner for alle tre event-typer**
(oprettet af brugeren under browser-test af editoren — det tidligere
udskudte punkt er dermed løst som en sideeffekt). "Annoncér" er
fortsat kun afprøvet i en fuldstændig isoleret sandbox (en
midlertidig separat rute, ikke rute 4) for at undgå at sende en
utilsigtet rigtig annoncering til de 308 rigtige deltagere under
udvikling.
**11c** (ordreoversigt/markér betalt) afslutter opgave 11. Ingen
backend-ændringer nødvendige — alt data kommer fra opgave 9's
allerede eksisterende `GET /orders?purchase_round_id=<id>`
(`OrderAdminView`), al aggregering (flaske-/beløbstotal, udestående,
den runde-brede vinliste) beregnes client-side.
- **`pages/OrdersPage.tsx`** (`/runder/:roundId/ordrer`, linket fra
`RoundsPage` og `RoundDetailPage`) — en to-niveaus harmonika
(bestemt af brugeren): øverste fold viser rundens samlede
flasketal, total og **udestående** (defineret som summen af
ordrer der endnu ikke er markeret betalt — ikke bogstaveligt "total
minus ubetalte", som ville givet summen af de betalte ordrer i
stedet), foldet ud til den samlede, **kategori-grupperede**
vinliste for hele runden. Derunder én fold pr. ordre (sorteret
efter `order_number`, med en rød/grøn kant der viser
betalt/ubetalt-status uden at skulle folde ud), foldet ud til en
"Markér betalt"-knap (`POST /orders/{id}/mark-paid`, skjult og
erstattet af "Betalt [dato]" hvis allerede betalt) + ordrens egen
**kategori-grupperede** vinliste. Alle folder kan være åbne
samtidig.
- **Beløb vises i DKK** (rundens `eur_dkk_rate`, samme
omregningsprincip som den offentlige bestillingsside og
kvitteringsmailen) — falder tilbage til EUR hvis runden ikke har en
kurs sat. Fundet under brugertest: beløb blev oprindeligt vist i
EUR, og både den samlede og de individuelle vinlister manglede
kategori-gruppering samt havde antal efter vinnavn i stedet for
før — alle tre rettet.
**Rute-vælger** (tilføjet efter 11c, da en anden rigtig test-rute kom
til — se Route ovenfor): `admin-ui/src/RouteContext.tsx`, samme
Context-mønster som `AuthContext.tsx`. Henter `GET /routes` én gang
efter login, holder `selectedRouteId` (gemt i `localStorage`, så
valget overlever en sideopdatering — falder tilbage til den første
rute hvis intet/et ugyldigt valg er gemt). `Layout.tsx` viser et
`<select>` i navbaren (kun når der er mere end én rute).
`ParticipantsPage`/`RoundsPage`/`TemplatesPage` brugte hver deres egen
`GET /routes`-kald + antog "første rute" — de er nu omskrevet til at
læse `selectedRouteId` fra `useRoute()` i stedet, og genindlæser
automatisk når man skifter rute i navbaren. `RoundDetailPage`/
`OrdersPage` er uændrede — de arbejder allerede på én bestemt rundes
id fra URL'en, uafhængigt af den globalt valgte rute.
## Mail-events
1. Runde åbnes → mail til alle aktive deltagere på ruten. **Implementeret**
(opgave 7a-c): skabelon pr. rute (`MailTemplate`,
@ -229,7 +434,7 @@ logges/advares om i stedet.
## Fase 1 — nuværende scope
**Færdige opgaver (1-10):**
**Færdige opgaver (1-11):**
1. ✅ FastAPI + SQLModel + PostgreSQL + Alembic scaffolding
2. ✅ Datamodellerne (Organization/Route-hierarki)
3. ✅ Deltager-migrering fra MongoDB (308 deltagere importeret,
@ -277,15 +482,29 @@ logges/advares om i stedet.
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), alle færdige: **del 1**
(frontend-infrastruktur + Deltagere-CRUD), **del 2** (runder +
vinliste), **del 3** (mail-skabelon-editor + annoncér-knap).
- **11c**: ✅ Ordreoversigt/markér betalt — se
"Admin-UI-arkitektur" ovenfor. Hele opgave 11 er dermed færdig.
**Resterende opgaver:**
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
- 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

227
INSTALL.md Normal file
View file

@ -0,0 +1,227 @@
# Installationsvejledning — produktionsopsætning
Denne vejledning dækker opsætning af Vinindkøb-backend'en (API +
offentlig side + admin-UI) på en rigtig server, fra en frisk
installation til en kørende løsning. Se [README.md](README.md) for
lokal udvikling.
Vejledningen forudsætter at der allerede findes en kørende
Postal-instans (selvhostet mailserver — se CLAUDE.md's "Tech stack")
med DKIM sat op for det domæne der sendes mails fra. Selve
Postal-opsætningen er uden for denne vejlednings omfang.
**Antaget topologi** (Proxmox): appen kører i sin egen LXC-container,
sammen med sin PostgreSQL-database. Reverse proxy'en (CloudPanel)
kører i en **anden** container/VM på samme Proxmox-host — appens
container skal derfor være tilgængelig over det interne
Proxmox-netværk, ikke kun lokalt i sig selv. Kører reverse proxy'en i
stedet i **samme** container som appen, kan trin 9's `--host` i
stedet sættes til `127.0.0.1`, og firewall-reglen i samme trin
udelades.
## 1. Opret Proxmox-containeren
- **Unprivileged LXC-container** — intet i denne app kræver
privilegeret adgang.
- **OS-template:** Debian 12 anbefales (stabil, letvægt). `uv` styrer
selv Python 3.13 uafhængigt af systemets Python-version, så
distro-valget er ikke kritisk.
- **Størrelse:** appen er lille (én organisation i drift i dag) og
kører sin egen Postgres i samme container — 1-2 vCPU, 1-2 GB RAM og
8-10 GB disk er rigeligt til at starte med.
- **Netværk:** giv containeren en **statisk IP** på Proxmox' interne
bridge (fx `vmbr0`), så CloudPanel-containeren kan nå den
konsistent.
## 2. Forudsætninger i containeren
- Python 3.13 (se `.python-version`)
- [`uv`](https://docs.astral.sh/uv/) (Python-pakkehåndtering)
- Node.js + npm (til at bygge admin-UI'et, `admin-ui/`)
- PostgreSQL (`apt install postgresql`, da databasen kører i samme
container som appen)
- git
## 3. Hent koden
```bash
git clone <repo-url> /sti/til/vinindkoeb
cd /sti/til/vinindkoeb
uv sync
```
## 4. Database
Opret en Postgres-bruger og -database der matcher formatet i
`DATABASE_URL` (`postgresql+psycopg://<bruger>:<kodeord>@<host>:5432/<database>`):
```sql
CREATE USER vinindkoeb WITH PASSWORD '...';
CREATE DATABASE vinindkoeb OWNER vinindkoeb;
```
## 5. Miljøvariabler
```bash
cp .env.example .env
```
Udfyld hvert felt i `.env`:
| Variabel | Hvor den kommer fra |
|---|---|
| `DATABASE_URL` | Fra trin 4 (`@localhost`, da Postgres kører i samme container) |
| `ENVIRONMENT` | `production` |
| `SECRET_KEY` | Generér med `openssl rand -hex 32` |
| `ACCESS_TOKEN_EXPIRE_MINUTES` / `ELEVATION_EXPIRE_MINUTES` | Standardværdierne er fine, med mindre andet ønskes |
| `POSTAL_BASE_URL` / `POSTAL_API_KEY` | Fra Postal-instansens admin-UI (opret en API-nøgle til denne server) |
| `POSTAL_WEBHOOK_PUBLIC_KEY_B64` | DKIM-nøglen for afsenderdomænet — find med `dig +short TXT <dkim-selector>._domainkey.<domæne>`, brug værdien af `p=` |
| `ADMIN_DOMAIN` | Domænet admin-UI'et skal serveres på, fx `admin.vinindkoeb.dk` |
## 6. Migrations
```bash
uv run alembic upgrade head
```
Dette opretter alle tabeller og seeder `WineCategory` automatisk
(vinkategorierne er globale, delt af alle organisationer/ruter).
## 7. Første organisation, rute og admin-bruger
Der findes ingen selvbetjenings-registrering — uden en bruger i
databasen kan ingen logge ind. Brug bootstrap-scriptet:
```bash
uv run python scripts/create_admin_user.py \
--org "Din Organisation" \
--route "Din Rute" \
--email admin@example.com \
--name "Dit Navn" \
--password "et-sikkert-kodeord"
```
Scriptet genbruger en organisation/rute med samme navn, hvis den
allerede findes — sikkert at køre igen for at oprette endnu en
admin-bruger på en eksisterende organisation.
## 8. Byg admin-UI'et
```bash
cd admin-ui
npm install
npm run build # skriver til ../app/admin_dist
cd ..
```
Uden dette trin kører API'et og den offentlige side fint, men
`ADMIN_DOMAIN` viser ingenting (se `app/admin_site.py`s
sikkerhedsnet).
## 9. Kør backend'en som en vedvarende service
Eksempel på en `systemd`-unit (`/etc/systemd/system/vinindkoeb.service`):
```ini
[Unit]
Description=Vinindkøb backend
After=network.target postgresql.service
[Service]
Type=simple
User=vinindkoeb
WorkingDirectory=/sti/til/vinindkoeb
ExecStart=/sti/til/vinindkoeb/.venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000
Restart=always
EnvironmentFile=/sti/til/vinindkoeb/.env
[Install]
WantedBy=multi-user.target
```
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now vinindkoeb
```
Da reverse proxy'en (CloudPanel) kører i en anden container, skal
backend'en kunne nås over Proxmox' interne netværk — deraf
`--host 0.0.0.0` i stedet for `127.0.0.1`. Det skal dog **ikke** stå
åbent for alt andet: brug Proxmox' egen indbyggede firewall på
containeren (fanen "Firewall" på containeren, eller Datacenter →
Firewall) til én regel: tillad TCP på port `8000` kun fra
CloudPanel-containerens IP, afvis ellers. Det er mere idiomatisk og
nemmere at vedligeholde end `iptables`/`nftables` inde i selve
gæste-OS'et.
## 10. Domæner + reverse proxy
Løsningen afgør selv hvilken rute/hvilket indhold der skal vises ud
fra requestens `Host`-header (se CLAUDE.md's "Route" og
"Admin-UI-arkitektur") — det betyder at **alle** domæner nedenfor
bare skal pege på samme kørende backend (samme container-IP og port,
fra trin 9), uanset hvilken reverse proxy-løsning der bruges:
- Organisationens/rutens `public_domain`-felt(er) i databasen, fx
`vinindkoeb.dk` og `test.vinindkoeb.dk`
- `ADMIN_DOMAIN` fra `.env`, fx `admin.vinindkoeb.dk`
**Med CloudPanel:** opret ét "Reverse Proxy"-site pr. domæne, der
peger på `<vinindkoeb-containerens-ip>:8000` (IP'en fra trin 1, porten
fra trin 9), og aktivér Let's Encrypt-certifikat for hvert site.
Bruges en anden løsning (nginx, Caddy, Traefik, ...), er princippet
det samme: én vhost/site pr. domæne, alle proxyet til samme
container-IP og port.
## 11. DNS
A-records (eller CNAME, alt efter opsætning) for hvert domæne fra
trin 10, der peger på **CloudPanel-serverens** offentlige IP-adresse
(ikke Vinindkøb-containerens — det er CloudPanel der tager imod
trafik udefra og sender den videre internt).
## 12. Postal-webhook
I Postal-instansens konfiguration: sæt webhook-URL'en til
`https://<det rigtige domæne>/webhooks/postal` (fx
`https://vinindkoeb.dk/webhooks/postal`) — det er sådan
leveringsstatus (sendt/leveret/afvist) opdateres asynkront på
`MailLog` (se opgave 7c).
## 13. Sæt rutens felter
For hver rute (den rigtige, og evt. en test-rute) skal disse felter
sættes direkte i databasen — der findes ingen rute-indstillings-skærm
i admin-UI'et endnu:
```sql
UPDATE route SET
public_domain = 'vinindkoeb.dk',
sender_name = 'Afsendernavn',
sender_email = 'afsender@vinindkoeb.dk'
WHERE id = <rute-id>;
```
## 14. Verifikation
1. `curl https://<rigtigt domæne>/health` → `{"status":"ok","database":"ok"}`.
2. Besøg det rigtige domæne i en browser → bestillings-/tilmeldingssiden.
3. Besøg `ADMIN_DOMAIN` → login-siden, log ind med brugeren fra trin 7.
4. Afprøv en tilmelding/bestilling på test-ruten (hvis en sådan er
sat op) og bekræft at en rigtig mail bliver sendt og logget i
`MailLog`.
## 15. Fremtidige opdateringer
```bash
git pull
uv sync
uv run alembic upgrade head
cd admin-ui && npm install && npm run build && cd ..
sudo systemctl restart vinindkoeb
```
## Backup
Ikke uddybet her, men en simpel `pg_dump`-baseret cron-opgave af
databasen anbefales som minimum.

View file

@ -1,7 +1,8 @@
# Vinindkøb v2
Admin-backend til fælles vinindkøb (FastAPI + SQLModel + PostgreSQL +
Alembic). Se [CLAUDE.md](CLAUDE.md) for projektplan og datamodel.
Alembic). Se [CLAUDE.md](CLAUDE.md) for projektplan og datamodel, og
[INSTALL.md](INSTALL.md) for produktionsopsætning på en server.
## Kom i gang
@ -13,3 +14,19 @@ uv run fastapi dev app/main.py
```
`GET /health` bekræfter at appen kan nå databasen.
## Admin-UI (React)
Serveres af samme app på et dedikeret domæne (`ADMIN_DOMAIN`, default
`admin.localhost`) — se CLAUDE.md's "Admin-UI-arkitektur". Skal bygges
før admin-domænet virker (API'et og den offentlige side kører fint
uden):
```
cd admin-ui
npm install
npm run build # skriver til ../app/admin_dist
```
Til udvikling af selve admin-UI'et: `npm run dev` i `admin-ui/`
(Vites egen dev-server, proxier API-kald til `127.0.0.1:8001`).

24
admin-ui/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

8
admin-ui/.oxlintrc.json Normal file
View file

@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}

32
admin-ui/README.md Normal file
View file

@ -0,0 +1,32 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.

12
admin-ui/index.html Normal file
View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Fælles Vinindkøb — Admin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1353
admin-ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

26
admin-ui/package.json Normal file
View file

@ -0,0 +1,26 @@
{
"name": "admin-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.4"
},
"devDependencies": {
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"oxlint": "^1.81.0",
"typescript": "~6.0.2",
"vite": "^8.3.0"
}
}

208
admin-ui/src/App.css Normal file
View file

@ -0,0 +1,208 @@
.login-card {
max-width: 360px;
margin: 10vh auto;
padding: 24px;
border: 1px solid #ddd;
border-radius: 8px;
}
.login-card h1 {
font-size: 1.2rem;
margin: 0 0 16px;
}
.login-card form label {
display: block;
font-weight: 600;
margin: 12px 0 4px;
}
.login-card form input {
display: block;
width: 100%;
padding: 8px;
font-size: 1rem;
box-sizing: border-box;
}
.login-card button {
width: 100%;
margin-top: 16px;
padding: 10px;
font-size: 1rem;
cursor: pointer;
}
.login-card .error {
color: #b3261e;
margin-top: 12px;
}
.navbar {
background: #1a1a1a;
color: #fff;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
}
.navbar-brand {
font-weight: 600;
}
.navbar-links {
display: flex;
align-items: center;
gap: 16px;
}
.route-select {
padding: 4px 8px;
}
.navbar-links a {
color: #fff;
text-decoration: none;
}
.navbar-links a.active {
text-decoration: underline;
}
.navbar-user {
color: #bbb;
font-size: 0.9rem;
}
.elevated-badge {
color: #7fd88f;
font-size: 0.9rem;
}
.hint {
color: #666;
font-size: 0.9rem;
font-style: italic;
}
.link-button {
background: none;
border: none;
color: #fff;
text-decoration: underline;
cursor: pointer;
font-size: 1rem;
padding: 0;
}
.content {
max-width: 900px;
margin: 0 auto;
padding: 24px;
}
.content .error {
color: #b3261e;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 16px;
}
th,
td {
text-align: left;
padding: 6px 8px;
border-bottom: 1px solid #eee;
}
.inline-form {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin: 12px 0;
}
.inline-form input {
padding: 6px;
}
.inline-form button {
padding: 6px 12px;
cursor: pointer;
}
.stacked-form {
max-width: 480px;
margin: 16px 0;
}
.stacked-form label {
display: block;
font-weight: 600;
margin: 12px 0 4px;
}
.stacked-form input,
.stacked-form select,
.stacked-form textarea {
display: block;
width: 100%;
padding: 8px;
font-size: 1rem;
box-sizing: border-box;
font-family: inherit;
}
.stacked-form textarea {
min-height: 80px;
}
.stacked-form button {
margin: 16px 8px 0 0;
padding: 8px 16px;
cursor: pointer;
}
.accordion {
margin-top: 16px;
}
.accordion-item {
border: 1px solid #ddd;
border-radius: 4px;
margin-bottom: 8px;
}
.accordion-header {
display: block;
width: 100%;
text-align: left;
padding: 12px;
background: #f7f7f7;
border: none;
cursor: pointer;
font-size: 1rem;
}
.accordion-header:hover {
background: #efefef;
}
.accordion-header.unpaid {
border-left: 4px solid #b3261e;
}
.accordion-header.paid {
border-left: 4px solid #2e7d32;
}
.accordion-body {
padding: 12px;
border-top: 1px solid #ddd;
}

45
admin-ui/src/App.tsx Normal file
View file

@ -0,0 +1,45 @@
import { Navigate, Route, BrowserRouter, Routes } from 'react-router-dom'
import { AuthProvider, useAuth } from './AuthContext'
import { Layout } from './Layout'
import { LoginPage } from './pages/LoginPage'
import { OrdersPage } from './pages/OrdersPage'
import { ParticipantsPage } from './pages/ParticipantsPage'
import { RoundDetailPage } from './pages/RoundDetailPage'
import { RoundsPage } from './pages/RoundsPage'
import { RouteProvider } from './RouteContext'
import { TemplatesPage } from './pages/TemplatesPage'
import './App.css'
function AppRoutes() {
const { currentUser, loading } = useAuth()
if (loading) return <p>Indlæser...</p>
if (!currentUser) return <LoginPage />
return (
<RouteProvider>
<Routes>
<Route element={<Layout />}>
<Route path="/deltagere" element={<ParticipantsPage />} />
<Route path="/runder" element={<RoundsPage />} />
<Route path="/runder/:roundId" element={<RoundDetailPage />} />
<Route path="/runder/:roundId/ordrer" element={<OrdersPage />} />
<Route path="/skabeloner" element={<TemplatesPage />} />
<Route path="*" element={<Navigate to="/deltagere" replace />} />
</Route>
</Routes>
</RouteProvider>
)
}
function App() {
return (
<BrowserRouter>
<AuthProvider>
<AppRoutes />
</AuthProvider>
</BrowserRouter>
)
}
export default App

View file

@ -0,0 +1,97 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
import {
apiFetch,
clearToken,
decodeJwtExpiry,
elevate as apiElevate,
getToken,
login as apiLogin,
setToken,
} from './api'
export interface CurrentUser {
id: number
email: string
name: string
is_active: boolean
is_superadmin: boolean
organization_id: number
}
interface AuthState {
currentUser: CurrentUser | null
loading: boolean
login: (email: string, password: string) => Promise<void>
logout: () => void
/** Kortlivet eleveret token — kun sat lige efter et bevidst "Forhøj rettigheder"-klik. */
elevatedToken: string | null
isElevated: boolean
requestElevation: () => Promise<void>
}
const AuthContext = createContext<AuthState | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null)
const [loading, setLoading] = useState(true)
const [elevatedToken, setElevatedToken] = useState<string | null>(null)
const [elevatedExpiresAt, setElevatedExpiresAt] = useState<number | null>(null)
const [, forceTick] = useState(0)
useEffect(() => {
const token = getToken()
if (!token) {
setLoading(false)
return
}
apiFetch<CurrentUser>('/auth/me', {}, token)
.then(setCurrentUser)
.catch(() => clearToken())
.finally(() => setLoading(false))
}, [])
// Genberegn isElevated løbende, så "Slet permanent"-knapper forsvinder
// igen af sig selv når den eleverede session udløber (uden en sideopdatering).
useEffect(() => {
if (elevatedExpiresAt === null) return
const interval = setInterval(() => forceTick((n) => n + 1), 5000)
return () => clearInterval(interval)
}, [elevatedExpiresAt])
const isElevated = elevatedExpiresAt !== null && Date.now() < elevatedExpiresAt
async function login(email: string, password: string) {
const token = await apiLogin(email, password)
setToken(token)
const user = await apiFetch<CurrentUser>('/auth/me', {}, token)
setCurrentUser(user)
}
function logout() {
clearToken()
setCurrentUser(null)
setElevatedToken(null)
setElevatedExpiresAt(null)
}
async function requestElevation() {
const token = await apiElevate()
const exp = decodeJwtExpiry(token)
setElevatedToken(token)
setElevatedExpiresAt(exp ? exp * 1000 : Date.now() + 5 * 60 * 1000)
}
return (
<AuthContext.Provider
value={{ currentUser, loading, login, logout, elevatedToken, isElevated, requestElevation }}
>
{children}
</AuthContext.Provider>
)
}
export function useAuth(): AuthState {
const context = useContext(AuthContext)
if (!context) throw new Error('useAuth skal bruges inden i AuthProvider')
return context
}

68
admin-ui/src/Layout.tsx Normal file
View file

@ -0,0 +1,68 @@
import { useState } from 'react'
import { NavLink, Outlet } from 'react-router-dom'
import { ApiError } from './api'
import { useAuth } from './AuthContext'
import { useRoute } from './RouteContext'
export function Layout() {
const { currentUser, logout, isElevated, requestElevation } = useAuth()
const { routes, selectedRouteId, selectRoute } = useRoute()
const [elevationError, setElevationError] = useState<string | null>(null)
async function handleElevate() {
setElevationError(null)
try {
await requestElevation()
} catch (err) {
setElevationError(err instanceof ApiError ? err.message : 'Kunne ikke forhøje rettigheder')
}
}
return (
<div>
<nav className="navbar">
<span className="navbar-brand">Fælles Vinindkøb — Admin</span>
<div className="navbar-links">
{routes.length > 1 && (
<select
className="route-select"
value={selectedRouteId ?? ''}
onChange={(e) => selectRoute(Number(e.target.value))}
>
{routes.map((route) => (
<option key={route.id} value={route.id}>
{route.name}
</option>
))}
</select>
)}
<NavLink to="/deltagere">Deltagere</NavLink>
<NavLink to="/runder">Runder</NavLink>
<NavLink to="/skabeloner">Skabeloner</NavLink>
{currentUser?.is_superadmin &&
(isElevated ? (
<span className="elevated-badge" title="Udløber automatisk efter 5 minutter">
Rettigheder forhøjet ✓
</span>
) : (
<button className="link-button" onClick={handleElevate}>
Forhøj rettigheder
</button>
))}
{currentUser && (
<>
<span className="navbar-user">{currentUser.name}</span>
<button className="link-button" onClick={logout}>
Log ud
</button>
</>
)}
</div>
</nav>
{elevationError && <p className="content error">{elevationError}</p>}
<main className="content">
<Outlet />
</main>
</div>
)
}

View file

@ -0,0 +1,59 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
import { apiFetch, ApiError } from './api'
export interface Route {
id: number
name: string
}
const ROUTE_STORAGE_KEY = 'vinindkoeb_admin_route_id'
interface RouteState {
routes: Route[]
selectedRouteId: number | null
selectRoute: (id: number) => void
loading: boolean
error: string | null
}
const RouteContext = createContext<RouteState | null>(null)
export function RouteProvider({ children }: { children: ReactNode }) {
const [routes, setRoutes] = useState<Route[]>([])
const [selectedRouteId, setSelectedRouteId] = useState<number | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
apiFetch<Route[]>('/routes')
.then((data) => {
setRoutes(data)
if (data.length === 0) return
const stored = localStorage.getItem(ROUTE_STORAGE_KEY)
const storedId = stored ? Number(stored) : null
const validStoredId = storedId !== null && data.some((r) => r.id === storedId) ? storedId : null
setSelectedRouteId(validStoredId ?? data[0].id)
})
.catch((err) => {
setError(err instanceof ApiError ? err.message : 'Kunne ikke hente ruter')
})
.finally(() => setLoading(false))
}, [])
function selectRoute(id: number) {
setSelectedRouteId(id)
localStorage.setItem(ROUTE_STORAGE_KEY, String(id))
}
return (
<RouteContext.Provider value={{ routes, selectedRouteId, selectRoute, loading, error }}>
{children}
</RouteContext.Provider>
)
}
export function useRoute(): RouteState {
const context = useContext(RouteContext)
if (!context) throw new Error('useRoute skal bruges inden i RouteProvider')
return context
}

74
admin-ui/src/api.ts Normal file
View file

@ -0,0 +1,74 @@
export const TOKEN_STORAGE_KEY = 'vinindkoeb_admin_token'
export class ApiError extends Error {}
export function getToken(): string | null {
return localStorage.getItem(TOKEN_STORAGE_KEY)
}
export function setToken(token: string): void {
localStorage.setItem(TOKEN_STORAGE_KEY, token)
}
export function clearToken(): void {
localStorage.removeItem(TOKEN_STORAGE_KEY)
}
async function extractErrorMessage(response: Response): Promise<string> {
try {
const body = await response.json()
if (typeof body.detail === 'string') return body.detail
if (Array.isArray(body.detail)) {
return body.detail.map((e: { msg?: string }) => e.msg || JSON.stringify(e)).join(', ')
}
return JSON.stringify(body.detail)
} catch {
return `Fejl (${response.status})`
}
}
/** Autentificeret fetch mod API'et — bruger det gemte token med mindre et andet gives via `token`. */
export async function apiFetch<T>(
path: string,
options: RequestInit = {},
token: string | null = getToken(),
): Promise<T> {
const headers = new Headers(options.headers)
if (token) headers.set('Authorization', `Bearer ${token}`)
const response = await fetch(path, { ...options, headers })
if (!response.ok) {
throw new ApiError(await extractErrorMessage(response))
}
if (response.status === 204) return undefined as T
return response.json() as Promise<T>
}
export async function login(email: string, password: string): Promise<string> {
const response = await fetch('/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ username: email, password }),
})
if (!response.ok) {
throw new ApiError(await extractErrorMessage(response))
}
const data = await response.json()
return data.access_token as string
}
/** Henter et kortlivet eleveret token (kræver ikke ny password-indtastning). */
export async function elevate(): Promise<string> {
const data = await apiFetch<{ access_token: string }>('/auth/elevate', { method: 'POST' })
return data.access_token
}
/** Læser `exp`-claimet (unix-sekunder) fra et JWT uden at verificere signaturen —
* kun til at vise/skjule UI, den rigtige kontrol sker altid server-side. */
export function decodeJwtExpiry(token: string): number | null {
try {
const payload = JSON.parse(atob(token.split('.')[1]))
return typeof payload.exp === 'number' ? payload.exp : null
} catch {
return null
}
}

29
admin-ui/src/dateUtils.ts Normal file
View file

@ -0,0 +1,29 @@
/** Konverterer en ISO-dato (UTC, fra backend) til værdien et
* <input type="datetime-local"> forventer, i browserens lokale tid. */
export function toDatetimeLocalValue(iso: string | null | undefined): string {
if (!iso) return ''
const d = new Date(iso)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
}
/** Konverterer en <input type="datetime-local">-værdi (browserens lokale tid)
* til en ISO-streng (UTC) API'et kan bruge, eller null hvis feltet er tomt. */
export function fromDatetimeLocalValue(value: string): string | null {
return value ? new Date(value).toISOString() : null
}
/** Samme som toDatetimeLocalValue, men kun dato (til felter uden brug for et tidspunkt). */
export function toDateValue(iso: string | null | undefined): string {
if (!iso) return ''
const d = new Date(iso)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}
/** Konverterer en <input type="date">-værdi (lokal dato, midnat) til en ISO-streng (UTC). */
export function fromDateValue(value: string): string | null {
if (!value) return null
const [year, month, day] = value.split('-').map(Number)
return new Date(year, month - 1, day).toISOString()
}

6
admin-ui/src/index.css Normal file
View file

@ -0,0 +1,6 @@
body {
margin: 0;
background: #fff;
color: #222;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}

10
admin-ui/src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View file

@ -0,0 +1,38 @@
import { useState, type FormEvent } from 'react'
import { useAuth } from '../AuthContext'
import { ApiError } from '../api'
export function LoginPage() {
const { login } = useAuth()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
async function handleSubmit(event: FormEvent) {
event.preventDefault()
setError(null)
try {
await login(email, password)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Der skete en fejl')
}
}
return (
<div className="login-card">
<h1>Fælles Vinindkøb — Admin</h1>
<form onSubmit={handleSubmit}>
<label>
Email
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
</label>
<label>
Adgangskode
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
</label>
<button type="submit">Log ind</button>
{error && <p className="error">{error}</p>}
</form>
</div>
)
}

View file

@ -0,0 +1,260 @@
import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { apiFetch, ApiError } from '../api'
interface PurchaseRound {
id: number
name: string
eur_dkk_rate: string | null
}
interface WineCategory {
id: number
name: string
sort_order: number
}
interface WineOffering {
id: number
category_id: number
}
interface OrderLineAdminView {
id: number
order_id: number
wine_offering_id: number
quantity: number
wine_name: string
unit_price: string
}
interface OrderAdminView {
id: number
participant_id: number
purchase_round_id: number
order_number: number
payment_status: 'unpaid' | 'paid'
paid_at: string | null
created_at: string
participant_name: string
participant_email: string
participant_phone: string | null
order_lines: OrderLineAdminView[]
total_price_eur: string
}
export function OrdersPage() {
const { roundId } = useParams<{ roundId: string }>()
const [round, setRound] = useState<PurchaseRound | null>(null)
const [orders, setOrders] = useState<OrderAdminView[]>([])
const [wineOfferings, setWineOfferings] = useState<WineOffering[]>([])
const [categories, setCategories] = useState<WineCategory[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [summaryOpen, setSummaryOpen] = useState(false)
const [openOrderIds, setOpenOrderIds] = useState<Set<number>>(new Set())
async function loadOrders(forRoundId: string) {
const data = await apiFetch<OrderAdminView[]>(`/orders?purchase_round_id=${forRoundId}&limit=500`)
setOrders(data.slice().sort((a, b) => a.order_number - b.order_number))
}
async function loadAll() {
if (!roundId) return
setLoading(true)
setError(null)
try {
const [roundData, wineOfferingData, categoryData] = await Promise.all([
apiFetch<PurchaseRound>(`/purchase-rounds/${roundId}`),
apiFetch<WineOffering[]>(`/wine-offerings?purchase_round_id=${roundId}&limit=500`),
apiFetch<WineCategory[]>('/wine-categories'),
loadOrders(roundId),
])
setRound(roundData)
setWineOfferings(wineOfferingData)
setCategories(categoryData)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Kunne ikke hente ordrer')
} finally {
setLoading(false)
}
}
useEffect(() => {
loadAll()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [roundId])
function toggleOrder(id: number) {
setOpenOrderIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
async function handleMarkPaid(order: OrderAdminView) {
if (!roundId) return
setError(null)
try {
await apiFetch(`/orders/${order.id}/mark-paid`, { method: 'POST' })
await loadOrders(roundId)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Kunne ikke markere ordren som betalt')
}
}
if (loading) return <p>Indlæser...</p>
if (!round) return <p className="error">{error ?? 'Runden blev ikke fundet'}</p>
const rate = round.eur_dkk_rate ? Number(round.eur_dkk_rate) : null
function formatEur(amountEur: number): string {
if (rate) return `${(amountEur * rate).toFixed(2)} kr`
return `${amountEur.toFixed(2)} EUR`
}
const totalBottles = orders.reduce(
(sum, o) => sum + o.order_lines.reduce((lineSum, l) => lineSum + l.quantity, 0),
0,
)
const totalAmount = orders.reduce((sum, o) => sum + Number(o.total_price_eur), 0)
const outstanding = orders
.filter((o) => o.payment_status === 'unpaid')
.reduce((sum, o) => sum + Number(o.total_price_eur), 0)
const categoryByWineOfferingId = new Map<number, WineCategory>()
for (const offering of wineOfferings) {
const category = categories.find((c) => c.id === offering.category_id)
if (category) categoryByWineOfferingId.set(offering.id, category)
}
const wineAggregation = new Map<number, { name: string; quantity: number; category: WineCategory | undefined }>()
for (const order of orders) {
for (const line of order.order_lines) {
const existing = wineAggregation.get(line.wine_offering_id)
if (existing) existing.quantity += line.quantity
else
wineAggregation.set(line.wine_offering_id, {
name: line.wine_name,
quantity: line.quantity,
category: categoryByWineOfferingId.get(line.wine_offering_id),
})
}
}
const wineRows = Array.from(wineAggregation.values()).sort((a, b) => {
const orderA = a.category?.sort_order ?? 0
const orderB = b.category?.sort_order ?? 0
if (orderA !== orderB) return orderA - orderB
return a.name.localeCompare(b.name)
})
const wineRowsByCategory = new Map<string, typeof wineRows>()
for (const row of wineRows) {
const categoryName = row.category?.name ?? 'Ukategoriseret'
const list = wineRowsByCategory.get(categoryName) ?? []
list.push(row)
wineRowsByCategory.set(categoryName, list)
}
function groupOrderLinesByCategory(lines: OrderLineAdminView[]): [string, OrderLineAdminView[]][] {
const sorted = lines.slice().sort((a, b) => {
const orderA = categoryByWineOfferingId.get(a.wine_offering_id)?.sort_order ?? 0
const orderB = categoryByWineOfferingId.get(b.wine_offering_id)?.sort_order ?? 0
if (orderA !== orderB) return orderA - orderB
return a.wine_name.localeCompare(b.wine_name)
})
const groups = new Map<string, OrderLineAdminView[]>()
for (const line of sorted) {
const categoryName = categoryByWineOfferingId.get(line.wine_offering_id)?.name ?? 'Ukategoriseret'
const list = groups.get(categoryName) ?? []
list.push(line)
groups.set(categoryName, list)
}
return Array.from(groups.entries())
}
return (
<div>
<h1>Ordreoversigt</h1>
{error && <p className="error">{error}</p>}
{orders.length === 0 ? (
<p>Ingen bestillinger endnu for denne runde.</p>
) : (
<div className="accordion">
<div className="accordion-item">
<button className="accordion-header" onClick={() => setSummaryOpen((v) => !v)}>
{round.name} — {totalBottles} flasker — Total: {formatEur(totalAmount)} — Udestående:{' '}
{formatEur(outstanding)}
</button>
{summaryOpen && (
<div className="accordion-body">
{Array.from(wineRowsByCategory.entries()).map(([categoryName, rows]) => (
<table key={categoryName}>
<thead>
<tr>
<th colSpan={2}>{categoryName}</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.name}>
<td>{row.quantity}</td>
<td>{row.name}</td>
</tr>
))}
</tbody>
</table>
))}
</div>
)}
</div>
{orders.map((order) => (
<div className="accordion-item" key={order.id}>
<button
className={`accordion-header ${order.payment_status === 'paid' ? 'paid' : 'unpaid'}`}
onClick={() => toggleOrder(order.id)}
>
#{order.order_number} — {order.participant_name} — {order.participant_phone ?? ''} —{' '}
{order.participant_email} — {formatEur(Number(order.total_price_eur))} —{' '}
{order.payment_status === 'paid' ? 'Betalt' : 'Ubetalt'}
</button>
{openOrderIds.has(order.id) && (
<div className="accordion-body">
{order.payment_status === 'paid' ? (
<p className="hint">
Betalt {order.paid_at ? new Date(order.paid_at).toLocaleString('da-DK') : ''}
</p>
) : (
<button onClick={() => handleMarkPaid(order)}>Markér betalt</button>
)}
{groupOrderLinesByCategory(order.order_lines).map(([categoryName, lines]) => (
<table key={categoryName}>
<thead>
<tr>
<th colSpan={3}>{categoryName}</th>
</tr>
</thead>
<tbody>
{lines.map((line) => (
<tr key={line.id}>
<td>{line.quantity}</td>
<td>{line.wine_name}</td>
<td>{formatEur(Number(line.unit_price))}</td>
</tr>
))}
</tbody>
</table>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
)
}

View file

@ -0,0 +1,212 @@
import { useEffect, useState, type FormEvent } from 'react'
import { apiFetch, ApiError } from '../api'
import { useAuth } from '../AuthContext'
import { useRoute } from '../RouteContext'
interface Participant {
id: number
name: string
email: string
phone: string | null
is_active: boolean
route_id: number
}
interface NewParticipantForm {
name: string
email: string
phone: string
}
const emptyForm: NewParticipantForm = { name: '', email: '', phone: '' }
export function ParticipantsPage() {
const { currentUser, isElevated, elevatedToken } = useAuth()
const { selectedRouteId: routeId, loading: routeLoading, error: routeError } = useRoute()
const [participants, setParticipants] = useState<Participant[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [newParticipant, setNewParticipant] = useState<NewParticipantForm>(emptyForm)
const [editingId, setEditingId] = useState<number | null>(null)
const [editForm, setEditForm] = useState<NewParticipantForm & { is_active: boolean }>({
...emptyForm,
is_active: true,
})
async function loadParticipants(forRouteId: number) {
setLoading(true)
setError(null)
try {
// limit=500 er API'ets maksimum (le=500) — komfortabelt over de 308 rigtige
// deltagere i dag. Skal siden bygges rigtig pagination hvis antallet vokser videre.
const data = await apiFetch<Participant[]>(`/participants?route_id=${forRouteId}&limit=500`)
setParticipants(data)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Kunne ikke hente deltagere')
} finally {
setLoading(false)
}
}
useEffect(() => {
if (routeId === null) return
loadParticipants(routeId)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [routeId])
async function handleCreate(event: FormEvent) {
event.preventDefault()
if (routeId === null) return
setError(null)
try {
await apiFetch('/participants', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...newParticipant, route_id: routeId }),
})
setNewParticipant(emptyForm)
await loadParticipants(routeId)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Kunne ikke oprette deltager')
}
}
function startEdit(participant: Participant) {
setEditingId(participant.id)
setEditForm({
name: participant.name,
email: participant.email,
phone: participant.phone ?? '',
is_active: participant.is_active,
})
}
async function handleSaveEdit(event: FormEvent) {
event.preventDefault()
if (editingId === null || routeId === null) return
setError(null)
try {
await apiFetch(`/participants/${editingId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editForm),
})
setEditingId(null)
await loadParticipants(routeId)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Kunne ikke gemme ændringer')
}
}
async function handleHardDelete(participant: Participant) {
if (routeId === null || !elevatedToken) return
if (!window.confirm(`Slet ${participant.name} permanent? Dette kan ikke fortrydes.`)) return
setError(null)
try {
await apiFetch(`/participants/${participant.id}`, { method: 'DELETE' }, elevatedToken)
await loadParticipants(routeId)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Kunne ikke slette deltager')
}
}
if (routeLoading || (loading && routeId !== null)) return <p>Indlæser...</p>
if (routeId === null) return <p className="error">{routeError ?? 'Ingen ruter fundet for din organisation'}</p>
return (
<div>
<h1>Deltagere</h1>
{error && <p className="error">{error}</p>}
{currentUser?.is_superadmin && !isElevated && (
<p className="hint">Forhøj dine rettigheder (knap øverst) for at kunne slette en deltager permanent.</p>
)}
<form onSubmit={handleCreate} className="inline-form">
<input
placeholder="Navn"
value={newParticipant.name}
onChange={(e) => setNewParticipant({ ...newParticipant, name: e.target.value })}
required
/>
<input
type="email"
placeholder="Email"
value={newParticipant.email}
onChange={(e) => setNewParticipant({ ...newParticipant, email: e.target.value })}
required
/>
<input
placeholder="Telefon"
value={newParticipant.phone}
onChange={(e) => setNewParticipant({ ...newParticipant, phone: e.target.value })}
required
/>
<button type="submit">Ny deltager</button>
</form>
<table>
<thead>
<tr>
<th>Navn</th>
<th>Email</th>
<th>Telefon</th>
<th>Aktiv</th>
<th>Handlinger</th>
</tr>
</thead>
<tbody>
{participants.map((participant) =>
editingId === participant.id ? (
<tr key={participant.id}>
<td colSpan={5}>
<form onSubmit={handleSaveEdit} className="inline-form">
<input
value={editForm.name}
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
required
/>
<input
type="email"
value={editForm.email}
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })}
required
/>
<input
value={editForm.phone}
onChange={(e) => setEditForm({ ...editForm, phone: e.target.value })}
/>
<label>
<input
type="checkbox"
checked={editForm.is_active}
onChange={(e) => setEditForm({ ...editForm, is_active: e.target.checked })}
/>
Aktiv
</label>
<button type="submit">Gem</button>
<button type="button" onClick={() => setEditingId(null)}>
Fortryd
</button>
</form>
</td>
</tr>
) : (
<tr key={participant.id}>
<td>{participant.name}</td>
<td>{participant.email}</td>
<td>{participant.phone}</td>
<td>{participant.is_active ? 'Ja' : 'Nej'}</td>
<td>
<button onClick={() => startEdit(participant)}>Redigér</button>
{currentUser?.is_superadmin && isElevated && (
<button onClick={() => handleHardDelete(participant)}>Slet permanent</button>
)}
</td>
</tr>
),
)}
</tbody>
</table>
</div>
)
}

View file

@ -0,0 +1,459 @@
import { useEffect, useState, type FormEvent } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { apiFetch, ApiError } from '../api'
import { useAuth } from '../AuthContext'
import { fromDateValue, fromDatetimeLocalValue, toDateValue, toDatetimeLocalValue } from '../dateUtils'
interface PurchaseRound {
id: number
name: string
status: 'draft' | 'open' | 'closed'
opens_at: string | null
order_deadline_at: string | null
pickup_at: string | null
eur_dkk_rate: string | null
intro_text: string | null
pickup_info_text: string | null
route_id: number
}
interface WineCategory {
id: number
name: string
sort_order: number
}
interface WineOffering {
id: number
name: string
price: string
is_organic: boolean
category_id: number
purchase_round_id: number
}
interface RoundFormState {
name: string
status: PurchaseRound['status']
opens_at: string
order_deadline_at: string
pickup_at: string
eur_dkk_rate: string
intro_text: string
pickup_info_text: string
}
function toFormState(round: PurchaseRound): RoundFormState {
return {
name: round.name,
status: round.status,
opens_at: toDateValue(round.opens_at),
order_deadline_at: toDateValue(round.order_deadline_at),
pickup_at: toDatetimeLocalValue(round.pickup_at),
eur_dkk_rate: round.eur_dkk_rate ?? '',
intro_text: round.intro_text ?? '',
pickup_info_text: round.pickup_info_text ?? '',
}
}
interface NewWineForm {
name: string
price: string
is_organic: boolean
category_id: string
}
const emptyWineForm: NewWineForm = { name: '', price: '', is_organic: false, category_id: '' }
interface AnnounceResult {
sent: number
failed: number
total_participants: number
}
export function RoundDetailPage() {
const { roundId } = useParams<{ roundId: string }>()
const navigate = useNavigate()
const { currentUser, isElevated, elevatedToken } = useAuth()
const [round, setRound] = useState<PurchaseRound | null>(null)
const [form, setForm] = useState<RoundFormState | null>(null)
const [categories, setCategories] = useState<WineCategory[]>([])
const [wines, setWines] = useState<WineOffering[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [announceResult, setAnnounceResult] = useState<AnnounceResult | null>(null)
const [newWine, setNewWine] = useState<NewWineForm>(emptyWineForm)
const [editingWineId, setEditingWineId] = useState<number | null>(null)
const [editWineForm, setEditWineForm] = useState<NewWineForm>(emptyWineForm)
async function loadWines(forRoundId: string) {
const data = await apiFetch<WineOffering[]>(`/wine-offerings?purchase_round_id=${forRoundId}&limit=500`)
setWines(data)
}
async function loadAll() {
if (!roundId) return
setLoading(true)
setError(null)
try {
const [roundData, categoryData] = await Promise.all([
apiFetch<PurchaseRound>(`/purchase-rounds/${roundId}`),
apiFetch<WineCategory[]>('/wine-categories'),
])
setRound(roundData)
setForm(toFormState(roundData))
setCategories(categoryData)
await loadWines(roundId)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Kunne ikke hente runden')
} finally {
setLoading(false)
}
}
useEffect(() => {
loadAll()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [roundId])
async function handleSave(event: FormEvent) {
event.preventDefault()
if (!roundId || !form) return
setError(null)
try {
const updated = await apiFetch<PurchaseRound>(`/purchase-rounds/${roundId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: form.name,
status: form.status,
opens_at: fromDateValue(form.opens_at),
order_deadline_at: fromDateValue(form.order_deadline_at),
pickup_at: fromDatetimeLocalValue(form.pickup_at),
eur_dkk_rate: form.eur_dkk_rate ? form.eur_dkk_rate : null,
intro_text: form.intro_text || null,
pickup_info_text: form.pickup_info_text || null,
}),
})
setRound(updated)
setForm(toFormState(updated))
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Kunne ikke gemme runden')
}
}
async function handleDelete() {
if (!roundId || !round) return
if (!window.confirm(`Slet "${round.name}"?`)) return
setError(null)
try {
const token = round.status === 'closed' ? elevatedToken : undefined
await apiFetch(`/purchase-rounds/${roundId}`, { method: 'DELETE' }, token)
navigate('/runder')
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Kunne ikke slette runden')
}
}
async function handleCopy() {
if (!roundId) return
setError(null)
try {
const copy = await apiFetch<PurchaseRound>(`/purchase-rounds/${roundId}/copy`, { method: 'POST' })
navigate(`/runder/${copy.id}`)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Kunne ikke kopiere runden')
}
}
async function handleAnnounce() {
if (!roundId) return
if (
!window.confirm(
'Send "runde åbnet"-mail til alle aktive deltagere på ruten? Dette kan ikke fortrydes, og sender igen til alle hvis du klikker mere end én gang.',
)
)
return
setError(null)
setAnnounceResult(null)
try {
const result = await apiFetch<AnnounceResult>(`/purchase-rounds/${roundId}/announce`, { method: 'POST' })
setAnnounceResult(result)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Kunne ikke annoncere runden')
}
}
async function handleCreateWine(event: FormEvent) {
event.preventDefault()
if (!roundId) return
setError(null)
try {
await apiFetch('/wine-offerings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: newWine.name,
price: newWine.price,
is_organic: newWine.is_organic,
category_id: Number(newWine.category_id),
purchase_round_id: Number(roundId),
}),
})
setNewWine(emptyWineForm)
await loadWines(roundId)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Kunne ikke oprette vin')
}
}
function startEditWine(wine: WineOffering) {
setEditingWineId(wine.id)
setEditWineForm({
name: wine.name,
price: wine.price,
is_organic: wine.is_organic,
category_id: String(wine.category_id),
})
}
async function handleSaveWine(event: FormEvent) {
event.preventDefault()
if (!roundId || editingWineId === null) return
setError(null)
try {
await apiFetch(`/wine-offerings/${editingWineId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: editWineForm.name,
price: editWineForm.price,
is_organic: editWineForm.is_organic,
category_id: Number(editWineForm.category_id),
}),
})
setEditingWineId(null)
await loadWines(roundId)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Kunne ikke gemme vin')
}
}
async function handleDeleteWine(wine: WineOffering) {
if (!roundId) return
if (!window.confirm(`Slet "${wine.name}"?`)) return
setError(null)
try {
await apiFetch(`/wine-offerings/${wine.id}`, { method: 'DELETE' })
await loadWines(roundId)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Kunne ikke slette vin')
}
}
if (loading) return <p>Indlæser...</p>
if (!round || !form) return <p className="error">{error ?? 'Runden blev ikke fundet'}</p>
const categoryName = (categoryId: number) => categories.find((c) => c.id === categoryId)?.name ?? '—'
return (
<div>
<h1>{round.name}</h1>
{error && <p className="error">{error}</p>}
<form onSubmit={handleSave} className="stacked-form">
<label>
Navn
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
</label>
<label>
Status
<select
value={form.status}
onChange={(e) => setForm({ ...form, status: e.target.value as PurchaseRound['status'] })}
>
<option value="draft">Kladde</option>
<option value="open">Åben</option>
<option value="closed">Lukket</option>
</select>
</label>
<label>
Åbner
<input type="date" value={form.opens_at} onChange={(e) => setForm({ ...form, opens_at: e.target.value })} />
</label>
<label>
Bestillingsfrist
<input
type="date"
value={form.order_deadline_at}
onChange={(e) => setForm({ ...form, order_deadline_at: e.target.value })}
/>
</label>
<label>
Afhentning
<input
type="datetime-local"
value={form.pickup_at}
onChange={(e) => setForm({ ...form, pickup_at: e.target.value })}
/>
</label>
<label>
EUR/DKK-kurs
<input
type="number"
step="0.0001"
value={form.eur_dkk_rate}
onChange={(e) => setForm({ ...form, eur_dkk_rate: e.target.value })}
/>
</label>
<label>
Introtekst
<textarea value={form.intro_text} onChange={(e) => setForm({ ...form, intro_text: e.target.value })} />
</label>
<label>
Afhentningsinfo
<textarea
value={form.pickup_info_text}
onChange={(e) => setForm({ ...form, pickup_info_text: e.target.value })}
/>
</label>
<button type="submit">Gem</button>
<button type="button" onClick={() => navigate(`/runder/${roundId}/ordrer`)}>
Se ordrer
</button>
<button type="button" onClick={handleCopy}>
Kopiér denne runde
</button>
{round.status === 'open' && (
<button type="button" onClick={handleAnnounce}>
Annoncér runde
</button>
)}
{(round.status === 'draft' || (round.status === 'closed' && currentUser?.is_superadmin && isElevated)) && (
<button type="button" onClick={handleDelete}>
Slet
</button>
)}
{round.status === 'closed' && currentUser?.is_superadmin && !isElevated && (
<p className="hint">Forhøj dine rettigheder (knap øverst) for at kunne slette en lukket runde.</p>
)}
</form>
{announceResult && (
<p className="hint">
Sendt: {announceResult.sent}, fejlet: {announceResult.failed}, deltagere i alt:{' '}
{announceResult.total_participants}
</p>
)}
<h2>Vinliste</h2>
<form onSubmit={handleCreateWine} className="inline-form">
<input
placeholder="Navn"
value={newWine.name}
onChange={(e) => setNewWine({ ...newWine, name: e.target.value })}
required
/>
<input
type="number"
step="0.01"
placeholder="Pris"
value={newWine.price}
onChange={(e) => setNewWine({ ...newWine, price: e.target.value })}
required
/>
<label>
<input
type="checkbox"
checked={newWine.is_organic}
onChange={(e) => setNewWine({ ...newWine, is_organic: e.target.checked })}
/>
Økologisk
</label>
<select
value={newWine.category_id}
onChange={(e) => setNewWine({ ...newWine, category_id: e.target.value })}
required
>
<option value="" disabled>
Vælg kategori
</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<button type="submit">Ny vin</button>
</form>
<table>
<thead>
<tr>
<th>Navn</th>
<th>Pris</th>
<th>Økologisk</th>
<th>Kategori</th>
<th>Handlinger</th>
</tr>
</thead>
<tbody>
{wines.map((wine) =>
editingWineId === wine.id ? (
<tr key={wine.id}>
<td colSpan={5}>
<form onSubmit={handleSaveWine} className="inline-form">
<input
value={editWineForm.name}
onChange={(e) => setEditWineForm({ ...editWineForm, name: e.target.value })}
required
/>
<input
type="number"
step="0.01"
value={editWineForm.price}
onChange={(e) => setEditWineForm({ ...editWineForm, price: e.target.value })}
required
/>
<label>
<input
type="checkbox"
checked={editWineForm.is_organic}
onChange={(e) => setEditWineForm({ ...editWineForm, is_organic: e.target.checked })}
/>
Økologisk
</label>
<select
value={editWineForm.category_id}
onChange={(e) => setEditWineForm({ ...editWineForm, category_id: e.target.value })}
>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<button type="submit">Gem</button>
<button type="button" onClick={() => setEditingWineId(null)}>
Fortryd
</button>
</form>
</td>
</tr>
) : (
<tr key={wine.id}>
<td>{wine.name}</td>
<td>{wine.price}</td>
<td>{wine.is_organic ? 'Ja' : 'Nej'}</td>
<td>{categoryName(wine.category_id)}</td>
<td>
<button onClick={() => startEditWine(wine)}>Redigér</button>
<button onClick={() => handleDeleteWine(wine)}>Slet</button>
</td>
</tr>
),
)}
</tbody>
</table>
</div>
)
}

View file

@ -0,0 +1,145 @@
import { useEffect, useState, type FormEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { apiFetch, ApiError } from '../api'
import { useAuth } from '../AuthContext'
import { useRoute } from '../RouteContext'
interface PurchaseRound {
id: number
name: string
status: 'draft' | 'open' | 'closed'
opens_at: string | null
order_deadline_at: string | null
pickup_at: string | null
route_id: number
}
const statusLabels: Record<PurchaseRound['status'], string> = {
draft: 'Kladde',
open: 'Åben',
closed: 'Lukket',
}
function formatDate(iso: string | null): string {
return iso ? new Date(iso).toLocaleDateString('da-DK') : ''
}
function formatDateTime(iso: string | null): string {
return iso ? new Date(iso).toLocaleString('da-DK') : ''
}
export function RoundsPage() {
const navigate = useNavigate()
const { currentUser, isElevated, elevatedToken } = useAuth()
const { selectedRouteId: routeId, loading: routeLoading, error: routeError } = useRoute()
const [rounds, setRounds] = useState<PurchaseRound[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [newName, setNewName] = useState('')
async function loadRounds(forRouteId: number) {
setLoading(true)
setError(null)
try {
const data = await apiFetch<PurchaseRound[]>(`/purchase-rounds?route_id=${forRouteId}&limit=500`)
setRounds(data)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Kunne ikke hente runder')
} finally {
setLoading(false)
}
}
useEffect(() => {
if (routeId === null) return
loadRounds(routeId)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [routeId])
async function handleCreate(event: FormEvent) {
event.preventDefault()
if (routeId === null) return
setError(null)
try {
const created = await apiFetch<PurchaseRound>('/purchase-rounds', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName, route_id: routeId }),
})
navigate(`/runder/${created.id}`)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Kunne ikke oprette runde')
}
}
async function handleCopy(round: PurchaseRound) {
setError(null)
try {
const copy = await apiFetch<PurchaseRound>(`/purchase-rounds/${round.id}/copy`, { method: 'POST' })
navigate(`/runder/${copy.id}`)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Kunne ikke kopiere runden')
}
}
async function handleDelete(round: PurchaseRound) {
if (routeId === null) return
if (!window.confirm(`Slet "${round.name}"?`)) return
setError(null)
try {
const token = round.status === 'closed' ? elevatedToken : undefined
await apiFetch(`/purchase-rounds/${round.id}`, { method: 'DELETE' }, token)
await loadRounds(routeId)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Kunne ikke slette runden')
}
}
if (routeLoading || (loading && routeId !== null)) return <p>Indlæser...</p>
if (routeId === null) return <p className="error">{routeError ?? 'Ingen ruter fundet for din organisation'}</p>
return (
<div>
<h1>Runder</h1>
{error && <p className="error">{error}</p>}
<form onSubmit={handleCreate} className="inline-form">
<input placeholder="Navn på ny runde" value={newName} onChange={(e) => setNewName(e.target.value)} required />
<button type="submit">Ny runde</button>
</form>
<table>
<thead>
<tr>
<th>Navn</th>
<th>Status</th>
<th>Åbner</th>
<th>Bestillingsfrist</th>
<th>Afhentning</th>
<th>Handlinger</th>
</tr>
</thead>
<tbody>
{rounds.map((round) => (
<tr key={round.id}>
<td>{round.name}</td>
<td>{statusLabels[round.status]}</td>
<td>{formatDate(round.opens_at)}</td>
<td>{formatDate(round.order_deadline_at)}</td>
<td>{formatDateTime(round.pickup_at)}</td>
<td>
<button onClick={() => navigate(`/runder/${round.id}`)}>Redigér</button>
<button onClick={() => navigate(`/runder/${round.id}/ordrer`)}>Se ordrer</button>
<button onClick={() => handleCopy(round)}>Kopiér</button>
{(round.status === 'draft' ||
(round.status === 'closed' && currentUser?.is_superadmin && isElevated)) && (
<button onClick={() => handleDelete(round)}>Slet</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}

View file

@ -0,0 +1,174 @@
import { useEffect, useState, type FormEvent } from 'react'
import { apiFetch, ApiError } from '../api'
import { useRoute } from '../RouteContext'
type EventType = 'round_announced' | 'order_confirmed' | 'payment_confirmed'
interface MailTemplate {
id: number
event_type: EventType
subject: string
body_html: string
route_id: number
}
const ORDER_MAIL_VARIABLES = [
'participant_name',
'route_name',
'round_name',
'order_number',
'order_deadline_at',
'pickup_at',
'pickup_info_text',
'order_lines_html',
'order_total_eur',
'order_total_dkk',
]
const EVENT_TYPES: { value: EventType; label: string; variables: string[] }[] = [
{
value: 'round_announced',
label: 'Runde åbnet',
variables: [
'participant_name',
'route_name',
'round_name',
'opens_at',
'order_deadline_at',
'pickup_at',
'intro_text',
'pickup_info_text',
],
},
{ value: 'order_confirmed', label: 'Ordre bekræftet', variables: ORDER_MAIL_VARIABLES },
{ value: 'payment_confirmed', label: 'Betaling bekræftet', variables: ORDER_MAIL_VARIABLES },
]
function TemplateSection({
eventType,
label,
variables,
template,
routeId,
onChange,
}: {
eventType: EventType
label: string
variables: string[]
template: MailTemplate | undefined
routeId: number
onChange: () => Promise<void>
}) {
const [subject, setSubject] = useState(template?.subject ?? '')
const [bodyHtml, setBodyHtml] = useState(template?.body_html ?? '')
const [error, setError] = useState<string | null>(null)
useEffect(() => {
setSubject(template?.subject ?? '')
setBodyHtml(template?.body_html ?? '')
}, [template])
async function handleSave(event: FormEvent) {
event.preventDefault()
setError(null)
try {
if (template) {
await apiFetch(`/mail-templates/${template.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subject, body_html: bodyHtml }),
})
} else {
await apiFetch('/mail-templates', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ event_type: eventType, subject, body_html: bodyHtml, route_id: routeId }),
})
}
await onChange()
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Kunne ikke gemme skabelonen')
}
}
async function handleDelete() {
if (!template) return
if (!window.confirm(`Slet skabelonen for "${label}"?`)) return
setError(null)
try {
await apiFetch(`/mail-templates/${template.id}`, { method: 'DELETE' })
await onChange()
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Kunne ikke slette skabelonen')
}
}
return (
<section>
<h2>{label}</h2>
<p className="hint">Tilgængelige variabler: {variables.map((v) => `{{${v}}}`).join(', ')}</p>
<form onSubmit={handleSave} className="stacked-form">
<label>
Emne
<input value={subject} onChange={(e) => setSubject(e.target.value)} required />
</label>
<label>
Indhold (HTML)
<textarea value={bodyHtml} onChange={(e) => setBodyHtml(e.target.value)} rows={8} required />
</label>
<button type="submit">{template ? 'Gem' : 'Opret'}</button>
{template && (
<button type="button" onClick={handleDelete}>
Slet
</button>
)}
</form>
{error && <p className="error">{error}</p>}
</section>
)
}
export function TemplatesPage() {
const { selectedRouteId: routeId, loading: routeLoading, error: routeError } = useRoute()
const [templates, setTemplates] = useState<MailTemplate[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
async function loadTemplates(forRouteId: number) {
const data = await apiFetch<MailTemplate[]>(`/mail-templates?route_id=${forRouteId}`)
setTemplates(data)
}
useEffect(() => {
if (routeId === null) return
setLoading(true)
setError(null)
loadTemplates(routeId)
.catch((err) => {
setError(err instanceof ApiError ? err.message : 'Kunne ikke hente skabeloner')
})
.finally(() => setLoading(false))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [routeId])
if (routeLoading || (loading && routeId !== null)) return <p>Indlæser...</p>
if (routeId === null) return <p className="error">{routeError ?? 'Ingen ruter fundet for din organisation'}</p>
return (
<div>
<h1>Mail-skabeloner</h1>
{error && <p className="error">{error}</p>}
{EVENT_TYPES.map(({ value, label, variables }) => (
<TemplateSection
key={value}
eventType={value}
label={label}
variables={variables}
template={templates.find((t) => t.event_type === value)}
routeId={routeId}
onChange={() => loadTemplates(routeId)}
/>
))}
</div>
)
}

View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
admin-ui/tsconfig.json Normal file
View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View file

@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

27
admin-ui/vite.config.ts Normal file
View file

@ -0,0 +1,27 @@
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
const API_PREFIXES = [
'/auth',
'/participants',
'/purchase-rounds',
'/wine-offerings',
'/wine-categories',
'/mail-templates',
'/mail-logs',
'/orders',
]
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
build: {
outDir: '../app/admin_dist',
emptyOutDir: true,
},
server: {
proxy: Object.fromEntries(
API_PREFIXES.map((prefix) => [prefix, { target: 'http://127.0.0.1:8001', changeOrigin: true }]),
),
},
})

66
app/admin_site.py Normal file
View file

@ -0,0 +1,66 @@
from pathlib import Path
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.responses import Response
from starlette.staticfiles import StaticFiles
from starlette.types import ASGIApp, Receive, Scope, Send
from app.core.config import get_settings
ADMIN_DIST_DIR = Path(__file__).parent / "admin_dist"
# Præfikser der altid rammer det almindelige API, uanset domæne — skal
# holdes i sync med routerne registreret i app/main.py
_API_PREFIXES = (
"/auth",
"/participants",
"/purchase-rounds",
"/wine-offerings",
"/wine-categories",
"/mail-templates",
"/mail-logs",
"/orders",
"/routes",
"/webhooks",
"/public",
"/health",
"/docs",
"/redoc",
"/openapi.json",
)
class SPAStaticFiles(StaticFiles):
"""Standard SPA-fallback: ukendte stier (client-side routing)
falder tilbage til index.html i stedet for 404."""
async def get_response(self, path: str, scope: Scope) -> Response:
try:
return await super().get_response(path, scope)
except StarletteHTTPException as exc:
if exc.status_code == 404:
return await super().get_response("index.html", scope)
raise
class AdminDomainDispatch:
"""Sender requests mod admin-domænet til den byggede React-SPA i
stedet for den domæne-opløste offentlige side (public_site.py) —
men lader API-kald (/auth, /participants, ...) passere uændret,
så admin-UI'et kan kalde API'et uden CORS (samme origin)."""
def __init__(self, app: ASGIApp) -> None:
self.app = app
self.admin_domain = get_settings().admin_domain
self.spa_app = SPAStaticFiles(directory=str(ADMIN_DIST_DIR), html=True) if ADMIN_DIST_DIR.is_dir() else None
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http" or self.spa_app is None:
await self.app(scope, receive, send)
return
headers = dict(scope["headers"])
host = headers.get(b"host", b"").decode().split(":")[0]
if host == self.admin_domain and not scope["path"].startswith(_API_PREFIXES):
await self.spa_app(scope, receive, send)
return
await self.app(scope, receive, send)

View file

@ -12,6 +12,7 @@ class Settings(BaseSettings):
postal_base_url: str
postal_api_key: str
postal_webhook_public_key_b64: str
admin_domain: str = "admin.localhost"
model_config = SettingsConfigDict(
env_file=".env",

View file

@ -2,6 +2,7 @@ from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from sqlalchemy import text
from app.admin_site import AdminDomainDispatch
from app.db import SessionDep
from app.routers import (
auth,
@ -12,6 +13,7 @@ from app.routers import (
public_orders,
public_site,
purchase_rounds,
routes,
webhooks,
wine_categories,
wine_offerings,
@ -27,9 +29,11 @@ app.include_router(wine_categories.router)
app.include_router(mail_templates.router)
app.include_router(mail_logs.router)
app.include_router(orders.router)
app.include_router(routes.router)
app.include_router(webhooks.router)
app.include_router(public_orders.router)
app.include_router(public_site.router)
app.add_middleware(AdminDomainDispatch)
@app.get("/health")

View file

@ -35,3 +35,7 @@ class Route(RouteBase, table=True):
mail_templates: List["MailTemplate"] = Relationship(
back_populates="route", sa_relationship_kwargs={"cascade": "all, delete-orphan"}
)
class RoutePublic(RouteBase):
id: int

13
app/routers/routes.py Normal file
View file

@ -0,0 +1,13 @@
from fastapi import APIRouter
from sqlmodel import select
from app.db import SessionDep
from app.dependencies import CurrentUser
from app.models.route import Route, RoutePublic
router = APIRouter(prefix="/routes", tags=["routes"])
@router.get("", response_model=list[RoutePublic])
def list_routes(session: SessionDep, current_user: CurrentUser) -> list[Route]:
return list(session.exec(select(Route).where(Route.organization_id == current_user.organization_id)).all())

View file

@ -0,0 +1,83 @@
"""One-off bootstrap: opretter den første organisation + rute + superadmin-bruger.
Der findes ingen selvbetjenings-registrering i systemet — uden en
bruger i databasen kan ingen logge ind overhovedet. Dette script
dækker det på en frisk installation.
Genbruger en organisation/rute med samme navn, hvis de allerede
findes (sikkert at køre flere gange, fx for at oprette endnu en
admin-bruger på en eksisterende organisation). Fejler tydeligt hvis
en bruger med samme email allerede findes, i stedet for at duplikere.
Brug:
uv run python scripts/create_admin_user.py \
--org "Fælles Vinindkøb" --route "Fælles Vinindkøb" \
--email admin@example.com --name "Dit Navn" --password "..."
"""
import argparse
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from sqlmodel import Session, select
from app.core.security import hash_password
from app.db import engine
from app.models.organization import Organization
from app.models.route import Route
from app.models.user import User
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--org", required=True, help="Organisationens navn (genbruges hvis den allerede findes)")
parser.add_argument("--route", required=True, help="Rutens navn (genbruges hvis den allerede findes)")
parser.add_argument("--email", required=True, help="Email til den nye admin-bruger")
parser.add_argument("--name", required=True, help="Navn på den nye admin-bruger")
parser.add_argument("--password", required=True, help="Adgangskode til den nye admin-bruger")
args = parser.parse_args()
with Session(engine) as session:
existing_user = session.exec(select(User).where(User.email == args.email)).first()
if existing_user is not None:
print(f"Fejl: en bruger med email {args.email!r} findes allerede (id={existing_user.id}).")
sys.exit(1)
organization = session.exec(select(Organization).where(Organization.name == args.org)).first()
if organization is None:
organization = Organization(name=args.org)
session.add(organization)
session.flush()
print(f"Oprettede organisation {args.org!r} (id={organization.id}).")
else:
print(f"Genbruger eksisterende organisation {args.org!r} (id={organization.id}).")
route = session.exec(
select(Route).where(Route.name == args.route, Route.organization_id == organization.id)
).first()
if route is None:
route = Route(name=args.route, organization_id=organization.id)
session.add(route)
session.flush()
print(f"Oprettede rute {args.route!r} (id={route.id}).")
else:
print(f"Genbruger eksisterende rute {args.route!r} (id={route.id}).")
user = User(
email=args.email,
name=args.name,
hashed_password=hash_password(args.password),
is_active=True,
is_superadmin=True,
organization_id=organization.id,
)
session.add(user)
session.commit()
session.refresh(user)
print(f"Oprettede superadmin-bruger {args.email!r} (id={user.id}).")
if __name__ == "__main__":
main()