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>
This commit is contained in:
Carsten Gram 2026-09-28 22:34:27 +02:00
parent 5db65fb268
commit 7d767ec7c3
4 changed files with 173 additions and 55 deletions

View file

@ -49,6 +49,13 @@ vinbonden Horcher-familiens egne ruter i Frankrig/Belgien), selvom kun
- `sender_name`/`sender_email` — afsenderidentitet til mails,
forskellig pr. rute (fx "Finn Gram - Fælles Vinindkøb
<finn@vinindkoeb.dk>")
- `public_domain` (unik, nullable) — det domæne den offentlige
bestillingsside bruger for denne rute (fx `vinindkoeb.dk`).
Opgave 8b: en reverse proxy sender alle organisationers domæner til
samme backend, som selv afgør rute ud fra request'ens
`Host`-header (ingen `route_id` i URL'en for de offentlige
endpoints) — ét frontend-build kan dermed betjene flere
organisationer
- **Participant** — tilhører en Route
- `name`, `email`, `phone`, `is_active`
@ -64,6 +71,11 @@ vinbonden Horcher-familiens egne ruter i Frankrig/Belgien), selvom kun
**overskrives** `name`/`phone`/`is_active` fra formularen ved hver
bestilling (ingen login = bestillingsformularen er selve vejen man
holder sine oplysninger opdateret); ny email = ny deltager-række
- Kan også oprettes/opdateres via det separate tilmeldings-endpoint
(opgave 8b, `POST /public/signup`, kun navn+email) — bevidst
forskellig fra bestillingsflowet: **rører aldrig `phone`**, så en
ren tilmelding ikke kan slette et telefonnummer givet ved en
tidligere bestilling
- **PurchaseRound** — tilhører en Route
- `name` (frit, fx "Forår 2026" — intet sæson/år-felt, da det ikke
@ -84,11 +96,11 @@ vinbonden Horcher-familiens egne ruter i Frankrig/Belgien), selvom kun
- **Order** — deltager + runde
- `payment_status` (`unpaid`/`paid`), `paid_at`, `created_at`
- Oprettes via den offentlige bestillings-API (opgave 8a, ingen
- Oprettes via den offentlige bestillings-API (opgave 8a/8b, ingen
login); admin-ordreoversigt + "markér betalt" er stadig opgave 9
- **OrderLine** — ordre + wine_offering + antal
- Samme som Order — skrives af opgave 8a's bestillings-endpoint
- Samme som Order — skrives af det offentlige bestillings-endpoint
- **MailTemplate** — tilhører Route, én pr. `(route, event_type)`
- `event_type`: `round_announced` / `order_confirmed` /
@ -114,14 +126,15 @@ vinbonden Horcher-familiens egne ruter i Frankrig/Belgien), selvom kun
statusopdateres løbende via Postal-webhook (`POST /webhooks/postal`,
RSA-SHA256-signatur verificeret mod DKIM-nøglen fra DNS).
2. Ordre afgivet → kvittering til deltager. **Implementeret** (opgave
8a): `POST /public/routes/{route_id}/orders` (ingen login) genbruger
7b's render/send/log-mønster for én deltager. Manglende skabelon
blokerer bevidst **ikke** bestillingen (kun `/announce`'s admin-flow
fejler hårdt på det) — en konfigurationsfejl må aldrig stoppe en
rigtig kundes ordre. Kvitteringen genskaber bevidst det gamle
systems fulde vinkatalog-layout (efterligner den fysiske
bestillingsseddel ved vinbonden). *Notifikation til admin ved ny
ordre findes ikke endnu.*
8a, omlagt i 8b): `POST /public/orders` (ingen login, ingen
`route_id` i URL'en — ruten afgøres af `Host`-headeren, se Route
ovenfor) genbruger 7b's render/send/log-mønster for én deltager.
Manglende skabelon blokerer bevidst **ikke** bestillingen (kun
`/announce`'s admin-flow fejler hårdt på det) — en konfigurationsfejl
må aldrig stoppe en rigtig kundes ordre. Kvitteringen genskaber
bevidst det gamle systems fulde vinkatalog-layout (efterligner den
fysiske bestillingsseddel ved vinbonden). *Notifikation til admin
ved ny ordre findes ikke endnu.*
3. **Nyt:** Admin markerer ordre "betalt" → automatisk kvitteringsmail
til deltager (opgave 9 — bygger videre på samme mønster)
@ -132,7 +145,7 @@ endnu.
## Fase 1 — nuværende scope
**Færdige opgaver (1-7, 8a):**
**Færdige opgaver (1-7, 8a-b):**
1. ✅ FastAPI + SQLModel + PostgreSQL + Alembic scaffolding
2. ✅ Datamodellerne (Organization/Route-hierarki)
3. ✅ Deltager-migrering fra MongoDB (308 deltagere importeret,
@ -148,11 +161,23 @@ endnu.
- **7b**: Afsendelse via Postal + `MailLog`
(`POST /purchase-rounds/{id}/announce`, `GET /mail-logs`)
- **7c**: Postal-webhook til leveringsstatus (`POST /webhooks/postal`)
8. "Flyt offentlig bestillingsformular til nyt API" — delt i to:
8. "Flyt offentlig bestillingsformular til nyt API" — delt i tre:
- **8a**: ✅ Backend-API (`GET /public/routes/{id}/current-round`,
`POST /public/routes/{id}/orders`), ad-hoc deltageroprettelse,
kvitteringsmail
- **8b**: Selve den nye offentlige frontend — brugeren har ikke
- **8b**: ✅ Domænebaseret rute-opløsning + tilmeldings-endpoint.
`route_id` fjernet fra alle offentlige URL'er (var forældet, før
nogen rigtig frontend var bygget mod det) til fordel for
`Route.public_domain` + `Host`-header-opslag (se Route ovenfor).
`GET /public/current-round` returnerer nu altid `200`
(`current_round: null` når ingen runde er åben) i stedet for en
`404`, så frontenden kan skifte mellem bestillings- og
tilmeldingsvisning uden fejlhåndtering. Nyt
`POST /public/signup` (kun navn+email, se Participant ovenfor) —
dækker både "ingen runde åben → vis tilmelding" og "runde åben →
link til tilmelding uden bestilling" (selve linket/UI-skiftet er
en 8c-detalje).
- **8c**: Selve den nye offentlige frontend — brugeren har ikke
adgang til den gamle frontends kode (hårdt koblet til det gamle
API), så en ny bygges. Egen stack-beslutning, ikke taget endnu.

View file

@ -16,6 +16,7 @@ class RouteBase(SQLModel):
contact_phone: Optional[str] = Field(default=None)
sender_name: Optional[str] = Field(default=None)
sender_email: Optional[str] = Field(default=None)
public_domain: Optional[str] = Field(default=None, unique=True, index=True)
class Route(RouteBase, table=True):

View file

@ -3,7 +3,7 @@ from datetime import datetime, timezone
from decimal import Decimal
from typing import Optional
from fastapi import APIRouter, HTTPException, status
from fastapi import APIRouter, HTTPException, Request, status
from pydantic import field_validator
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import selectinload
@ -14,7 +14,7 @@ from app.models.mail_log import MailLog, MailLogStatus
from app.models.mail_template import MailEventType, MailTemplate
from app.models.order import Order, OrderConfirmation, OrderPublic
from app.models.order_line import OrderLine, OrderLinePublic
from app.models.participant import Participant, _normalize_email
from app.models.participant import Participant, ParticipantPublic, _normalize_email
from app.models.purchase_round import PurchaseRound, PurchaseRoundStatus
from app.models.route import Route
from app.models.wine_offering import WineOffering
@ -55,6 +55,16 @@ class PublicOrderSubmission(SQLModel):
order_lines: list[PublicOrderLineInput]
class PublicSignupInput(SQLModel):
name: str
email: str
@field_validator("email")
@classmethod
def _validate_email(cls, v: str) -> str:
return _normalize_email(v)
class PublicWineCategory(SQLModel):
id: int
name: str
@ -69,10 +79,7 @@ class PublicWineOffering(SQLModel):
category: PublicWineCategory
class PublicRoundInfo(SQLModel):
route_id: int
route_name: str
meeting_info: Optional[str]
class PublicRoundDetails(SQLModel):
round_id: int
round_name: str
opens_at: Optional[datetime]
@ -84,6 +91,21 @@ class PublicRoundInfo(SQLModel):
wine_offerings: list[PublicWineOffering]
class PublicPageInfo(SQLModel):
route_name: str
meeting_info: Optional[str]
current_round: Optional[PublicRoundDetails]
def get_route_from_domain(request: Request, session: Session) -> Route:
host = request.headers.get("host", "")
domain = host.split(":")[0] # fjern evt. port
route = session.exec(select(Route).where(Route.public_domain == domain)).first()
if route is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"No route configured for domain {domain!r}")
return route
def _get_open_round(session: Session, route_id: int) -> Optional[PurchaseRound]:
return session.exec(
select(PurchaseRound)
@ -127,14 +149,41 @@ def _get_or_create_participant(session: Session, route_id: int, data: PublicPart
return participant
@router.get("/routes/{route_id}/current-round", response_model=PublicRoundInfo)
def get_current_round(route_id: int, session: SessionDep) -> PublicRoundInfo:
route = session.get(Route, route_id)
if route is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Route not found")
purchase_round = _get_open_round(session, route_id)
def _signup_participant(session: Session, route_id: int, data: PublicSignupInput) -> Participant:
existing = session.exec(
select(Participant).where(Participant.route_id == route_id, Participant.email == data.email)
).first()
if existing is not None:
existing.name = data.name
existing.is_active = True # tilmelding er en eksplicit "ja tak til mails"-handling
session.add(existing)
return existing
participant = Participant(name=data.name, email=data.email, phone=None, is_active=True, route_id=route_id)
session.add(participant)
try:
session.flush()
except IntegrityError as exc:
session.rollback()
if "uq_participant_route_email" in str(getattr(exc, "orig", exc)):
existing = session.exec(
select(Participant).where(Participant.route_id == route_id, Participant.email == data.email)
).first()
if existing is not None:
existing.name = data.name
existing.is_active = True
session.add(existing)
return existing
raise
return participant
@router.get("/current-round", response_model=PublicPageInfo)
def get_current_round(request: Request, session: SessionDep) -> PublicPageInfo:
route = get_route_from_domain(request, session)
purchase_round = _get_open_round(session, route.id)
if purchase_round is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No open purchase round for this route")
return PublicPageInfo(route_name=route.name, meeting_info=route.meeting_info, current_round=None)
offerings = session.exec(
select(WineOffering)
@ -142,33 +191,43 @@ def get_current_round(route_id: int, session: SessionDep) -> PublicRoundInfo:
.options(selectinload(WineOffering.category))
).all()
return PublicRoundInfo(
route_id=route.id,
return PublicPageInfo(
route_name=route.name,
meeting_info=route.meeting_info,
round_id=purchase_round.id,
round_name=purchase_round.name,
opens_at=purchase_round.opens_at,
order_deadline_at=purchase_round.order_deadline_at,
pickup_at=purchase_round.pickup_at,
intro_text=purchase_round.intro_text,
pickup_info_text=purchase_round.pickup_info_text,
eur_dkk_rate=purchase_round.eur_dkk_rate,
wine_offerings=[
PublicWineOffering(
id=o.id,
name=o.name,
price=o.price,
is_organic=o.is_organic,
category=PublicWineCategory(
id=o.category.id, name=o.category.name, sort_order=o.category.sort_order
),
)
for o in offerings
],
current_round=PublicRoundDetails(
round_id=purchase_round.id,
round_name=purchase_round.name,
opens_at=purchase_round.opens_at,
order_deadline_at=purchase_round.order_deadline_at,
pickup_at=purchase_round.pickup_at,
intro_text=purchase_round.intro_text,
pickup_info_text=purchase_round.pickup_info_text,
eur_dkk_rate=purchase_round.eur_dkk_rate,
wine_offerings=[
PublicWineOffering(
id=o.id,
name=o.name,
price=o.price,
is_organic=o.is_organic,
category=PublicWineCategory(
id=o.category.id, name=o.category.name, sort_order=o.category.sort_order
),
)
for o in offerings
],
),
)
@router.post("/signup", response_model=ParticipantPublic, status_code=status.HTTP_201_CREATED)
def signup(payload: PublicSignupInput, request: Request, session: SessionDep) -> Participant:
route = get_route_from_domain(request, session)
participant = _signup_participant(session, route.id, payload)
session.commit()
session.refresh(participant)
return participant
def _send_order_confirmation(
session: Session,
route: Route,
@ -252,12 +311,10 @@ def _send_order_confirmation(
session.commit()
@router.post("/routes/{route_id}/orders", response_model=OrderConfirmation, status_code=status.HTTP_201_CREATED)
def submit_order(route_id: int, payload: PublicOrderSubmission, session: SessionDep) -> OrderConfirmation:
route = session.get(Route, route_id)
if route is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Route not found")
purchase_round = _get_open_round(session, route_id)
@router.post("/orders", response_model=OrderConfirmation, status_code=status.HTTP_201_CREATED)
def submit_order(payload: PublicOrderSubmission, request: Request, session: SessionDep) -> OrderConfirmation:
route = get_route_from_domain(request, session)
purchase_round = _get_open_round(session, route.id)
if purchase_round is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No open purchase round for this route")
if not payload.order_lines:
@ -277,7 +334,7 @@ def submit_order(route_id: int, payload: PublicOrderSubmission, session: Session
detail=f"wine_offering_id(s) not part of the current round: {sorted(missing)}",
)
participant = _get_or_create_participant(session, route_id, payload.participant)
participant = _get_or_create_participant(session, route.id, payload.participant)
order = Order(participant_id=participant.id, purchase_round_id=purchase_round.id)
session.add(order)

View file

@ -0,0 +1,35 @@
"""add route public_domain
Revision ID: 6ec72407c24e
Revises: 5935c5a3039e
Create Date: 2026-09-28 22:31:51.337919
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = '6ec72407c24e'
down_revision: Union[str, Sequence[str], None] = '5935c5a3039e'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('route', sa.Column('public_domain', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
op.create_index(op.f('ix_route_public_domain'), 'route', ['public_domain'], unique=True)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_route_public_domain'), table_name='route')
op.drop_column('route', 'public_domain')
# ### end Alembic commands ###