Commit graph

30 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
b6b14aefac Opgave 10: fortløbende ordrenummer pr. runde + afhentningsliste
Order.order_number tildeles atomisk i POST /public/orders via en
SELECT ... FOR UPDATE-lås på rundens række + MAX+1, så samtidige
bestillinger lige efter en runde åbner ikke kan kollidere. Nummeret
starter på 1 for hver runde (UNIQUE(purchase_round_id, order_number))
og er nu tilgængeligt som {{order_number}} i mail-skabeloner.

GET /purchase-rounds/{id}/pickup-list genskaber den gamle systems
afhentningsliste (delt af brugeren som reference) mod den nye
datamodel: én printside pr. ordre, sorteret efter order_number (vist
stort i øverste højre hjørne), kontaktinfo, flaske-/kasseantal
(pænt afrundet — originalen viste et urundet float), og
vinlinjer grupperet pr. kategori.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 00:21:45 +02:00
3592fbf845 Opgave 9: admin-ordreoversigt + "markér betalt"
GET /orders (filtre: purchase_round_id, route_id, payment_status)
giver admin en oversigt pr. ordre med deltagerinfo, vinlinjer og
beregnet total. POST /orders/{id}/mark-paid registrerer betalingen
(payment_status=paid, paid_at) og udløser mail-event #3
(payment_confirmed) — afviser med 409 hvis ordren allerede er betalt.

Udtrukket render/send/log-logikken fra 8a's _send_order_confirmation
til en delt app/services/order_mail.py::send_order_event_mail,
parameteriseret over MailEventType, så order_confirmed og
payment_confirmed ikke længere duplikerer den samme kode. Som med
order_confirmed er mail-afsendelsen best-effort: en manglende
skabelon eller afsenderkonfiguration blokerer aldrig selve
betalingsregistreringen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 00:03:45 +02:00
18f1c6cbac Opdatér CLAUDE.md: 8c er nu browser-testet og justeret
Dokumenterer bekræftelsessiden (/bestilling-modtaget) og de to fund
fra brugerens browser-gennemgang, samt fjerner den nu forældede note
om at JS-laget ikke var browser-verificeret.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 23:52:20 +02:00
cae8decd21 Opgave 8c: bekræftelsesside efter bestilling + responsivt layout-fix
Baseret på brugerens gennemgang af siden i browser:
- Ved succesfuld bestilling omdirigeres brugeren nu til en dedikeret
  GET /bestilling-modtaget i stedet for en inline besked, der skjulte
  vinkataloget — brugeren vil fortsat kunne se hvad de har bestilt.
  Siden gør det eksplicit at bestillingen først er gyldig ved
  modtaget betaling, og linker tilbage til bestillingssiden.
- Rettet et CSS-layoutbug på smalle skærme: flex-basis (sat til
  600px/260px til brug for bredde i to-kolonne-layoutet) blev
  fejlagtigt fortolket som højde, når layoutet skiftede til
  flex-direction: column under 800px, hvilket gav et stort tomt
  mellemrum mellem vinkatalog og sidebar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 23:51:41 +02:00
609c3efb05 Opgave 8c: ny offentlig bestillings-/tilmeldingsside
Server-renderet af FastAPI selv (Jinja2 + vanilla JS/CSS, intet
build-step) mod det eksisterende domæneopløste JSON-API fra 8b.
GET / viser bestillingssiden når en runde er åben (rundeoverskrift,
intro_text, kategori-grupperet vinkatalog med live-beregnet
EUR/DKK-total, bestillingsformular), ellers en tilmeldingsside.
GET /tilmelding er altid tilgængelig uafhængigt af rundestatus, og
GET /afhentning viser rutens meeting_info. Layoutet er modelleret
efter den gamle sides bestillingsside. get_current_round's
serialiseringslogik er udtrukket til en delt _build_page_info-helper,
så JSON-API'et og de nye HTML-sider genbruger nøjagtig samme
domæne-/rundeopslag.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 22:55:55 +02:00
0653227345 Tilføj stående ordre om plan mode til proces-noten i CLAUDE.md
Efter en afsluttet opgave skal der fremover gås tilbage i plan mode
automatisk, i stedet for at fortsætte til næste opgave uden at spørge.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 22:37:22 +02:00
7d767ec7c3 Opgave 8b: domænebaseret rute-opløsning + tilmeldings-endpoint
Offentlige endpoints afgør nu ruten via Host-headeren
(Route.public_domain) i stedet for route_id i URL'en, så én
frontend kan betjene flere organisationers domæner via reverse
proxy. GET /public/current-round returnerer altid 200 med
current_round: null når ingen runde er åben (i stedet for 404),
og nyt POST /public/signup lader deltagere tilmelde sig med kun
navn+email uden at røre et eksisterende telefonnummer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 22:34:27 +02:00
5db65fb268 Update CLAUDE.md for task 8a, add standing update-after-each-task note
Order/OrderLine now have a real write path (the public order API), so
their "model exists, no API yet" notes are replaced with what's
actually there. Participant's entry documents the ad-hoc-create/
always-update-on-order behavior. Mail-events #2 marked implemented
with the same detail level as #1. Fase 1 task 8 split into 8a (done)/
8b (pending, own stack decision) matching the 7a/7b/7c precedent.

Added a short process note at the top, per explicit user request:
this file should be updated after each (sub)task, not just on request.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 22:11:55 +02:00
706c6d5828 Add public order API (task 8a) — no login, ad-hoc participant creation
POST /public/routes/{id}/orders and GET .../current-round are the
first fully unauthenticated routes in the app — no CurrentUser or
org-scoping helper applies, route_id comes straight from the URL.

Participant identification matches the old system's no-login design:
an existing participant (case-insensitive email match on the route) is
reused, and — per explicit user correction — its name/phone/is_active
are overwritten from the submission every time, since there's no login
or "edit my details" page; the order form *is* how members keep their
contact info current. is_active is a checkbox on the form itself
(default true), not inferred. A new email always creates a new
participant row — that's the deliberate no-login way to "change
email". An IntegrityError race on concurrent same-email submissions
falls back to re-selecting the winner rather than 500ing.

Order confirmation email reuses 7b's render/send/log pattern for a
single participant, but a missing order_confirmed template (or missing
route sender identity) is a silent no-op + logger.warning, never a
blocking error — an admin configuration gap must never stop a real
order, unlike task 7b's admin-triggered /announce which fails fast on
the same conditions.

Per explicit user correction, the confirmation email deliberately
reproduces the old system's full-catalog receipt (every wine in the
round, grouped by category, ordered quantity filled in where
applicable) — confirmed via old-emails/Kvitering.eml discussion to be
a deliberate mimicry of the physical order sheet used when buying wine
at the producer's cellar, not a legacy-template artifact to simplify
away. Currency totals are computed at full Decimal precision (EUR
summed, then × eur_dkk_rate with no intermediate rounding) and only
rounded to 2dp (ROUND_HALF_UP) at final display formatting, per
correction — avoids compounding an early rounding error into the DKK
figure.

Empty order_lines is rejected with 400 (not the Pydantic-level 422 a
schema constraint would give) — the old empty-order signup/unsubscribe
hack is intentionally not resurrected here; a real signup/unsubscribe
flow is a separate future task per the user's own framing.

Verified end-to-end directly against the real route 4: a temporary
open round + wine offerings + order_confirmed template, a real order
submitted and a real confirmation email sent/logged, a second order
with the same (differently-cased) email confirmed to update the
existing participant in place rather than duplicate it, a
different-round wine_offering_id rejected (400), an empty order
rejected (400 not 422), and the missing-template case confirmed to
still return 201 with no new MailLog row. All temporary data removed
afterward; the real participant (carsten@itkon.dk, id 133) — legitimately
touched by the get-or-create-with-update logic during testing, exactly
as designed — was restored to its original name/phone/is_active. The
other 307 real participants were untouched throughout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 22:08:58 +02:00
53a5138d45 Update CLAUDE.md to match actual implementation (opgave 1-7)
Datamodel section now reflects reality: PurchaseRound's season/year
was replaced with opens_at/order_deadline_at/pickup_at + a free-text
name (per user feedback during task 2); WineOffering's vintage was
dropped (folded into name); added the MailTemplate/MailLog models and
Route's sender_name/sender_email that opgave 7 introduced; noted
Order/OrderLine exist as models but have no API yet.

Fase 1 task list marks 1-7 done and expands 7 into its actual 7a/7b/7c
shape. Added a short auth line (JWT + sudo-style elevation) and
resolved the "Mail: SMTP-udbyder — afklares" placeholder now that
Postal is wired up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 20:54:31 +02:00
4eb8800872 Add Postal webhook receiver for delivery status (task 7c)
POST /webhooks/postal (unauthenticated — RSA signature is the auth)
verifies the X-Postal-Signature-256 header: RSA-SHA256/PKCS1v15 over
the raw request body, using the same keypair as DKIM signing. Verified
directly against Postal's own source (lib/postal/http.rb, signer.rb)
rather than guessed, after the user pointed out the mechanism and that
their instance is new enough to use the -256 (SHA256) header over the
legacy SHA1 one. The public key is stored as the raw base64 DER blob
from the domain's DKIM DNS TXT record (dig TXT
postal-eWHeqb._domainkey.vinindkoeb.dk) — no PEM wrapping needed,
cryptography.load_der_public_key takes it directly.

Events are correlated to MailLog via postal_message_id. MessageSent
(actual delivery confirmation, not to be confused with task 7b's
synchronous "Postal accepted the request") maps to the DELIVERED
status already reserved for it; MessageDeliveryFailed/MessageBounced/
MessageHeld/MessageDelayed map to new terminal/transient statuses.
MessageLoaded/MessageLinkClicked set separate opened_at/clicked_at
timestamps rather than overwriting status, since engagement can happen
after delivery and shouldn't regress it. DomainDNSError and any
unrecognized event are acknowledged (200) and ignored — no message to
correlate.

Discovered along the way: the native_enum=False enum columns are
plain length-capped VARCHARs with no IN-list CHECK constraint, so
adding "held"/"delayed" needed no constraint migration, just the two
new opened_at/clicked_at columns Alembic did autogenerate correctly.

Verified: signature logic in isolation against a self-generated RSA
keypair (valid data/signature accepted, tampered data and garbage
signatures rejected), the real DKIM key parses correctly (1024-bit
RSA), the live endpoint rejects missing/invalid signatures with 401,
and the event-to-MailLog mapping logic was exercised directly (not
over HTTP, since a validly Postal-signed payload can't be forged
without their private key) against an isolated throwaway sandbox —
all cleaned up afterward, real route/participant data confirmed
unaffected throughout.

True end-to-end verification (a real Postal-originated webhook call)
requires the app to be deployed somewhere Postal can reach, plus
configuring the webhook URL in Postal's admin UI — both are deployment
steps outside this coding task.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 20:50:24 +02:00
34ed5c28d7 Add Postal sending, "annoncér runde" action, and MailLog (task 7b)
POST /purchase-rounds/{id}/announce renders the route's round_announced
MailTemplate with simple {{variable}} substitution (app/services/
mail_rendering.py) for every active participant on the route, sends
via Postal's real HTTP API (app/services/postal.py — request/response
shapes verified directly against docs.postalserver.io, not guessed),
and records one MailLog row per attempt (sent/failed, Postal's
message id + token for the future webhook in 7c, denormalized
rendered_subject so the log reflects what was actually sent even if
the template changes later). A failed send for one participant doesn't
abort the rest — each attempt is isolated and committed individually.

Route gained sender_name/sender_email in 7a; both are now populated
for the real route from the historical emails ("Finn Gram - Fælles
Vinindkøb <finn@vinindkoeb.dk>") directly in the DB, since there's no
Route CRUD API yet.

Verified end-to-end against the real Postal instance: a real test
email was sent and received via an isolated throwaway Organization/
Route/Participant/PurchaseRound/MailTemplate sandbox (never the real
route's 308 participants), MailLog captured the correct Postal message
id/token, and a temporarily-invalid API key produced a clean
sent:0/failed:1 result with Postal's actual error message stored,
not a 500. All test data removed afterward; real route/participant
data confirmed untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 20:16:04 +02:00
bf0affcdc6 Add mail template CRUD (task 7a), per-route and per-event-type
MailTemplate holds subject + body_html with plain {{variable}}
placeholders (no loops/conditionals — the user edits these directly
and is comfortable generating list-shaped content like the wine
catalog in code instead, per the real "Kvitering" email reviewed in
old-emails/). One template per (route, event_type), matching the
three CLAUDE.md mail-events (round_announced, order_confirmed,
payment_confirmed); UNIQUE(route_id, event_type) enforces that and
IntegrityError is caught as 409, same pattern as prior tasks.

Route gains nullable sender_name/sender_email — the real old emails
show a per-route sender identity (e.g. "Finn Gram <finn@vinindkoeb.dk>"
for this route), so it lives on Route rather than being duplicated
across each of its templates.

This is data + CRUD only. Actual Postal sending, the round-announce
action, and per-participant send logging are task 7b; a Postal
webhook receiver for delivery status is task 7c — both need real
Postal API access, still to be arranged.

old-emails/ (real historical order/participant data) is gitignored,
same treatment as old_participants.json.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 19:43:47 +02:00
acaa2383e9 Add admin CRUD for purchase rounds and wine offerings, with round copy
Full CRUD on PurchaseRound and WineOffering, org-scoped via two new
join-based dependencies.py helpers (get_round_in_organization,
get_wine_offering_in_organization) mirroring task 5's pattern. New
rounds always start as draft regardless of what the client posts,
avoiding a confusing creation-time constraint error.

POST /purchase-rounds/{id}/copy implements "kopiér fra forrige runde":
duplicates every round field (dates, texts) and every wine offering
(incl. category) into a fresh draft, leaving the source untouched.

PurchaseRound DELETE gets a three-tier policy based on status: draft
deletable by any admin, open never deletable, closed only by an
elevated superadmin. This is runtime-conditional on the loaded row, so
the elevation check was extracted out of get_current_active_superuser
into a standalone require_elevated_superuser(user, token) helper that
both the dependency and this handler call directly. WineOffering
delete has no such tier — an offering with real order lines is already
blocked by the existing RESTRICT FK, caught here as a 409.

Also seeds the 8 wine categories (empty table blocked any offering
creation) via a plain Alembic data migration, idempotent and
unconditional (no secret involved, unlike the task-4 superuser seed).

Verified end-to-end: dates-required 409, open round successfully
patched with dates, wine offerings created, round copied (new draft,
duplicated offerings with new ids/same category), open-round delete
403, closed-round delete 403 then 204 after /auth/elevate, wine
offering delete 204, draft round delete 204 (cascades its remaining
offering). DB left clean, participant count unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 18:56:38 +02:00
0ee6ef2e68 Add sudo-style elevation for superadmin actions
CurrentSuperuser now requires a short-lived (5 min) elevated JWT
claim in addition to the is_superadmin flag, obtained via the new
POST /auth/elevate (no re-authentication — the user is already the
only superadmin in practice; this is a deliberate-confirmation guard
against accidentally triggering a destructive action, not a defense
against a stolen session). Regular login tokens keep working unchanged
for all non-superadmin routes; DELETE /participants/{id} from task 5
now needs a fresh /auth/elevate call, no other code changes required
since CurrentSuperuser's meaning changed underneath it.

create_access_token gained an extra_claims param (backward compatible)
to carry the elevated flag.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 13:19:35 +02:00
ded23efc6a Add admin CRUD for participants (incl. active/inactive)
Full CRUD on Participant scoped to the caller's organization via new
get_route_in_organization/get_participant_in_organization helpers in
app/dependencies.py (shared cross-cutting spot, reused by task 6).
PATCH is_active is the primary deactivation path — replaces the old
spreadsheet-era "empty order" trick and preserves order history for
participants who leave and later return. DELETE is a separate,
superadmin-gated hard-delete for GDPR erasure requests, which cascades
to the participant's orders.

Adds UNIQUE(route_id, email) at the DB level (existing 308 participants
already conform, per task 3's dedup) plus email normalization on
create/update, so case-variant duplicates can't reappear via the API.
Every write path relies on catching the constraint's IntegrityError for
a clean 409 rather than a racy pre-check.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 13:05:08 +02:00
5be79a7c7a Add simple JWT login for User
Password hashing via pwdlib (argon2id), stateless JWT auth (2h expiry)
delivered as an Authorization: Bearer token, with an OAuth2PasswordBearer
dependency (app/dependencies.py) protecting future routes. Establishes
app/routers/ as the convention for feature routes, explicitly registered
in main.py (no auto-discovery, unlike app/models/).

The first admin user is seeded via an Alembic data migration gated on
SUPERUSER_EMAIL/SUPERUSER_PASSWORD env vars (read at migration-run time
only, never persisted to .env/git) rather than a script or open endpoint,
so a fresh `alembic upgrade head` still succeeds without them. The
migration mirrors the users/organization tables locally instead of
importing the live SQLModel classes, keeping it a stable schema
snapshot; it does import app.core.security.hash_password, a pure
utility with no table-shape dependency.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 22:23:25 +02:00
5a309c8aca Add one-off participant import script for legacy MongoDB data
Parses the raw MongoDB shell/Compass export (comments + ObjectId(...)
calls, not valid JSON), maps navn/email/telefon/afmeldt to the new
Participant model, filters out known test rows, and upserts on
(route_id, lowercased email) so re-running is safe. Excludes the raw
export (old_participants.json) from version control since it contains
real participants' personal data.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 19:16:26 +02:00
d2abe70333 Define core domain models and add first Alembic migration
Nine SQLModel table models covering the Organization/Route hierarchy
(WineCategory, Organization, User, Route, Participant, PurchaseRound,
WineOffering, Order, OrderLine), with explicit tablenames, FK cascade
behavior, and a CHECK constraint preventing a PurchaseRound from
reaching open/closed status without its opens_at/order_deadline_at/
pickup_at dates set. Also fixes the Alembic script template to import
sqlmodel (needed for autogenerate's AutoString column type).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 18:51:04 +02:00
589dcbcdf5 Scaffold FastAPI + SQLModel + Alembic project, Postgres-backed
Config-driven DATABASE_URL shared by the app and Alembic's env.py
(fixes v1's config drift), psycopg3 driver, auto-discovering
app/models package for SQLModel.metadata, and a /health endpoint
that exercises the DB dependency end-to-end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 15:57:44 +02:00