7f51f5242f
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
464 lines
19 KiB
Python
464 lines
19 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 concurrent.futures import ThreadPoolExecutor
|
|
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 import Path as ApiPath
|
|
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 import objectstore as objectstore_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, max_size=10)
|
|
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
|
|
app.state.objectstore = objectstore_mod.build_objectstore(config.objectstore_kind())
|
|
app.state.image_allow_private = config.image_fetch_allow_private()
|
|
app.state.image_runner = ThreadPoolExecutor(max_workers=2, thread_name_prefix="img-run")
|
|
# §6.9 startup recovery — resume runs stuck mid image-fetch, off the request path.
|
|
app.state.image_runner.submit(
|
|
products.recover_incomplete_runs, app.state.pool,
|
|
app.state.objectstore, app.state.image_allow_private,
|
|
)
|
|
try:
|
|
yield
|
|
finally:
|
|
app.state.image_runner.shutdown(wait=False)
|
|
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.",
|
|
)
|
|
app.state.image_runner.submit(
|
|
products.run_image_phase, app.state.pool, app.state.objectstore,
|
|
run_id, app.state.image_allow_private,
|
|
)
|
|
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"'},
|
|
)
|
|
|
|
_RENDITION_CT = {"thumb": "image/webp", "card": "image/webp",
|
|
"detail": "image/webp", "original": "application/octet-stream"}
|
|
|
|
@app.get("/api/products/images/{image_id}/{rendition}")
|
|
def serve_product_image(
|
|
image_id: int,
|
|
rendition: str = ApiPath(pattern="^(original|thumb|card|detail)$"),
|
|
conn: psycopg.Connection = Depends(get_conn),
|
|
sess: dict | None = Depends(get_session),
|
|
):
|
|
"""Serve a hosted image rendition, storefront-authorized + immutable cache (§6.4, INV-16)."""
|
|
gate = _merchant_gate(conn, sess)
|
|
if isinstance(gate, JSONResponse):
|
|
return gate
|
|
_account, sf = gate
|
|
rec = products.image_for_serving(conn, sf.id, image_id)
|
|
if rec is None:
|
|
return _error(404, "not_found", "No such image.")
|
|
if rec["status"] != "fetched":
|
|
return _error(409, "not_fetched", "This image has not been fetched yet.")
|
|
key = rec[rendition]
|
|
if not key:
|
|
return _error(404, "not_found", "No such rendition.")
|
|
try:
|
|
data = app.state.objectstore.get(key)
|
|
except objectstore_mod.ObjectNotFound:
|
|
return _error(404, "not_found", "No such image.")
|
|
return Response(content=data, media_type=_RENDITION_CT[rendition],
|
|
headers={"Cache-Control": "public, max-age=31536000, immutable"})
|
|
|
|
@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()
|