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:
parent
5db65fb268
commit
7d767ec7c3
4 changed files with 173 additions and 55 deletions
51
CLAUDE.md
51
CLAUDE.md
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
35
migrations/versions/6ec72407c24e_add_route_public_domain.py
Normal file
35
migrations/versions/6ec72407c24e_add_route_public_domain.py
Normal 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 ###
|
||||
Loading…
Add table
Reference in a new issue