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, - `sender_name`/`sender_email` — afsenderidentitet til mails,
forskellig pr. rute (fx "Finn Gram - Fælles Vinindkøb forskellig pr. rute (fx "Finn Gram - Fælles Vinindkøb
<finn@vinindkoeb.dk>") <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 - **Participant** — tilhører en Route
- `name`, `email`, `phone`, `is_active` - `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 **overskrives** `name`/`phone`/`is_active` fra formularen ved hver
bestilling (ingen login = bestillingsformularen er selve vejen man bestilling (ingen login = bestillingsformularen er selve vejen man
holder sine oplysninger opdateret); ny email = ny deltager-række 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 - **PurchaseRound** — tilhører en Route
- `name` (frit, fx "Forår 2026" — intet sæson/år-felt, da det ikke - `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 - **Order** — deltager + runde
- `payment_status` (`unpaid`/`paid`), `paid_at`, `created_at` - `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 login); admin-ordreoversigt + "markér betalt" er stadig opgave 9
- **OrderLine** — ordre + wine_offering + antal - **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)` - **MailTemplate** — tilhører Route, én pr. `(route, event_type)`
- `event_type`: `round_announced` / `order_confirmed` / - `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`, statusopdateres løbende via Postal-webhook (`POST /webhooks/postal`,
RSA-SHA256-signatur verificeret mod DKIM-nøglen fra DNS). RSA-SHA256-signatur verificeret mod DKIM-nøglen fra DNS).
2. Ordre afgivet → kvittering til deltager. **Implementeret** (opgave 2. Ordre afgivet → kvittering til deltager. **Implementeret** (opgave
8a): `POST /public/routes/{route_id}/orders` (ingen login) genbruger 8a, omlagt i 8b): `POST /public/orders` (ingen login, ingen
7b's render/send/log-mønster for én deltager. Manglende skabelon `route_id` i URL'en — ruten afgøres af `Host`-headeren, se Route
blokerer bevidst **ikke** bestillingen (kun `/announce`'s admin-flow ovenfor) genbruger 7b's render/send/log-mønster for én deltager.
fejler hårdt på det) — en konfigurationsfejl må aldrig stoppe en Manglende skabelon blokerer bevidst **ikke** bestillingen (kun
rigtig kundes ordre. Kvitteringen genskaber bevidst det gamle `/announce`'s admin-flow fejler hårdt på det) — en konfigurationsfejl
systems fulde vinkatalog-layout (efterligner den fysiske må aldrig stoppe en rigtig kundes ordre. Kvitteringen genskaber
bestillingsseddel ved vinbonden). *Notifikation til admin ved ny bevidst det gamle systems fulde vinkatalog-layout (efterligner den
ordre findes ikke endnu.* fysiske bestillingsseddel ved vinbonden). *Notifikation til admin
ved ny ordre findes ikke endnu.*
3. **Nyt:** Admin markerer ordre "betalt" → automatisk kvitteringsmail 3. **Nyt:** Admin markerer ordre "betalt" → automatisk kvitteringsmail
til deltager (opgave 9 — bygger videre på samme mønster) til deltager (opgave 9 — bygger videre på samme mønster)
@ -132,7 +145,7 @@ endnu.
## Fase 1 — nuværende scope ## Fase 1 — nuværende scope
**Færdige opgaver (1-7, 8a):** **Færdige opgaver (1-7, 8a-b):**
1. ✅ FastAPI + SQLModel + PostgreSQL + Alembic scaffolding 1. ✅ FastAPI + SQLModel + PostgreSQL + Alembic scaffolding
2. ✅ Datamodellerne (Organization/Route-hierarki) 2. ✅ Datamodellerne (Organization/Route-hierarki)
3. ✅ Deltager-migrering fra MongoDB (308 deltagere importeret, 3. ✅ Deltager-migrering fra MongoDB (308 deltagere importeret,
@ -148,11 +161,23 @@ endnu.
- **7b**: Afsendelse via Postal + `MailLog` - **7b**: Afsendelse via Postal + `MailLog`
(`POST /purchase-rounds/{id}/announce`, `GET /mail-logs`) (`POST /purchase-rounds/{id}/announce`, `GET /mail-logs`)
- **7c**: Postal-webhook til leveringsstatus (`POST /webhooks/postal`) - **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`, - **8a**: ✅ Backend-API (`GET /public/routes/{id}/current-round`,
`POST /public/routes/{id}/orders`), ad-hoc deltageroprettelse, `POST /public/routes/{id}/orders`), ad-hoc deltageroprettelse,
kvitteringsmail 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 adgang til den gamle frontends kode (hårdt koblet til det gamle
API), så en ny bygges. Egen stack-beslutning, ikke taget endnu. 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) contact_phone: Optional[str] = Field(default=None)
sender_name: Optional[str] = Field(default=None) sender_name: Optional[str] = Field(default=None)
sender_email: 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): class Route(RouteBase, table=True):

View file

@ -3,7 +3,7 @@ from datetime import datetime, timezone
from decimal import Decimal from decimal import Decimal
from typing import Optional from typing import Optional
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, Request, status
from pydantic import field_validator from pydantic import field_validator
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import selectinload 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.mail_template import MailEventType, MailTemplate
from app.models.order import Order, OrderConfirmation, OrderPublic from app.models.order import Order, OrderConfirmation, OrderPublic
from app.models.order_line import OrderLine, OrderLinePublic 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.purchase_round import PurchaseRound, PurchaseRoundStatus
from app.models.route import Route from app.models.route import Route
from app.models.wine_offering import WineOffering from app.models.wine_offering import WineOffering
@ -55,6 +55,16 @@ class PublicOrderSubmission(SQLModel):
order_lines: list[PublicOrderLineInput] 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): class PublicWineCategory(SQLModel):
id: int id: int
name: str name: str
@ -69,10 +79,7 @@ class PublicWineOffering(SQLModel):
category: PublicWineCategory category: PublicWineCategory
class PublicRoundInfo(SQLModel): class PublicRoundDetails(SQLModel):
route_id: int
route_name: str
meeting_info: Optional[str]
round_id: int round_id: int
round_name: str round_name: str
opens_at: Optional[datetime] opens_at: Optional[datetime]
@ -84,6 +91,21 @@ class PublicRoundInfo(SQLModel):
wine_offerings: list[PublicWineOffering] 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]: def _get_open_round(session: Session, route_id: int) -> Optional[PurchaseRound]:
return session.exec( return session.exec(
select(PurchaseRound) select(PurchaseRound)
@ -127,14 +149,41 @@ def _get_or_create_participant(session: Session, route_id: int, data: PublicPart
return participant return participant
@router.get("/routes/{route_id}/current-round", response_model=PublicRoundInfo) def _signup_participant(session: Session, route_id: int, data: PublicSignupInput) -> Participant:
def get_current_round(route_id: int, session: SessionDep) -> PublicRoundInfo: existing = session.exec(
route = session.get(Route, route_id) select(Participant).where(Participant.route_id == route_id, Participant.email == data.email)
if route is None: ).first()
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Route not found") if existing is not None:
purchase_round = _get_open_round(session, route_id) 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: 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( offerings = session.exec(
select(WineOffering) select(WineOffering)
@ -142,10 +191,10 @@ def get_current_round(route_id: int, session: SessionDep) -> PublicRoundInfo:
.options(selectinload(WineOffering.category)) .options(selectinload(WineOffering.category))
).all() ).all()
return PublicRoundInfo( return PublicPageInfo(
route_id=route.id,
route_name=route.name, route_name=route.name,
meeting_info=route.meeting_info, meeting_info=route.meeting_info,
current_round=PublicRoundDetails(
round_id=purchase_round.id, round_id=purchase_round.id,
round_name=purchase_round.name, round_name=purchase_round.name,
opens_at=purchase_round.opens_at, opens_at=purchase_round.opens_at,
@ -166,9 +215,19 @@ def get_current_round(route_id: int, session: SessionDep) -> PublicRoundInfo:
) )
for o in offerings 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( def _send_order_confirmation(
session: Session, session: Session,
route: Route, route: Route,
@ -252,12 +311,10 @@ def _send_order_confirmation(
session.commit() session.commit()
@router.post("/routes/{route_id}/orders", response_model=OrderConfirmation, status_code=status.HTTP_201_CREATED) @router.post("/orders", response_model=OrderConfirmation, status_code=status.HTTP_201_CREATED)
def submit_order(route_id: int, payload: PublicOrderSubmission, session: SessionDep) -> OrderConfirmation: def submit_order(payload: PublicOrderSubmission, request: Request, session: SessionDep) -> OrderConfirmation:
route = session.get(Route, route_id) route = get_route_from_domain(request, session)
if route is None: purchase_round = _get_open_round(session, route.id)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Route not found")
purchase_round = _get_open_round(session, route_id)
if purchase_round is None: if purchase_round is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No open purchase round for this route") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No open purchase round for this route")
if not payload.order_lines: 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)}", 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) order = Order(participant_id=participant.id, purchase_round_id=purchase_round.id)
session.add(order) 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 ###