Files
wiggleverse-ecomm/backend/app/main.py
T

418 lines
17 KiB
Python

"""ecomm backend — FastAPI app factory + the REST BFF.
SLICE-1 mounted /healthz; SLICE-2 adds the /api/auth/* identity endpoints (§6.4). The BFF
translates HTTP <-> domain calls and owns no business logic (INV-6): every rule lives in
the accounts domain. create_app() opens the pool, self-migrates (INV-1, INV-7), and builds
the configured mailer (INV-8) at startup. SLICE-3 adds POST /api/storefronts and feeds the
_storefront_for seam from the storefronts domain. SLICE-5 adds the /api/products/* import
spine (SD-0002 §6.4): each endpoint is a gate + one products-domain call + error mapping.
"""
from __future__ import annotations
import logging
import sys
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
import psycopg
from fastapi import Depends, FastAPI, File, Query, Response, UploadFile
from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from app.domains import accounts, products, storefronts
from app.platform import config, db
from app.platform import mailer as mailer_mod
from app.platform.deps import SESSION_COOKIE, get_conn, get_mailer, get_session
from app.platform.mailer import Mailer
from app.platform import session as session_mod
# The repo-root VERSION file is the single version source: the deploy pin checks out its
# tag, and /healthz must report it back (flotilla-core's verify gate compares them).
_REPO_ROOT = Path(__file__).resolve().parents[2]
try:
_APP_VERSION = (_REPO_ROOT / "VERSION").read_text().strip()
except OSError:
_APP_VERSION = "0.0.0"
class RequestCodeBody(BaseModel):
email: str
class VerifyBody(BaseModel):
email: str
code: str
class CreateStorefrontBody(BaseModel):
name: str | None = None
def _error(status: int, code: str, message: str, **extra: Any) -> JSONResponse:
"""The shared §6.4 error envelope: {"error": {"code", "message", ...}}."""
return JSONResponse(status_code=status, content={"error": {"code": code, "message": message, **extra}})
def _storefront_for(conn: psycopg.Connection, account: accounts.Account) -> dict | None:
"""The entry-routing answer: which storefront, if any, this account has (§6.5)."""
sf = storefronts.storefront_for(conn, account.id)
return {"id": sf.id, "name": sf.name} if sf else None
def _merchant_gate(
conn: psycopg.Connection, sess: dict | None
) -> JSONResponse | tuple[accounts.Account, storefronts.Storefront]:
"""The shared /api/products/* gate: a signed-in account that has its storefront.
Returns the (account, storefront) pair, or the ready-to-return error response —
401 with no session, 404 before the storefront exists (INV-14: every products
call is storefront-scoped, so there is nothing to address yet).
"""
if sess is None:
return _error(401, "unauthenticated", "You are not signed in.")
account = accounts.get_account(conn, sess["account_id"])
if account is None:
return _error(401, "unauthenticated", "You are not signed in.")
sf = storefronts.storefront_for(conn, account.id)
if sf is None:
return _error(404, "no_storefront", "Create your storefront first.")
return account, sf
def _ensure_app_logging() -> None:
"""Surface the app's own `ecomm.*` INFO logs on stderr (idempotent).
uvicorn configures only its own loggers, leaving the root with no INFO handler — so
without this the `ecomm.mailer` line (PUC-10's local dev channel: the one-time code in
the backend log) would be swallowed. A dedicated handler with propagate=False keeps it
out of uvicorn's stream and avoids double-logging.
"""
lg = logging.getLogger("ecomm")
if not lg.handlers:
handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(logging.Formatter("%(levelname)s:%(name)s: %(message)s"))
lg.addHandler(handler)
lg.setLevel(logging.INFO)
lg.propagate = False
def _set_session_cookie(response: Response, account: accounts.Account) -> None:
token = session_mod.sign({"account_id": account.id, "email": account.email}, config.session_secret())
response.set_cookie(
SESSION_COOKIE,
token,
httponly=True,
samesite="lax",
secure=config.cookie_secure(),
path="/",
)
def create_app(database_url: str | None = None, static_dir: str | Path | None = None) -> FastAPI:
_ensure_app_logging()
dsn = database_url or config.database_url()
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.pool = db.open_pool(dsn)
with app.state.pool.connection() as conn:
db.migrate(conn) # self-migrate at startup (INV-1, INV-7)
app.state.mailer = mailer_mod.build_mailer(config.mailer_kind()) # INV-8
try:
yield
finally:
app.state.pool.close()
app = FastAPI(title="ecomm", version=_APP_VERSION, lifespan=lifespan)
@app.get("/healthz")
def healthz(response: Response, conn: psycopg.Connection = Depends(get_conn)):
"""Liveness + readiness: process up, DB reachable, migrations current (§6.4)."""
try:
conn.execute("SELECT 1")
pending = db.pending_migrations(conn)
except Exception:
response.status_code = 503
return {"status": "unavailable", "reason": "database_unreachable"}
if pending:
response.status_code = 503
return {"status": "unavailable", "reason": "migrations_pending"}
return {"status": "ok", "version": _APP_VERSION}
@app.post("/api/auth/request-code")
def request_code(
body: RequestCodeBody,
conn: psycopg.Connection = Depends(get_conn),
mailer: Mailer = Depends(get_mailer),
):
"""Issue + dispatch a one-time code. Uniform for new/known emails (§6.6)."""
try:
accounts.request_code(conn, mailer, body.email)
except accounts.InvalidEmail:
return _error(400, "invalid_email", "That doesn't look like an email address.")
except accounts.ResendCooldown as exc:
return _error(
429, "resend_cooldown",
f"Please wait {exc.retry_after_s}s before requesting another code.",
retry_after_s=exc.retry_after_s,
)
except accounts.DeliveryFailed:
return _error(
502, "delivery_failed",
"We couldn't send the code — try again in a moment.",
)
return Response(status_code=204)
@app.post("/api/auth/verify")
def verify(body: VerifyBody, conn: psycopg.Connection = Depends(get_conn)):
"""Verify a code, start a session, and answer entry routing (§6.4/§6.5)."""
try:
account, created = accounts.verify(conn, body.email, body.code)
except accounts.InvalidEmail:
return _error(400, "invalid_email", "That doesn't look like an email address.")
except accounts.CodeMismatch as exc:
return _error(
400, "code_mismatch", "That code didn't match.",
attempts_remaining=exc.attempts_remaining,
)
except accounts.CodeExpired:
return _error(400, "code_expired", "That code expired — request a fresh one.")
except accounts.CodeExhausted:
return _error(400, "code_exhausted", "Too many attempts — request a fresh code.")
payload = {
"account": {"email": account.email},
"storefront": _storefront_for(conn, account),
"created": created,
}
resp = JSONResponse(status_code=200, content=payload)
_set_session_cookie(resp, account)
return resp
@app.get("/api/auth/me")
def me(conn: psycopg.Connection = Depends(get_conn), sess: dict | None = Depends(get_session)):
"""The signed-in account + entry-routing answer, or 401 (§6.4/§6.5)."""
if sess is None:
return _error(401, "unauthenticated", "You are not signed in.")
account = accounts.get_account(conn, sess["account_id"])
if account is None:
return _error(401, "unauthenticated", "You are not signed in.")
return {"account": {"email": account.email}, "storefront": _storefront_for(conn, account)}
@app.post("/api/auth/logout")
def logout():
"""End the session by clearing the cookie. Idempotent (PUC-9; no server state)."""
resp = Response(status_code=204)
resp.delete_cookie(SESSION_COOKIE, path="/")
return resp
@app.post("/api/storefronts")
def create_storefront(
body: CreateStorefrontBody,
conn: psycopg.Connection = Depends(get_conn),
sess: dict | None = Depends(get_session),
):
"""Create the account's one storefront (§6.4; PUC-4, INV-4/PUC-7 on refusal)."""
if sess is None:
return _error(401, "unauthenticated", "You are not signed in.")
account = accounts.get_account(conn, sess["account_id"])
if account is None:
return _error(401, "unauthenticated", "You are not signed in.")
try:
sf = storefronts.create_storefront(conn, account.id, account.email, body.name)
except storefronts.AlreadyOwnsStorefront:
return _error(
409, "already_owns_storefront",
"Your account already has its storefront — ecomm is one storefront per account today.",
)
return JSONResponse(status_code=201, content={"id": sf.id, "name": sf.name})
@app.post("/api/products/imports")
async def import_upload(
file: UploadFile = File(...),
conn: psycopg.Connection = Depends(get_conn),
sess: dict | None = Depends(get_session),
):
"""Upload a CSV → validated import draft (§6.4; PUC-2, PUC-5/5a on rejection)."""
gate = _merchant_gate(conn, sess)
if isinstance(gate, JSONResponse):
return gate
account, sf = gate
data = await file.read()
if len(data) > products.MAX_FILE_BYTES:
return _error(413, "file_too_large", "This file is larger than 10 MB.")
try:
draft = products.import_validate(conn, sf.id, account.id, file.filename or "upload.csv", data)
except products.FileRejected as exc:
return _error(400, exc.code, exc.message)
return JSONResponse(status_code=201, content=draft)
@app.get("/api/products/imports/drafts/{draft_id}")
def get_import_draft(
draft_id: int,
conn: psycopg.Connection = Depends(get_conn),
sess: dict | None = Depends(get_session),
):
"""One draft's preview payload — summary, never the file bytes (§6.4; PUC-3)."""
gate = _merchant_gate(conn, sess)
if isinstance(gate, JSONResponse):
return gate
_account, sf = gate
try:
return products.get_draft(conn, sf.id, draft_id)
except products.DraftNotFound:
return _error(404, "not_found", "No such import preview.")
except products.DraftExpired:
return _error(410, "draft_expired", "This preview expired — upload the file again.")
@app.get("/api/products/imports/drafts/{draft_id}/records")
def get_import_draft_records(
draft_id: int,
kind: str | None = Query(default=None, pattern="^(add|update|unchanged|error)$"),
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
conn: psycopg.Connection = Depends(get_conn),
sess: dict | None = Depends(get_session),
):
"""The draft's per-product preview records, paged + kind-filtered (§6.4; PUC-3)."""
gate = _merchant_gate(conn, sess)
if isinstance(gate, JSONResponse):
return gate
_account, sf = gate
try:
records = products.get_draft_records(conn, sf.id, draft_id, kind, limit, offset)
except products.DraftNotFound:
return _error(404, "not_found", "No such import preview.")
except products.DraftExpired:
return _error(410, "draft_expired", "This preview expired — upload the file again.")
return {"records": records}
@app.post("/api/products/imports/drafts/{draft_id}/confirm")
def confirm_import_draft(
draft_id: int,
conn: psycopg.Connection = Depends(get_conn),
sess: dict | None = Depends(get_session),
):
"""Apply the previewed diff as one import run (§6.4; PUC-4, INV-10/11)."""
gate = _merchant_gate(conn, sess)
if isinstance(gate, JSONResponse):
return gate
account, sf = gate
try:
run_id = products.confirm_draft(conn, sf.id, account.id, draft_id)
except products.DraftNotFound:
return _error(404, "not_found", "No such import preview.")
except products.DraftExpired:
return _error(410, "draft_expired", "This preview expired — upload the file again.")
except products.PreviewStale:
return _error(
409, "preview_stale",
"Your catalog changed since this preview — upload the file again.",
)
except products.NothingToApply:
return _error(
409, "nothing_to_apply",
"Nothing to change — your catalog already matches this file.",
)
return JSONResponse(status_code=201, content={"run_id": run_id})
@app.delete("/api/products/imports/drafts/{draft_id}")
def discard_import_draft(
draft_id: int,
conn: psycopg.Connection = Depends(get_conn),
sess: dict | None = Depends(get_session),
):
"""Discard the draft, no trace kept; idempotent (§6.4; PUC-3a)."""
gate = _merchant_gate(conn, sess)
if isinstance(gate, JSONResponse):
return gate
_account, sf = gate
products.discard_draft(conn, sf.id, draft_id)
return Response(status_code=204)
@app.get("/api/products/imports/runs")
def list_import_runs(
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
conn: psycopg.Connection = Depends(get_conn),
sess: dict | None = Depends(get_session),
):
"""The storefront's import history, newest first (§6.4; PUC-8)."""
gate = _merchant_gate(conn, sess)
if isinstance(gate, JSONResponse):
return gate
_account, sf = gate
return {"runs": products.list_runs(conn, sf.id, limit, offset)}
@app.get("/api/products/imports/runs/{run_id}")
def get_import_run(
run_id: int,
conn: psycopg.Connection = Depends(get_conn),
sess: dict | None = Depends(get_session),
):
"""One run's detail payload, errors included (§6.4; PUC-8)."""
gate = _merchant_gate(conn, sess)
if isinstance(gate, JSONResponse):
return gate
_account, sf = gate
try:
return products.get_run(conn, sf.id, run_id)
except products.RunNotFound:
return _error(404, "not_found", "No such import run.")
@app.get("/api/products/summary")
def products_summary(
conn: psycopg.Connection = Depends(get_conn),
sess: dict | None = Depends(get_session),
):
"""The products dashboard counts (§6.4)."""
gate = _merchant_gate(conn, sess)
if isinstance(gate, JSONResponse):
return gate
_account, sf = gate
return products.summary(conn, sf.id)
@app.get("/api/products/export")
def export_products(
status: str = Query(default="all", pattern="^(all|active|draft|archived)$"),
conn: psycopg.Connection = Depends(get_conn),
sess: dict | None = Depends(get_session),
):
"""Stream the catalog as canonical CSV, optionally status-filtered (§6.4; PUC-9)."""
gate = _merchant_gate(conn, sess)
if isinstance(gate, JSONResponse):
return gate
_account, sf = gate
try:
stream = products.export_catalog(conn, sf.id, status)
except products.EmptyCatalog:
return _error(409, "empty_catalog", "There are no products to export.")
return StreamingResponse(
stream,
media_type="text/csv",
headers={"content-disposition": 'attachment; filename="ecomm-products-export.csv"'},
)
@app.get("/api/products/sample.csv")
def products_sample_csv():
"""The DOC-3 worked-example CSV. Documentation, so no auth gate (§6.4)."""
return PlainTextResponse(
products.SAMPLE_CSV_PATH.read_text(),
media_type="text/csv",
headers={"content-disposition": 'attachment; filename="ecomm-products-sample.csv"'},
)
# Deployed topology (launch-app SPEC §2): nginx proxies everything here, so the
# backend serves the built SPA. Mounted LAST so /healthz and /api/* win. In dev the
# dist dir doesn't exist (Vite serves the frontend) and the mount is skipped.
spa_dir = Path(static_dir) if static_dir is not None else _REPO_ROOT / "frontend" / "dist"
if (spa_dir / "index.html").is_file():
app.mount("/", StaticFiles(directory=spa_dir, html=True), name="spa")
return app
app = create_app()