diff --git a/CLAUDE.md b/CLAUDE.md index 888577e..ebecbaa 100644 --- a/CLAUDE.md +++ b/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 ") + - `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. diff --git a/app/models/route.py b/app/models/route.py index b56b8f7..aaad81a 100644 --- a/app/models/route.py +++ b/app/models/route.py @@ -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): diff --git a/app/routers/public_orders.py b/app/routers/public_orders.py index 4a4d67a..175dbeb 100644 --- a/app/routers/public_orders.py +++ b/app/routers/public_orders.py @@ -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) diff --git a/migrations/versions/6ec72407c24e_add_route_public_domain.py b/migrations/versions/6ec72407c24e_add_route_public_domain.py new file mode 100644 index 0000000..51e9a78 --- /dev/null +++ b/migrations/versions/6ec72407c24e_add_route_public_domain.py @@ -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 ###