Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 011f4d5dc1 | |||
| 2606fbf826 | |||
| ad738adbc0 | |||
| a58f42cf86 | |||
| d18a84e135 | |||
| 87a56a66c4 | |||
| 4141285b89 | |||
| 188494272a | |||
| 26f84cb916 | |||
| b5dac8886f | |||
| 4dacc2dafd | |||
| a8538d3ecc | |||
| 0c7865e9e1 | |||
| fcbf1393f5 | |||
| 138126ab17 | |||
| 667a462e0b | |||
| 9bc6e4dbd2 | |||
| 19ee695c20 | |||
| f64c3fddf9 | |||
| c39bbd4728 | |||
| 0ee948b34d | |||
| 1267d4f29d | |||
| 6b405a0f2d | |||
| 118e925580 | |||
| 11acb3a5b1 | |||
| efc9c8edb9 | |||
| f7b76d5370 | |||
| 4bb9763633 | |||
| a456d51f17 | |||
| ac3c4ffe36 | |||
| 8a2ec24d86 | |||
| acff7ac588 | |||
| 22d738fc74 | |||
| 1b8c60fcb5 | |||
| 9f4295b77e | |||
| a1c5544694 | |||
| 0747181157 | |||
| 877be249c7 | |||
| 25ac540171 | |||
| 10d22938f1 | |||
| ddc949615e | |||
| 0b302ea1e1 | |||
| ef1498ba08 | |||
| cc9d9dda5f | |||
| 306b5c1e5d | |||
| e11fb032b1 | |||
| ba8b493a31 | |||
| 1b40bde4a0 | |||
| 725877c5c6 | |||
| bf62f413d7 | |||
| cfc28d7002 | |||
| b3ffb2d4b6 | |||
| cfd2b4ecc7 | |||
| d2eceac272 | |||
| 95680c9960 | |||
| 2d15f1a2cb | |||
| 446c13211a | |||
| 7a6b396f65 | |||
| 9373177ed3 |
@@ -20,8 +20,14 @@ This app (One Name `ecomm`, see [`app.json`](./app.json)) is composed of:
|
||||
|
||||
## Status
|
||||
|
||||
SLICE-1 (walking skeleton) of SD-0001 is in place: a four-layer FastAPI backend that
|
||||
self-migrates an empty PostgreSQL database at startup and serves `/healthz`, a
|
||||
Vite/React shell, the `scripts/check.sh` gate, and the dev container lifecycle
|
||||
(`scripts/dev.sh`). See [`docs/BOOTSTRAP.md`](./docs/BOOTSTRAP.md) to run it locally.
|
||||
Identity (SLICE-2) and the storefront (SLICE-3) are next.
|
||||
SLICE-3 (storefront) of SD-0001 is in place on top of SLICE-1/2: the `storefronts`
|
||||
domain (create + owner membership, INV-4's one-storefront guard with concurrent-create
|
||||
refusal), `POST /api/storefronts`, the Create-storefront and Admin-shell screens, the
|
||||
complete entry-routing rule (landing / create-storefront / admin), and the INV-1
|
||||
whole-flow bootstrap test — all skinned to the Claude Design export
|
||||
(`wiggleverse-ecomm-content/ui/designs/ecomm-login-and-create-storefront-designs/`),
|
||||
which also re-skinned the SLICE-2 Landing + Sign-in screens. See
|
||||
[`docs/BOOTSTRAP.md`](./docs/BOOTSTRAP.md) to run it locally. SLICE-4's deploy
|
||||
contract is in place (versioned `/healthz`, backend-served SPA, `SmtpMailer` with
|
||||
honest 502 on delivery failure); PPE provisioning + the bootstrap rehearsal are the
|
||||
remaining SLICE-4 steps (BOOTSTRAP.md's PPE section is the runbook).
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""accounts domain — identity: account records, one-time-code issue/verify, account lookup.
|
||||
|
||||
Owns INV-2 (email canonical key) and INV-3 (one-time codes handled like secrets). Knows
|
||||
nothing about storefronts (that is the storefronts domain, SLICE-3). Imported via this
|
||||
package surface only (§6.2).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .errors import (
|
||||
AccountsError,
|
||||
CodeExhausted,
|
||||
CodeExpired,
|
||||
CodeMismatch,
|
||||
DeliveryFailed,
|
||||
InvalidEmail,
|
||||
ResendCooldown,
|
||||
)
|
||||
from .models import Account
|
||||
from .service import get_account, request_code, verify
|
||||
|
||||
__all__ = [
|
||||
"Account",
|
||||
"AccountsError",
|
||||
"InvalidEmail",
|
||||
"ResendCooldown",
|
||||
"CodeMismatch",
|
||||
"CodeExpired",
|
||||
"CodeExhausted",
|
||||
"DeliveryFailed",
|
||||
"request_code",
|
||||
"verify",
|
||||
"get_account",
|
||||
]
|
||||
@@ -0,0 +1,42 @@
|
||||
"""accounts — domain exceptions.
|
||||
|
||||
Raised by the service layer; the BFF maps each to the §6.4 error shape. Keeping them here
|
||||
(not in main) keeps the rules in the domain (INV-6).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class AccountsError(Exception):
|
||||
"""Base for accounts-domain errors."""
|
||||
|
||||
|
||||
class InvalidEmail(AccountsError):
|
||||
"""The supplied email is not a plausible address (§6.4 400 invalid_email)."""
|
||||
|
||||
|
||||
class ResendCooldown(AccountsError):
|
||||
"""A code was issued for this email under 60s ago (INV-3 → §6.4 429 resend_cooldown)."""
|
||||
|
||||
def __init__(self, retry_after_s: int) -> None:
|
||||
super().__init__(f"resend available in {retry_after_s}s")
|
||||
self.retry_after_s = retry_after_s
|
||||
|
||||
|
||||
class CodeMismatch(AccountsError):
|
||||
"""The submitted code is wrong (PUC-2a → §6.4 400 code_mismatch)."""
|
||||
|
||||
def __init__(self, attempts_remaining: int) -> None:
|
||||
super().__init__("code did not match")
|
||||
self.attempts_remaining = attempts_remaining
|
||||
|
||||
|
||||
class CodeExpired(AccountsError):
|
||||
"""No live code for this email — expired, consumed, or never issued (§6.4 400 code_expired)."""
|
||||
|
||||
|
||||
class CodeExhausted(AccountsError):
|
||||
"""The code's attempt budget is spent; it is invalidated (INV-3 → §6.4 400 code_exhausted)."""
|
||||
|
||||
|
||||
class DeliveryFailed(AccountsError):
|
||||
"""The relay refused the code email; nothing was committed (INV-9 → §6.4 502 delivery_failed)."""
|
||||
@@ -0,0 +1,12 @@
|
||||
"""accounts — domain models (SD-0001 §6.3)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Account:
|
||||
"""A person's identity on the platform. Email is the canonical key (INV-2)."""
|
||||
|
||||
id: int
|
||||
email: str
|
||||
@@ -0,0 +1,173 @@
|
||||
"""accounts — the identity service (SD-0001 §6.5).
|
||||
|
||||
All identity rules live here exactly once (INV-6): email normalization (INV-2); one-time
|
||||
code issue/verify handled like a secret (INV-3 — hashed with the app secret as pepper,
|
||||
10-minute TTL, single-use, ≤5 attempts, 60s resend cooldown); and get-or-create account.
|
||||
Uniform for new vs known emails so the surface never enumerates accounts (§6.6). Imports
|
||||
only app.platform (downward); takes the connection and mailer as parameters (the BFF wires
|
||||
them) so the domain owns no request/transport concerns.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import re
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import psycopg
|
||||
|
||||
from app.platform import config
|
||||
from app.platform.mailer import Mailer, MailerError
|
||||
|
||||
from .errors import (
|
||||
CodeExhausted,
|
||||
CodeExpired,
|
||||
CodeMismatch,
|
||||
DeliveryFailed,
|
||||
InvalidEmail,
|
||||
ResendCooldown,
|
||||
)
|
||||
from .models import Account
|
||||
|
||||
# INV-3 constants — the one-time-code policy.
|
||||
CODE_TTL = timedelta(minutes=10)
|
||||
RESEND_COOLDOWN = timedelta(seconds=60)
|
||||
MAX_ATTEMPTS = 5
|
||||
_CODE_DIGITS = 6
|
||||
|
||||
# A pragmatic email check — rejects obvious non-addresses without an email-validator
|
||||
# dependency. The provable test of an address is that the emailed code comes back (verify).
|
||||
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||
|
||||
|
||||
def normalize_email(email: str) -> str:
|
||||
"""Lowercase + strip — the canonical form one account is keyed by (INV-2)."""
|
||||
norm = email.strip().lower()
|
||||
if not _EMAIL_RE.match(norm):
|
||||
raise InvalidEmail(email)
|
||||
return norm
|
||||
|
||||
|
||||
def _hash_code(code: str) -> str:
|
||||
"""HMAC-SHA256 of the code, peppered by the app secret (INV-3 — hashes only, not the code)."""
|
||||
key = config.session_secret().encode("utf-8")
|
||||
return hmac.new(key, code.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def request_code(conn: psycopg.Connection, mailer: Mailer, email: str) -> None:
|
||||
"""Issue a one-time code to `email` and dispatch it. Uniform for new/known emails (§6.6).
|
||||
|
||||
Enforces the 60s resend cooldown (INV-3 → PUC-2c). Opportunistically purges this email's
|
||||
spent/expired codes. Never reveals whether the email already has an account.
|
||||
"""
|
||||
email = normalize_email(email)
|
||||
now = _now()
|
||||
|
||||
last = conn.execute(
|
||||
"SELECT max(created_at) FROM auth_code WHERE email = %s AND consumed_at IS NULL",
|
||||
(email,),
|
||||
).fetchone()[0]
|
||||
if last is not None:
|
||||
elapsed = now - last
|
||||
if elapsed < RESEND_COOLDOWN:
|
||||
retry_after_s = int((RESEND_COOLDOWN - elapsed).total_seconds()) + 1
|
||||
raise ResendCooldown(retry_after_s)
|
||||
|
||||
# Housekeeping: drop this email's consumed/expired codes (§6.3) before issuing a fresh one.
|
||||
conn.execute(
|
||||
"DELETE FROM auth_code WHERE email = %s AND (consumed_at IS NOT NULL OR expires_at < %s)",
|
||||
(email, now),
|
||||
)
|
||||
|
||||
code = "".join(secrets.choice("0123456789") for _ in range(_CODE_DIGITS))
|
||||
conn.execute(
|
||||
"INSERT INTO auth_code (email, code_hash, expires_at) VALUES (%s, %s, %s)",
|
||||
(email, _hash_code(code), now + CODE_TTL),
|
||||
)
|
||||
|
||||
# Send BEFORE commit (ecomm#7): a refused delivery rolls the row back, so no orphan
|
||||
# code blocks the 60s cooldown and the caller can honestly retry at once (INV-9).
|
||||
try:
|
||||
mailer.send(
|
||||
to=email,
|
||||
subject=f"Your ecomm code: {code}",
|
||||
body=(
|
||||
f"Your ecomm one-time code is {code}.\n"
|
||||
f"It is valid for {int(CODE_TTL.total_seconds() // 60)} minutes.\n"
|
||||
"If you didn't request this, ignore this message."
|
||||
),
|
||||
)
|
||||
except MailerError as exc:
|
||||
conn.rollback()
|
||||
raise DeliveryFailed(str(exc)) from exc
|
||||
conn.commit()
|
||||
|
||||
|
||||
def verify(conn: psycopg.Connection, email: str, code: str) -> tuple[Account, bool]:
|
||||
"""Verify a code and resolve the account. Returns (account, created).
|
||||
|
||||
Consumes the live code transactionally (single-use, INV-3): wrong codes spend an attempt
|
||||
(PUC-2a) until MAX_ATTEMPTS invalidates it (CodeExhausted); expired/absent codes raise
|
||||
CodeExpired (PUC-2b). On success the code is consumed and the account is got-or-created
|
||||
by normalized email (INV-2). Account creation IS email verification (the address
|
||||
provably received the code).
|
||||
"""
|
||||
email = normalize_email(email)
|
||||
now = _now()
|
||||
|
||||
row = conn.execute(
|
||||
"SELECT id, code_hash, expires_at, attempts FROM auth_code "
|
||||
"WHERE email = %s AND consumed_at IS NULL "
|
||||
"ORDER BY created_at DESC LIMIT 1 FOR UPDATE",
|
||||
(email,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
conn.commit()
|
||||
raise CodeExpired(email) # nothing live to verify — offer a fresh code
|
||||
code_id, code_hash, expires_at, attempts = row
|
||||
|
||||
if expires_at < now:
|
||||
conn.commit()
|
||||
raise CodeExpired(email)
|
||||
|
||||
if not hmac.compare_digest(code_hash, _hash_code(code)):
|
||||
attempts += 1
|
||||
if attempts >= MAX_ATTEMPTS:
|
||||
conn.execute(
|
||||
"UPDATE auth_code SET attempts = %s, consumed_at = %s WHERE id = %s",
|
||||
(attempts, now, code_id),
|
||||
)
|
||||
conn.commit()
|
||||
raise CodeExhausted(email)
|
||||
conn.execute("UPDATE auth_code SET attempts = %s WHERE id = %s", (attempts, code_id))
|
||||
conn.commit()
|
||||
raise CodeMismatch(attempts_remaining=MAX_ATTEMPTS - attempts)
|
||||
|
||||
# Correct: consume the code and get-or-create the account in the same transaction.
|
||||
conn.execute("UPDATE auth_code SET consumed_at = %s WHERE id = %s", (now, code_id))
|
||||
account, created = _get_or_create_account(conn, email)
|
||||
conn.commit()
|
||||
return account, created
|
||||
|
||||
|
||||
def _get_or_create_account(conn: psycopg.Connection, email: str) -> tuple[Account, bool]:
|
||||
"""Insert the account if absent; return (account, created). Race-safe via the unique index."""
|
||||
inserted = conn.execute(
|
||||
"INSERT INTO account (email) VALUES (%s) ON CONFLICT (email) DO NOTHING RETURNING id",
|
||||
(email,),
|
||||
).fetchone()
|
||||
if inserted is not None:
|
||||
return Account(id=inserted[0], email=email), True
|
||||
existing = conn.execute("SELECT id FROM account WHERE email = %s", (email,)).fetchone()
|
||||
return Account(id=existing[0], email=email), False
|
||||
|
||||
|
||||
def get_account(conn: psycopg.Connection, account_id: int) -> Account | None:
|
||||
"""Load an account by id (for the signed-in `/me`)."""
|
||||
row = conn.execute("SELECT id, email FROM account WHERE id = %s", (account_id,)).fetchone()
|
||||
return Account(id=row[0], email=row[1]) if row else None
|
||||
@@ -0,0 +1,41 @@
|
||||
"""products domain — catalog + bulk CSV import/export (SD-0002 §6.2).
|
||||
|
||||
Owns the canonical row model, codec, validation, diff engine, and import
|
||||
drafts/runs. Storefront-scoped throughout (INV-14); upsert is the only mutation
|
||||
(INV-10). Imported via this package surface only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .errors import (
|
||||
DraftExpired,
|
||||
DraftNotFound,
|
||||
FileRejected,
|
||||
NothingToApply,
|
||||
PreviewStale,
|
||||
ProductsError,
|
||||
RunNotFound,
|
||||
)
|
||||
from .models import MAX_DATA_ROWS, MAX_FILE_BYTES
|
||||
from .service import (
|
||||
confirm_draft,
|
||||
discard_draft,
|
||||
get_draft,
|
||||
get_draft_records,
|
||||
get_run,
|
||||
import_validate,
|
||||
list_runs,
|
||||
summary,
|
||||
)
|
||||
|
||||
# DOC-3: the downloadable worked-example CSV the BFF serves at /api/products/sample.csv.
|
||||
SAMPLE_CSV_PATH = Path(__file__).parent / "sample.csv"
|
||||
|
||||
__all__ = [
|
||||
"ProductsError", "FileRejected", "DraftNotFound", "DraftExpired",
|
||||
"PreviewStale", "NothingToApply", "RunNotFound",
|
||||
"MAX_DATA_ROWS", "MAX_FILE_BYTES", "SAMPLE_CSV_PATH",
|
||||
"import_validate", "get_draft", "get_draft_records", "discard_draft",
|
||||
"confirm_draft", "list_runs", "get_run", "summary",
|
||||
]
|
||||
@@ -0,0 +1,65 @@
|
||||
"""CSV codec — bytes → ParsedFile (SD-0002 §6.5.1). File-level gates only (PUC-5a);
|
||||
row semantics live in validate.py. Dialect detection is the INV-17 seam (SLICE-8
|
||||
adds Shopify)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
|
||||
from .errors import FileRejected
|
||||
from .models import KNOWN_COLUMNS, MAX_DATA_ROWS, MAX_FILE_BYTES, ParsedFile, Row
|
||||
|
||||
_REQUIRED_HEADER_COLUMNS = ("Handle", "Title")
|
||||
|
||||
|
||||
def detect_dialect(header: list[str]) -> str:
|
||||
"""The INV-17 seam: SLICE-8 recognizes Shopify's exact header set here."""
|
||||
return "canonical"
|
||||
|
||||
|
||||
def parse_csv(data: bytes) -> ParsedFile:
|
||||
if len(data) > MAX_FILE_BYTES:
|
||||
raise FileRejected("file_too_large", "This file is larger than 10 MB.")
|
||||
try:
|
||||
text = data.decode("utf-8-sig")
|
||||
except UnicodeDecodeError:
|
||||
raise FileRejected("not_csv", "This file isn't readable as CSV.") from None
|
||||
reader = csv.reader(io.StringIO(text))
|
||||
try:
|
||||
try:
|
||||
raw_header = next(reader)
|
||||
except StopIteration:
|
||||
raise FileRejected("not_csv", "This file isn't readable as CSV.") from None
|
||||
header = [h.strip() for h in raw_header]
|
||||
for col in _REQUIRED_HEADER_COLUMNS:
|
||||
if col not in header:
|
||||
raise FileRejected(
|
||||
"missing_required_column",
|
||||
f"This file is missing the required column '{col}'.",
|
||||
)
|
||||
# First occurrence of a duplicated column wins.
|
||||
col_index: dict[str, int] = {}
|
||||
for i, name in enumerate(header):
|
||||
if name and name not in col_index:
|
||||
col_index[name] = i
|
||||
known_present = [c for c in col_index if c in KNOWN_COLUMNS]
|
||||
unknown = [c for c in col_index if c not in KNOWN_COLUMNS]
|
||||
rows: list[Row] = []
|
||||
for raw in reader:
|
||||
if not any(cell.strip() for cell in raw):
|
||||
continue
|
||||
if len(rows) >= MAX_DATA_ROWS:
|
||||
raise FileRejected(
|
||||
"too_many_rows",
|
||||
f"This file has more than {MAX_DATA_ROWS:,} rows — split it and import in parts.",
|
||||
)
|
||||
cells = {
|
||||
c: (raw[col_index[c]].strip() if col_index[c] < len(raw) else "")
|
||||
for c in known_present
|
||||
}
|
||||
rows.append(Row(line_number=reader.line_num, cells=cells))
|
||||
except csv.Error:
|
||||
raise FileRejected("not_csv", "This file isn't readable as CSV.") from None
|
||||
return ParsedFile(
|
||||
dialect=detect_dialect(header), header=header, unknown_columns=unknown, rows=rows
|
||||
)
|
||||
@@ -0,0 +1,322 @@
|
||||
"""Diff engine — catalog × canonical products → apply plan + preview records (SD-0002 §6.5.2).
|
||||
|
||||
Classifies each canonical product against the storefront's current catalog as
|
||||
add / update / unchanged / error. One walk produces two views of the same
|
||||
computation: a typed apply *plan* carrying resolved native values (Decimal etc.)
|
||||
for the confirm transaction, and JSON-ready preview records derived from that
|
||||
walk (stored as draft JSONB, served verbatim to the SPA) — so what confirm
|
||||
applies is exactly what preview showed (INV-11). A summary and a deterministic
|
||||
fingerprint over the records detect catalog drift between preview and confirm.
|
||||
|
||||
Deliberately DB-free: the catalog snapshot dataclasses are defined here and the
|
||||
repo layer builds them. Only fields present in the file's canonical fields{}
|
||||
participate in a comparison — an absent column is untouched, never a change
|
||||
(§6.5.1); a present-but-empty cell resolves to the field's CLEAR_DEFAULTS entry
|
||||
— except a variant's position, whose default is the variant's 1-based file
|
||||
order within its product ("defaults to file order"), resolved here at diff time.
|
||||
Catalog variants/images absent from the file are likewise untouched (INV-10);
|
||||
file variants match catalog variants by their option-value combination (INV-13).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal
|
||||
|
||||
from .models import CLEAR_DEFAULTS, CanonicalProduct, CanonicalVariant
|
||||
|
||||
|
||||
@dataclass
|
||||
class CatalogVariant:
|
||||
id: int
|
||||
options: tuple[str | None, str | None, str | None]
|
||||
position: int
|
||||
# sku, barcode, price (Decimal|None), cost, weight, weight_unit, volume,
|
||||
# volume_unit, tax_id_1, tax_id_2, inventory_tracker, inventory_qty,
|
||||
# variant_image (linked image source_url or None)
|
||||
fields: dict[str, object]
|
||||
|
||||
|
||||
@dataclass
|
||||
class CatalogImage:
|
||||
id: int
|
||||
source_url: str
|
||||
position: int
|
||||
alt_text: str | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CatalogProduct:
|
||||
id: int
|
||||
handle: str
|
||||
title: str
|
||||
option_names: tuple[str | None, str | None, str | None]
|
||||
# title, description_html, vendor, product_type, google_product_category,
|
||||
# tags (list[str]), status, published (bool)
|
||||
fields: dict[str, object]
|
||||
variants: list[CatalogVariant]
|
||||
images: list[CatalogImage]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Apply plan — the typed twin of the preview records. confirm_draft executes
|
||||
# these; the values are resolved natives (Decimal, bool, list), never the
|
||||
# json-safe strings the records carry for display.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class VariantPlan:
|
||||
kind: str # "add" | "update"
|
||||
canonical: CanonicalVariant
|
||||
catalog_id: int | None # None for add
|
||||
# 1-based index within the product's file variants — the position default.
|
||||
file_order: int = 0
|
||||
# field -> resolved after-value (update only); adds resolve from canonical.fields
|
||||
changes: dict[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImagePlan:
|
||||
kind: str # "add" | "update"
|
||||
source_url: str
|
||||
position: int
|
||||
alt_text: str | None
|
||||
image_id: int | None # None for add
|
||||
changes: dict[str, object] = field(default_factory=dict) # subset of {"position","alt_text"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProductPlan:
|
||||
kind: str # "add" | "update" | "unchanged" | "error"
|
||||
canonical: CanonicalProduct
|
||||
catalog: CatalogProduct | None
|
||||
# field -> resolved after-value (update only; may include title/option*_name)
|
||||
product_changes: dict[str, object] = field(default_factory=dict)
|
||||
variant_plans: list[VariantPlan] = field(default_factory=list)
|
||||
image_plans: list[ImagePlan] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DiffResult:
|
||||
records: list[dict]
|
||||
summary: dict
|
||||
fingerprint: str
|
||||
plan: list[ProductPlan]
|
||||
|
||||
|
||||
# Option-name presence markers in fields{} (see validate.py); the values are
|
||||
# compared via the option_names attribute, not through the generic field loop.
|
||||
_OPTION_NAME_FIELDS = ("option1_name", "option2_name", "option3_name")
|
||||
_SUMMARY_KEY = {"add": "adds", "update": "updates", "unchanged": "unchanged", "error": "errors"}
|
||||
|
||||
|
||||
def compute_diff(catalog: dict[str, CatalogProduct], products: list[CanonicalProduct]) -> DiffResult:
|
||||
records: list[dict] = []
|
||||
plan: list[ProductPlan] = []
|
||||
summary = {"adds": 0, "updates": 0, "unchanged": 0, "errors": 0}
|
||||
for product in products:
|
||||
current = catalog.get(product.handle)
|
||||
if product.errors:
|
||||
product_plan = ProductPlan(kind="error", canonical=product, catalog=current)
|
||||
detail: dict = {"errors": [e.as_json() for e in product.errors]}
|
||||
elif current is None:
|
||||
product_plan = _add_plan(product)
|
||||
detail = _add_detail(product_plan)
|
||||
else:
|
||||
product_plan, detail = _update_plan(product, current)
|
||||
kind = product_plan.kind
|
||||
plan.append(product_plan)
|
||||
records.append(
|
||||
{
|
||||
"handle": product.handle,
|
||||
"title": product.title or (current.title if current else ""),
|
||||
"kind": kind,
|
||||
"variant_count": len(current.variants) if kind == "unchanged" else len(product.variants),
|
||||
"detail": detail,
|
||||
}
|
||||
)
|
||||
summary[_SUMMARY_KEY[kind]] += 1
|
||||
fingerprint = hashlib.sha256(
|
||||
json.dumps(records, sort_keys=True, separators=(",", ":")).encode()
|
||||
).hexdigest()
|
||||
return DiffResult(records=records, summary=summary, fingerprint=fingerprint, plan=plan)
|
||||
|
||||
|
||||
def _add_plan(product: CanonicalProduct) -> ProductPlan:
|
||||
return ProductPlan(
|
||||
kind="add",
|
||||
canonical=product,
|
||||
catalog=None,
|
||||
variant_plans=[
|
||||
VariantPlan(kind="add", canonical=v, catalog_id=None, file_order=order)
|
||||
for order, v in enumerate(product.variants, start=1)
|
||||
],
|
||||
image_plans=[
|
||||
ImagePlan(kind="add", source_url=i.source_url, position=i.position,
|
||||
alt_text=i.alt_text, image_id=None)
|
||||
for i in product.images
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _add_detail(plan: ProductPlan) -> dict:
|
||||
# On add, absent fields fall back to their defaults for display where one
|
||||
# exists — the detail shows what will actually be set.
|
||||
set_fields = resolved_product_fields(plan.canonical)
|
||||
for field_name, default in CLEAR_DEFAULTS.items():
|
||||
set_fields.setdefault(field_name, default)
|
||||
return {
|
||||
"set": {f: _json_safe(v) for f, v in set_fields.items()},
|
||||
"option_names": list(plan.canonical.option_names),
|
||||
"variants": [
|
||||
{"options": list(vp.canonical.options),
|
||||
"set": _resolved_variant_fields(vp.canonical, vp.file_order)}
|
||||
for vp in plan.variant_plans
|
||||
],
|
||||
"images": [
|
||||
{"src": ip.source_url, "position": ip.position, "alt_text": ip.alt_text}
|
||||
for ip in plan.image_plans
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _update_plan(product: CanonicalProduct, current: CatalogProduct) -> tuple[ProductPlan, dict]:
|
||||
"""One walk, two outputs: the resolved-value plan entries and the json-safe
|
||||
record detail entries are appended side by side, so they can never diverge."""
|
||||
product_changes: dict[str, object] = {}
|
||||
changes: list[dict] = []
|
||||
# Title is always file-present (required header column); "" means the block
|
||||
# already carries an error and never reaches here.
|
||||
if product.title and product.title != current.title:
|
||||
product_changes["title"] = product.title
|
||||
changes.append(_change("title", current.title, product.title))
|
||||
for field_name, resolved in resolved_product_fields(product).items():
|
||||
before = current.fields.get(field_name)
|
||||
if resolved != before:
|
||||
product_changes[field_name] = resolved
|
||||
changes.append(_change(field_name, before, resolved))
|
||||
for slot in (1, 2, 3):
|
||||
if f"option{slot}_name" not in product.fields:
|
||||
continue
|
||||
before, after = current.option_names[slot - 1], product.option_names[slot - 1]
|
||||
if after != before:
|
||||
product_changes[f"option{slot}_name"] = after
|
||||
changes.append(_change(f"option{slot}_name", before, after))
|
||||
|
||||
variant_plans: list[VariantPlan] = []
|
||||
variant_entries: list[dict] = []
|
||||
by_options = {v.options: v for v in current.variants}
|
||||
for file_order, variant in enumerate(product.variants, start=1):
|
||||
match = by_options.get(variant.options)
|
||||
if match is None:
|
||||
variant_plans.append(
|
||||
VariantPlan(kind="add", canonical=variant, catalog_id=None, file_order=file_order)
|
||||
)
|
||||
variant_entries.append(
|
||||
{"options": list(variant.options), "kind": "add",
|
||||
"set": _resolved_variant_fields(variant, file_order)}
|
||||
)
|
||||
continue
|
||||
variant_changes: dict[str, object] = {}
|
||||
variant_change_entries: list[dict] = []
|
||||
for f, resolved in resolved_variant_fields(variant, file_order).items():
|
||||
# position lives on the catalog variant as an attribute, not in
|
||||
# fields{} — compare it explicitly (as images do for theirs).
|
||||
before = match.position if f == "position" else match.fields.get(f)
|
||||
if resolved != before:
|
||||
variant_changes[f] = resolved
|
||||
variant_change_entries.append(_change(f, before, resolved))
|
||||
if variant_changes:
|
||||
variant_plans.append(
|
||||
VariantPlan(kind="update", canonical=variant, catalog_id=match.id,
|
||||
file_order=file_order, changes=variant_changes)
|
||||
)
|
||||
variant_entries.append(
|
||||
{"options": list(variant.options), "kind": "update", "changes": variant_change_entries}
|
||||
)
|
||||
|
||||
image_plans: list[ImagePlan] = []
|
||||
image_entries: list[dict] = []
|
||||
by_src = {i.source_url: i for i in current.images}
|
||||
for image in product.images:
|
||||
match = by_src.get(image.source_url)
|
||||
if match is None:
|
||||
image_plans.append(
|
||||
ImagePlan(kind="add", source_url=image.source_url, position=image.position,
|
||||
alt_text=image.alt_text, image_id=None)
|
||||
)
|
||||
image_entries.append(
|
||||
{"src": image.source_url, "kind": "add", "position": image.position, "alt_text": image.alt_text}
|
||||
)
|
||||
continue
|
||||
image_changes: dict[str, object] = {}
|
||||
image_change_entries: list[dict] = []
|
||||
for f, before, after in (
|
||||
("position", match.position, image.position),
|
||||
("alt_text", match.alt_text, image.alt_text),
|
||||
):
|
||||
if after != before:
|
||||
image_changes[f] = after
|
||||
image_change_entries.append(_change(f, before, after))
|
||||
if image_changes:
|
||||
image_plans.append(
|
||||
ImagePlan(kind="update", source_url=image.source_url, position=image.position,
|
||||
alt_text=image.alt_text, image_id=match.id, changes=image_changes)
|
||||
)
|
||||
image_entries.append(
|
||||
{"src": image.source_url, "kind": "update", "changes": image_change_entries}
|
||||
)
|
||||
|
||||
detail: dict = {}
|
||||
if changes:
|
||||
detail["changes"] = changes
|
||||
if variant_entries:
|
||||
detail["variants"] = variant_entries
|
||||
if image_entries:
|
||||
detail["images"] = image_entries
|
||||
kind = "update" if detail else "unchanged"
|
||||
return (
|
||||
ProductPlan(kind=kind, canonical=product, catalog=current, product_changes=product_changes,
|
||||
variant_plans=variant_plans, image_plans=image_plans),
|
||||
detail,
|
||||
)
|
||||
|
||||
|
||||
def resolved_fields(fields: dict[str, object]) -> dict[str, object]:
|
||||
"""File-present fields with None (an explicit clear) resolved to the default."""
|
||||
return {f: (v if v is not None else CLEAR_DEFAULTS.get(f)) for f, v in fields.items()}
|
||||
|
||||
|
||||
def resolved_product_fields(product: CanonicalProduct) -> dict[str, object]:
|
||||
"""Resolved product-level fields, minus the option-name presence markers."""
|
||||
return {
|
||||
f: v for f, v in resolved_fields(product.fields).items() if f not in _OPTION_NAME_FIELDS
|
||||
}
|
||||
|
||||
|
||||
def resolved_variant_fields(variant: CanonicalVariant, file_order: int) -> dict[str, object]:
|
||||
"""File-present variant fields with clears resolved. A cleared position has
|
||||
no CLEAR_DEFAULTS entry — it resets to the variant's 1-based file order
|
||||
within its product ("defaults to file order"), never to NULL."""
|
||||
resolved = resolved_fields(variant.fields)
|
||||
if "position" in resolved and resolved["position"] is None:
|
||||
resolved["position"] = file_order
|
||||
return resolved
|
||||
|
||||
|
||||
def _resolved_variant_fields(variant: CanonicalVariant, file_order: int) -> dict[str, object]:
|
||||
return {f: _json_safe(v) for f, v in resolved_variant_fields(variant, file_order).items()}
|
||||
|
||||
|
||||
def _change(field_name: str, before: object, after: object) -> dict:
|
||||
return {"field": field_name, "before": _json_safe(before), "after": _json_safe(after)}
|
||||
|
||||
|
||||
def _json_safe(value: object) -> object:
|
||||
if isinstance(value, Decimal):
|
||||
return str(value)
|
||||
if isinstance(value, (tuple, list)):
|
||||
return [_json_safe(v) for v in value]
|
||||
return value
|
||||
@@ -0,0 +1,37 @@
|
||||
"""products domain errors (SD-0002 §6.4 error envelope codes)."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class ProductsError(Exception):
|
||||
"""Base for products-domain errors."""
|
||||
|
||||
|
||||
class FileRejected(ProductsError):
|
||||
"""PUC-5a: the whole file is unusable; no draft is created. `code` is the §6.4
|
||||
error code (not_csv | missing_required_column | unknown_dialect | too_many_rows |
|
||||
file_too_large)."""
|
||||
|
||||
def __init__(self, code: str, message: str):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
|
||||
|
||||
class DraftNotFound(ProductsError):
|
||||
"""No such draft for this storefront (or already discarded)."""
|
||||
|
||||
|
||||
class DraftExpired(ProductsError):
|
||||
"""The draft's validity window passed (§6.3 ~1 h)."""
|
||||
|
||||
|
||||
class PreviewStale(ProductsError):
|
||||
"""INV-11: the catalog changed since validation — the previewed diff no longer holds."""
|
||||
|
||||
|
||||
class NothingToApply(ProductsError):
|
||||
"""PUC-10: no adds and no updates — confirming would be a no-op."""
|
||||
|
||||
|
||||
class RunNotFound(ProductsError):
|
||||
"""No such import run for this storefront."""
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Canonical row model + column registry — the one model every dialect maps to (INV-17)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
# §6.5.1 canonical columns, by level. Header detection, unknown-column warnings, and
|
||||
# validation all read from this registry.
|
||||
PRODUCT_COLUMNS: dict[str, str] = {
|
||||
# column -> product field name
|
||||
"Title": "title",
|
||||
"Description": "description_html",
|
||||
"Vendor": "vendor",
|
||||
"Type": "product_type",
|
||||
"Google Product Category": "google_product_category",
|
||||
"Tags": "tags",
|
||||
"Status": "status",
|
||||
"Published": "published",
|
||||
"Option1 Name": "option1_name",
|
||||
"Option2 Name": "option2_name",
|
||||
"Option3 Name": "option3_name",
|
||||
}
|
||||
VARIANT_COLUMNS: dict[str, str] = {
|
||||
"Variant SKU": "sku",
|
||||
"Variant Barcode": "barcode",
|
||||
"Variant Price": "price",
|
||||
"Variant Cost": "cost",
|
||||
"Variant Weight": "weight",
|
||||
"Variant Weight Unit": "weight_unit",
|
||||
"Variant Volume": "volume",
|
||||
"Variant Volume Unit": "volume_unit",
|
||||
"Variant Tax ID 1": "tax_id_1",
|
||||
"Variant Tax ID 2": "tax_id_2",
|
||||
"Variant Inventory Tracker": "inventory_tracker",
|
||||
"Variant Inventory Qty": "inventory_qty",
|
||||
"Variant Position": "position",
|
||||
"Variant Image": "variant_image",
|
||||
}
|
||||
OPTION_VALUE_COLUMNS = ("Option1 Value", "Option2 Value", "Option3 Value")
|
||||
IMAGE_COLUMNS = ("Image Src", "Image Position", "Image Alt Text")
|
||||
COMPONENT_COLUMNS = tuple(
|
||||
f"Component {i} {kind}" for i in range(1, 11) for kind in ("SKU", "Quantity")
|
||||
)
|
||||
KNOWN_COLUMNS = (
|
||||
{"Handle"}
|
||||
| set(PRODUCT_COLUMNS)
|
||||
| set(VARIANT_COLUMNS)
|
||||
| set(OPTION_VALUE_COLUMNS)
|
||||
| set(IMAGE_COLUMNS)
|
||||
| set(COMPONENT_COLUMNS)
|
||||
)
|
||||
|
||||
# Clearing a field (present-but-empty cell, §6.5.1) resets it to its default.
|
||||
CLEAR_DEFAULTS: dict[str, object] = {
|
||||
"status": "active",
|
||||
"published": True,
|
||||
"product_type": "standalone",
|
||||
"tags": [],
|
||||
}
|
||||
|
||||
MAX_DATA_ROWS = 5_000 # INV-18
|
||||
MAX_FILE_BYTES = 10 * 1024 * 1024 # INV-18
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Row:
|
||||
"""One CSV data row: 1-based file line number + the cells of known columns
|
||||
present in the header (column name -> raw string, possibly empty)."""
|
||||
|
||||
line_number: int
|
||||
cells: dict[str, str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParsedFile:
|
||||
dialect: str
|
||||
header: list[str]
|
||||
unknown_columns: list[str]
|
||||
rows: list[Row]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RowError:
|
||||
line_number: int
|
||||
column: str | None
|
||||
message: str
|
||||
|
||||
def as_json(self) -> dict:
|
||||
return {"line": self.line_number, "column": self.column, "message": self.message}
|
||||
|
||||
|
||||
@dataclass
|
||||
class CanonicalVariant:
|
||||
line_number: int
|
||||
options: tuple[str | None, str | None, str | None]
|
||||
# field name -> normalized value; present only for columns in the file.
|
||||
# value None == clear (reset to default/NULL).
|
||||
fields: dict[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CanonicalImage:
|
||||
line_number: int
|
||||
source_url: str
|
||||
position: int
|
||||
alt_text: str | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CanonicalProduct:
|
||||
first_line: int
|
||||
handle: str
|
||||
title: str # "" when missing (the block then carries an error)
|
||||
option_names: tuple[str | None, str | None, str | None] = (None, None, None)
|
||||
fields: dict[str, object] = field(default_factory=dict) # product-level, same semantics
|
||||
variants: list[CanonicalVariant] = field(default_factory=list)
|
||||
images: list[CanonicalImage] = field(default_factory=list)
|
||||
errors: list[RowError] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def valid(self) -> bool:
|
||||
return not self.errors
|
||||
@@ -0,0 +1,479 @@
|
||||
"""products repo — the SQL layer for the import spine (SD-0002 §6.3 data model).
|
||||
|
||||
Owns SQL only: the catalog snapshot the diff engine reads, import draft/run
|
||||
CRUD, and the apply primitives the confirm transaction calls. Business rules
|
||||
live in service.py and diff.py — nothing here validates, diffs, commits, or
|
||||
rolls back (the confirm flow runs the apply primitives inside its own
|
||||
transaction). Every catalog/draft/run query is storefront-scoped (INV-14).
|
||||
|
||||
Dict payload conventions: functions feeding §6.4 API payloads (insert_draft,
|
||||
list_runs, get_run) return datetimes as `.isoformat()` strings; get_draft_row
|
||||
returns raw datetimes for the service's expiry check. TEXT[] columns bind/load
|
||||
as Python lists and NUMERIC loads as Decimal natively under psycopg 3.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import psycopg
|
||||
from psycopg import sql
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from .diff import CatalogImage, CatalogProduct, CatalogVariant
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Catalog snapshot (diff input) + dashboard counts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def load_catalog(conn: psycopg.Connection, storefront_id: int) -> dict[str, CatalogProduct]:
|
||||
"""The storefront's full catalog, keyed by handle, in diff.py's snapshot shape."""
|
||||
catalog: dict[str, CatalogProduct] = {}
|
||||
by_id: dict[int, CatalogProduct] = {}
|
||||
for row in conn.execute(
|
||||
"SELECT id, handle, title, description_html, vendor, product_type,"
|
||||
" google_product_category, tags, status, published,"
|
||||
" option1_name, option2_name, option3_name"
|
||||
" FROM product WHERE storefront_id = %s",
|
||||
(storefront_id,),
|
||||
):
|
||||
product = CatalogProduct(
|
||||
id=row[0],
|
||||
handle=row[1],
|
||||
title=row[2],
|
||||
option_names=(row[10], row[11], row[12]),
|
||||
fields={
|
||||
"title": row[2],
|
||||
"description_html": row[3],
|
||||
"vendor": row[4],
|
||||
"product_type": row[5],
|
||||
"google_product_category": row[6],
|
||||
"tags": row[7],
|
||||
"status": row[8],
|
||||
"published": row[9],
|
||||
},
|
||||
variants=[],
|
||||
images=[],
|
||||
)
|
||||
catalog[product.handle] = product
|
||||
by_id[product.id] = product
|
||||
for row in conn.execute(
|
||||
"SELECT v.product_id, v.id, v.position,"
|
||||
" v.option1_value, v.option2_value, v.option3_value,"
|
||||
" v.sku, v.barcode, v.price, v.cost, v.weight, v.weight_unit,"
|
||||
" v.volume, v.volume_unit, v.tax_id_1, v.tax_id_2,"
|
||||
" v.inventory_tracker, v.inventory_qty, i.source_url"
|
||||
" FROM variant v"
|
||||
" JOIN product p ON p.id = v.product_id"
|
||||
" LEFT JOIN product_image i ON i.id = v.image_id"
|
||||
" WHERE p.storefront_id = %s"
|
||||
" ORDER BY v.product_id, v.position, v.id",
|
||||
(storefront_id,),
|
||||
):
|
||||
by_id[row[0]].variants.append(
|
||||
CatalogVariant(
|
||||
id=row[1],
|
||||
options=(row[3], row[4], row[5]),
|
||||
position=row[2],
|
||||
fields={
|
||||
"sku": row[6],
|
||||
"barcode": row[7],
|
||||
"price": row[8],
|
||||
"cost": row[9],
|
||||
"weight": row[10],
|
||||
"weight_unit": row[11],
|
||||
"volume": row[12],
|
||||
"volume_unit": row[13],
|
||||
"tax_id_1": row[14],
|
||||
"tax_id_2": row[15],
|
||||
"inventory_tracker": row[16],
|
||||
"inventory_qty": row[17],
|
||||
"variant_image": row[18],
|
||||
},
|
||||
)
|
||||
)
|
||||
for row in conn.execute(
|
||||
"SELECT i.product_id, i.id, i.source_url, i.position, i.alt_text"
|
||||
" FROM product_image i"
|
||||
" JOIN product p ON p.id = i.product_id"
|
||||
" WHERE p.storefront_id = %s"
|
||||
" ORDER BY i.product_id, i.position, i.id",
|
||||
(storefront_id,),
|
||||
):
|
||||
by_id[row[0]].images.append(
|
||||
CatalogImage(id=row[1], source_url=row[2], position=row[3], alt_text=row[4])
|
||||
)
|
||||
return catalog
|
||||
|
||||
|
||||
def product_count(conn: psycopg.Connection, storefront_id: int) -> int:
|
||||
return conn.execute(
|
||||
"SELECT count(*) FROM product WHERE storefront_id = %s", (storefront_id,)
|
||||
).fetchone()[0]
|
||||
|
||||
|
||||
def image_problem_count(conn: psycopg.Connection, storefront_id: int) -> int:
|
||||
return conn.execute(
|
||||
"SELECT count(*) FROM product_image i"
|
||||
" JOIN product p ON p.id = i.product_id"
|
||||
" WHERE p.storefront_id = %s"
|
||||
" AND i.status IN ('rejected_low_res', 'rejected_not_image', 'failed')",
|
||||
(storefront_id,),
|
||||
).fetchone()[0]
|
||||
|
||||
|
||||
def latest_run_id(conn: psycopg.Connection, storefront_id: int) -> int | None:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM import_run WHERE storefront_id = %s"
|
||||
" ORDER BY created_at DESC, id DESC LIMIT 1",
|
||||
(storefront_id,),
|
||||
).fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Import drafts (preview server side, INV-11)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def insert_draft(
|
||||
conn: psycopg.Connection,
|
||||
storefront_id: int,
|
||||
account_id: int,
|
||||
file_name: str,
|
||||
dialect: str,
|
||||
file_bytes: bytes,
|
||||
summary: dict,
|
||||
records: list,
|
||||
fingerprint: str,
|
||||
unknown_columns: list[str],
|
||||
) -> dict:
|
||||
"""Create a draft (expires in 1 hour); returns the §6.4 draft payload."""
|
||||
row = conn.execute(
|
||||
"INSERT INTO import_draft"
|
||||
" (storefront_id, account_id, file_name, dialect, file_bytes,"
|
||||
" summary, records, fingerprint, unknown_columns, expires_at)"
|
||||
" VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, now() + interval '1 hour')"
|
||||
" RETURNING id, expires_at",
|
||||
(
|
||||
storefront_id,
|
||||
account_id,
|
||||
file_name,
|
||||
dialect,
|
||||
file_bytes,
|
||||
Jsonb(summary),
|
||||
Jsonb(records),
|
||||
fingerprint,
|
||||
unknown_columns,
|
||||
),
|
||||
).fetchone()
|
||||
return {
|
||||
"id": row[0],
|
||||
"file_name": file_name,
|
||||
"dialect": dialect,
|
||||
"summary": summary,
|
||||
"unknown_columns": unknown_columns,
|
||||
"expires_at": row[1].isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def get_draft_row(conn: psycopg.Connection, storefront_id: int, draft_id: int) -> dict | None:
|
||||
row = conn.execute(
|
||||
"SELECT id, storefront_id, account_id, file_name, dialect, file_bytes,"
|
||||
" summary, records, fingerprint, unknown_columns, expires_at, created_at"
|
||||
" FROM import_draft WHERE id = %s AND storefront_id = %s",
|
||||
(draft_id, storefront_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
columns = (
|
||||
"id",
|
||||
"storefront_id",
|
||||
"account_id",
|
||||
"file_name",
|
||||
"dialect",
|
||||
"file_bytes",
|
||||
"summary",
|
||||
"records",
|
||||
"fingerprint",
|
||||
"unknown_columns",
|
||||
"expires_at",
|
||||
"created_at",
|
||||
)
|
||||
record = dict(zip(columns, row))
|
||||
# BYTEA loads as memoryview; the service expects bytes.
|
||||
record["file_bytes"] = bytes(record["file_bytes"])
|
||||
return record
|
||||
|
||||
|
||||
def draft_records(
|
||||
conn: psycopg.Connection,
|
||||
storefront_id: int,
|
||||
draft_id: int,
|
||||
kind: str | None,
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> list[dict]:
|
||||
"""The draft's preview records, order-preserving, optionally filtered by kind."""
|
||||
rows = conn.execute(
|
||||
"SELECT rec FROM import_draft d,"
|
||||
" jsonb_array_elements(d.records) WITH ORDINALITY AS r(rec, ord)"
|
||||
" WHERE d.id = %(draft_id)s AND d.storefront_id = %(storefront_id)s"
|
||||
" AND (%(kind)s::text IS NULL OR rec->>'kind' = %(kind)s)"
|
||||
" ORDER BY ord LIMIT %(limit)s OFFSET %(offset)s",
|
||||
{
|
||||
"draft_id": draft_id,
|
||||
"storefront_id": storefront_id,
|
||||
"kind": kind,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
},
|
||||
).fetchall()
|
||||
return [row[0] for row in rows]
|
||||
|
||||
|
||||
def delete_draft(conn: psycopg.Connection, storefront_id: int, draft_id: int) -> None:
|
||||
conn.execute(
|
||||
"DELETE FROM import_draft WHERE id = %s AND storefront_id = %s",
|
||||
(draft_id, storefront_id),
|
||||
)
|
||||
|
||||
|
||||
def sweep_expired_drafts(conn: psycopg.Connection) -> None:
|
||||
conn.execute("DELETE FROM import_draft WHERE expires_at < now()")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Import runs (history, PUC-8)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_TERMINAL_RUN_STATUSES = ("complete", "complete_with_problems")
|
||||
|
||||
|
||||
def insert_run(
|
||||
conn: psycopg.Connection,
|
||||
storefront_id: int,
|
||||
account_id: int,
|
||||
file_name: str,
|
||||
dialect: str,
|
||||
added: int,
|
||||
updated: int,
|
||||
errored: int,
|
||||
status: str,
|
||||
) -> int:
|
||||
return conn.execute(
|
||||
"INSERT INTO import_run"
|
||||
" (storefront_id, account_id, file_name, dialect,"
|
||||
" products_added, products_updated, rows_errored, status, completed_at)"
|
||||
" VALUES (%s, %s, %s, %s, %s, %s, %s, %s, CASE WHEN %s THEN now() END)"
|
||||
" RETURNING id",
|
||||
(
|
||||
storefront_id,
|
||||
account_id,
|
||||
file_name,
|
||||
dialect,
|
||||
added,
|
||||
updated,
|
||||
errored,
|
||||
status,
|
||||
status in _TERMINAL_RUN_STATUSES,
|
||||
),
|
||||
).fetchone()[0]
|
||||
|
||||
|
||||
def insert_run_errors(conn: psycopg.Connection, run_id: int, errors: list[dict]) -> None:
|
||||
"""Record per-row errors (RowError.as_json shape: line/column/message)."""
|
||||
if not errors:
|
||||
return
|
||||
with conn.cursor() as cur:
|
||||
cur.executemany(
|
||||
"INSERT INTO import_run_error (run_id, line_number, column_name, message)"
|
||||
" VALUES (%s, %s, %s, %s)",
|
||||
[(run_id, e["line"], e["column"], e["message"]) for e in errors],
|
||||
)
|
||||
|
||||
|
||||
_RUN_SELECT = (
|
||||
"SELECT r.id, r.file_name, r.dialect, r.created_at, r.completed_at, r.status,"
|
||||
" a.email, r.products_added, r.products_updated, r.rows_errored"
|
||||
" FROM import_run r JOIN account a ON a.id = r.account_id"
|
||||
)
|
||||
|
||||
|
||||
def _run_dict(row: tuple) -> dict:
|
||||
return {
|
||||
"id": row[0],
|
||||
"file_name": row[1],
|
||||
"dialect": row[2],
|
||||
"created_at": row[3].isoformat(),
|
||||
"completed_at": row[4].isoformat() if row[4] is not None else None,
|
||||
"status": row[5],
|
||||
"by": row[6],
|
||||
"products_added": row[7],
|
||||
"products_updated": row[8],
|
||||
"rows_errored": row[9],
|
||||
}
|
||||
|
||||
|
||||
def list_runs(
|
||||
conn: psycopg.Connection, storefront_id: int, limit: int, offset: int
|
||||
) -> list[dict]:
|
||||
rows = conn.execute(
|
||||
_RUN_SELECT
|
||||
+ " WHERE r.storefront_id = %s ORDER BY r.created_at DESC, r.id DESC"
|
||||
" LIMIT %s OFFSET %s",
|
||||
(storefront_id, limit, offset),
|
||||
).fetchall()
|
||||
return [_run_dict(row) for row in rows]
|
||||
|
||||
|
||||
def get_run(conn: psycopg.Connection, storefront_id: int, run_id: int) -> dict | None:
|
||||
row = conn.execute(
|
||||
_RUN_SELECT + " WHERE r.storefront_id = %s AND r.id = %s",
|
||||
(storefront_id, run_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
run = _run_dict(row)
|
||||
run["errors"] = [
|
||||
{"line": line, "column": column, "message": message}
|
||||
for line, column, message in conn.execute(
|
||||
"SELECT line_number, column_name, message FROM import_run_error"
|
||||
" WHERE run_id = %s ORDER BY line_number, id",
|
||||
(run_id,),
|
||||
)
|
||||
]
|
||||
# SLICE-7 fills these; the §6.4 payload shape is stable from SLICE-5 on.
|
||||
run["image_progress"] = {"done": 0, "total": 0}
|
||||
run["image_outcomes"] = []
|
||||
return run
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Apply primitives — called inside the confirm transaction (Task 8); no commits.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def insert_product(
|
||||
conn: psycopg.Connection,
|
||||
storefront_id: int,
|
||||
handle: str,
|
||||
resolved_fields: dict,
|
||||
option_names: tuple[str | None, str | None, str | None],
|
||||
) -> int:
|
||||
"""INSERT with only the file-present fields; absent ones take column defaults."""
|
||||
columns = ["storefront_id", "handle", "option1_name", "option2_name", "option3_name"]
|
||||
values: list[object] = [storefront_id, handle, *option_names]
|
||||
for field_name, value in resolved_fields.items():
|
||||
columns.append(field_name)
|
||||
values.append(value)
|
||||
query = sql.SQL("INSERT INTO product ({}) VALUES ({}) RETURNING id").format(
|
||||
sql.SQL(", ").join(sql.Identifier(c) for c in columns),
|
||||
sql.SQL(", ").join(sql.Placeholder() for _ in columns),
|
||||
)
|
||||
return conn.execute(query, values).fetchone()[0]
|
||||
|
||||
|
||||
def update_product(conn: psycopg.Connection, product_id: int, changed_fields: dict) -> None:
|
||||
if not changed_fields:
|
||||
return
|
||||
assignments = [
|
||||
sql.SQL("{} = {}").format(sql.Identifier(f), sql.Placeholder())
|
||||
for f in changed_fields
|
||||
]
|
||||
query = sql.SQL("UPDATE product SET {}, updated_at = now() WHERE id = {}").format(
|
||||
sql.SQL(", ").join(assignments), sql.Placeholder()
|
||||
)
|
||||
conn.execute(query, [*changed_fields.values(), product_id])
|
||||
|
||||
|
||||
def insert_variant(
|
||||
conn: psycopg.Connection,
|
||||
product_id: int,
|
||||
position: int,
|
||||
options: tuple[str | None, str | None, str | None],
|
||||
resolved_fields: dict,
|
||||
image_id: int | None,
|
||||
) -> int:
|
||||
# variant_image is not a column — the caller translates it to image_id; position
|
||||
# is the explicit param. Filter both defensively.
|
||||
fields = {
|
||||
k: v for k, v in resolved_fields.items() if k != "variant_image" and k != "position"
|
||||
}
|
||||
columns = [
|
||||
"product_id",
|
||||
"position",
|
||||
"option1_value",
|
||||
"option2_value",
|
||||
"option3_value",
|
||||
"image_id",
|
||||
]
|
||||
values: list[object] = [product_id, position, *options, image_id]
|
||||
for field_name, value in fields.items():
|
||||
columns.append(field_name)
|
||||
values.append(value)
|
||||
query = sql.SQL("INSERT INTO variant ({}) VALUES ({}) RETURNING id").format(
|
||||
sql.SQL(", ").join(sql.Identifier(c) for c in columns),
|
||||
sql.SQL(", ").join(sql.Placeholder() for _ in columns),
|
||||
)
|
||||
return conn.execute(query, values).fetchone()[0]
|
||||
|
||||
|
||||
def update_variant(
|
||||
conn: psycopg.Connection,
|
||||
variant_id: int,
|
||||
changed_fields: dict,
|
||||
image_id: int | None | type(...) = ...,
|
||||
) -> None:
|
||||
"""Dynamic UPDATE; image_id's Ellipsis default means "don't touch image_id"."""
|
||||
fields = {
|
||||
k: v for k, v in changed_fields.items() if k != "variant_image"
|
||||
}
|
||||
assignments = [
|
||||
sql.SQL("{} = {}").format(sql.Identifier(f), sql.Placeholder()) for f in fields
|
||||
]
|
||||
values: list[object] = list(fields.values())
|
||||
if image_id is not ...:
|
||||
assignments.append(sql.SQL("image_id = {}").format(sql.Placeholder()))
|
||||
values.append(image_id)
|
||||
if not assignments:
|
||||
return
|
||||
query = sql.SQL("UPDATE variant SET {}, updated_at = now() WHERE id = {}").format(
|
||||
sql.SQL(", ").join(assignments), sql.Placeholder()
|
||||
)
|
||||
conn.execute(query, [*values, variant_id])
|
||||
|
||||
|
||||
def get_or_create_image(
|
||||
conn: psycopg.Connection,
|
||||
product_id: int,
|
||||
source_url: str,
|
||||
position: int,
|
||||
alt_text: str | None,
|
||||
run_id: int,
|
||||
) -> int:
|
||||
"""Image identity within a product is source_url (§6.3); existing rows are
|
||||
returned untouched — diff emits explicit image update entries for position/alt."""
|
||||
row = conn.execute(
|
||||
"SELECT id FROM product_image WHERE product_id = %s AND source_url = %s",
|
||||
(product_id, source_url),
|
||||
).fetchone()
|
||||
if row is not None:
|
||||
return row[0]
|
||||
return conn.execute(
|
||||
"INSERT INTO product_image (product_id, source_url, position, alt_text, import_run_id)"
|
||||
" VALUES (%s, %s, %s, %s, %s) RETURNING id",
|
||||
(product_id, source_url, position, alt_text, run_id),
|
||||
).fetchone()[0]
|
||||
|
||||
|
||||
def update_image(conn: psycopg.Connection, image_id: int, changed_fields: dict) -> None:
|
||||
"""Subset of {position, alt_text}."""
|
||||
if not changed_fields:
|
||||
return
|
||||
assignments = [
|
||||
sql.SQL("{} = {}").format(sql.Identifier(f), sql.Placeholder())
|
||||
for f in changed_fields
|
||||
]
|
||||
query = sql.SQL("UPDATE product_image SET {} WHERE id = {}").format(
|
||||
sql.SQL(", ").join(assignments), sql.Placeholder()
|
||||
)
|
||||
conn.execute(query, [*changed_fields.values(), image_id])
|
||||
@@ -0,0 +1,6 @@
|
||||
Handle,Title,Description,Vendor,Type,Google Product Category,Tags,Status,Published,Option1 Name,Option1 Value,Option2 Name,Option2 Value,Variant SKU,Variant Price,Variant Inventory Qty,Image Src,Image Position,Image Alt Text
|
||||
moon-mug,Moon Mug,"<p>A ceramic mug glazed in moonlight grey.</p>",Wiggle Goods,standalone,Home & Garden > Kitchen & Dining,"kitchen, mugs",active,TRUE,,,,,WG-MUG-001,18.00,40,https://images.example.com/moon-mug.jpg,1,Moon Mug on a desk
|
||||
star-tee,Star Tee,"<p>Soft cotton tee with a hand-printed star.</p>",Wiggle Goods,standalone,Apparel & Accessories > Clothing,"apparel, tees",active,TRUE,Size,S,Color,Indigo,WG-TEE-S,24.00,12,https://images.example.com/star-tee.jpg,1,Star Tee flat lay
|
||||
star-tee,,,,,,,,,,M,,Indigo,WG-TEE-M,24.00,18,,,
|
||||
star-tee,,,,,,,,,,L,,Indigo,WG-TEE-L,26.00,9,,,
|
||||
star-tee,,,,,,,,,,,,,,,,https://images.example.com/star-tee-back.jpg,2,Star Tee back print
|
||||
|
@@ -0,0 +1,227 @@
|
||||
"""products service — the import/export use-case orchestration (SD-0002 §6.5).
|
||||
|
||||
Coordinates codec → validate → diff → repo; owns transaction boundaries (repo
|
||||
never commits). Preview is read-only against catalog tables (INV-11): validation
|
||||
writes exactly one row — the import_draft. TEL events per §9.1.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import psycopg
|
||||
|
||||
from app.platform import telemetry
|
||||
|
||||
from . import codec, diff, repo, validate
|
||||
from .errors import DraftExpired, DraftNotFound, NothingToApply, PreviewStale, RunNotFound
|
||||
|
||||
|
||||
def import_validate(conn: psycopg.Connection, storefront_id: int, account_id: int,
|
||||
file_name: str, data: bytes) -> dict:
|
||||
"""Upload → validate → diff → persist draft (PUC-2/3; INV-11). Raises FileRejected."""
|
||||
started = time.monotonic()
|
||||
# Commit the sweep before parsing: a FileRejected mid-parse must not roll
|
||||
# back expired-draft cleanup along with it.
|
||||
repo.sweep_expired_drafts(conn)
|
||||
conn.commit()
|
||||
parsed = codec.parse_csv(data)
|
||||
products = validate.build_products(parsed)
|
||||
catalog = repo.load_catalog(conn, storefront_id)
|
||||
diff_result = diff.compute_diff(catalog, products)
|
||||
draft = repo.insert_draft(
|
||||
conn, storefront_id, account_id, file_name, parsed.dialect, data,
|
||||
diff_result.summary, diff_result.records, diff_result.fingerprint,
|
||||
parsed.unknown_columns,
|
||||
)
|
||||
conn.commit()
|
||||
telemetry.emit(
|
||||
"import_draft_created",
|
||||
storefront_id=storefront_id,
|
||||
dialect=parsed.dialect,
|
||||
row_count=len(parsed.rows),
|
||||
adds=diff_result.summary["adds"],
|
||||
updates=diff_result.summary["updates"],
|
||||
unchanged=diff_result.summary["unchanged"],
|
||||
errors=diff_result.summary["errors"],
|
||||
unknown_columns_count=len(parsed.unknown_columns),
|
||||
duration_ms=int((time.monotonic() - started) * 1000),
|
||||
)
|
||||
return draft
|
||||
|
||||
|
||||
def _live_draft_row(conn: psycopg.Connection, storefront_id: int, draft_id: int) -> dict:
|
||||
"""The draft row if it exists and hasn't expired; expiry deletes lazily (§6.3)."""
|
||||
row = repo.get_draft_row(conn, storefront_id, draft_id)
|
||||
if row is None:
|
||||
raise DraftNotFound()
|
||||
if row["expires_at"] < datetime.now(timezone.utc):
|
||||
repo.delete_draft(conn, storefront_id, draft_id)
|
||||
conn.commit()
|
||||
raise DraftExpired()
|
||||
return row
|
||||
|
||||
|
||||
def get_draft(conn: psycopg.Connection, storefront_id: int, draft_id: int) -> dict:
|
||||
"""The §6.4 draft payload — never file_bytes or the full records list."""
|
||||
row = _live_draft_row(conn, storefront_id, draft_id)
|
||||
return {
|
||||
"id": row["id"],
|
||||
"file_name": row["file_name"],
|
||||
"dialect": row["dialect"],
|
||||
"summary": row["summary"],
|
||||
"unknown_columns": row["unknown_columns"],
|
||||
"expires_at": row["expires_at"].isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def get_draft_records(conn: psycopg.Connection, storefront_id: int, draft_id: int,
|
||||
kind: str | None = None, limit: int = 100, offset: int = 0) -> list[dict]:
|
||||
"""The draft's preview records, paged, optionally filtered by kind (PUC-3)."""
|
||||
_live_draft_row(conn, storefront_id, draft_id)
|
||||
return repo.draft_records(conn, storefront_id, draft_id, kind, limit, offset)
|
||||
|
||||
|
||||
def discard_draft(conn: psycopg.Connection, storefront_id: int, draft_id: int) -> None:
|
||||
"""Delete the draft, no trace kept; idempotent — an absent draft is fine (PUC-3a)."""
|
||||
repo.delete_draft(conn, storefront_id, draft_id)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def confirm_draft(conn: psycopg.Connection, storefront_id: int, account_id: int,
|
||||
draft_id: int) -> int:
|
||||
"""Apply the previewed diff in one transaction (PUC-4; INV-10/11).
|
||||
|
||||
Everything is re-derived from the draft's stored file bytes against the live
|
||||
catalog; a fingerprint mismatch means the catalog drifted since preview
|
||||
(PreviewStale — the draft is kept so the merchant can re-validate). The apply
|
||||
executes the typed plan compute_diff built alongside the preview records, so
|
||||
what lands is exactly what the preview showed. rows_errored counts the
|
||||
import_run_error rows recorded (one per RowError), which is what the run
|
||||
detail's error table shows; the preview's errors tile counts error *products*.
|
||||
"""
|
||||
started = time.monotonic()
|
||||
row = _live_draft_row(conn, storefront_id, draft_id)
|
||||
parsed = codec.parse_csv(row["file_bytes"])
|
||||
products = validate.build_products(parsed)
|
||||
catalog = repo.load_catalog(conn, storefront_id)
|
||||
diff_result = diff.compute_diff(catalog, products)
|
||||
if diff_result.fingerprint != row["fingerprint"]:
|
||||
# Release the read snapshot; nothing written.
|
||||
conn.rollback()
|
||||
raise PreviewStale()
|
||||
summary_counts = diff_result.summary
|
||||
if summary_counts["adds"] + summary_counts["updates"] == 0:
|
||||
# Release the read snapshot; nothing written.
|
||||
conn.rollback()
|
||||
raise NothingToApply()
|
||||
error_rows = [
|
||||
error.as_json()
|
||||
for plan in diff_result.plan if plan.kind == "error"
|
||||
for error in plan.canonical.errors
|
||||
]
|
||||
try:
|
||||
run_id = repo.insert_run(
|
||||
conn, storefront_id, account_id, row["file_name"], row["dialect"],
|
||||
added=summary_counts["adds"], updated=summary_counts["updates"],
|
||||
errored=len(error_rows), status="complete",
|
||||
)
|
||||
for plan in diff_result.plan:
|
||||
_apply_product_plan(conn, storefront_id, plan, run_id)
|
||||
repo.insert_run_errors(conn, run_id, error_rows)
|
||||
repo.delete_draft(conn, storefront_id, draft_id)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
conn.rollback()
|
||||
telemetry.emit(
|
||||
"import_apply_failed",
|
||||
draft_id=draft_id,
|
||||
storefront_id=storefront_id,
|
||||
error_class=type(exc).__name__,
|
||||
)
|
||||
raise
|
||||
telemetry.emit(
|
||||
"import_run_completed",
|
||||
run_id=run_id,
|
||||
storefront_id=storefront_id,
|
||||
added=summary_counts["adds"],
|
||||
updated=summary_counts["updates"],
|
||||
errored=len(error_rows),
|
||||
duration_ms=int((time.monotonic() - started) * 1000),
|
||||
)
|
||||
return run_id
|
||||
|
||||
|
||||
def _apply_product_plan(conn: psycopg.Connection, storefront_id: int,
|
||||
plan: diff.ProductPlan, run_id: int) -> None:
|
||||
"""Execute one product's plan inside the confirm transaction (no commits here)."""
|
||||
if plan.kind == "add":
|
||||
# Title is a canonical attribute, not a fields{} entry — non-error
|
||||
# products always carry one (validate guarantees it).
|
||||
product_fields = {"title": plan.canonical.title}
|
||||
product_fields.update(diff.resolved_product_fields(plan.canonical))
|
||||
product_id = repo.insert_product(
|
||||
conn, storefront_id, plan.canonical.handle, product_fields, plan.canonical.option_names
|
||||
)
|
||||
image_ids: dict[str, int] = {}
|
||||
elif plan.kind == "update":
|
||||
product_id = plan.catalog.id
|
||||
repo.update_product(conn, product_id, plan.product_changes)
|
||||
image_ids = {image.source_url: image.id for image in plan.catalog.images}
|
||||
else:
|
||||
return
|
||||
|
||||
# Images first, so variants' variant_image URLs resolve to ids: validate puts
|
||||
# every variant_image URL into canonical.images, so each URL is in either the
|
||||
# catalog map (existing image) or the adds below.
|
||||
for image_plan in plan.image_plans:
|
||||
if image_plan.kind == "add":
|
||||
image_ids[image_plan.source_url] = repo.get_or_create_image(
|
||||
conn, product_id, image_plan.source_url, image_plan.position,
|
||||
image_plan.alt_text, run_id,
|
||||
)
|
||||
else:
|
||||
repo.update_image(conn, image_plan.image_id, image_plan.changes)
|
||||
|
||||
for variant_plan in plan.variant_plans:
|
||||
if variant_plan.kind == "add":
|
||||
fields = diff.resolved_variant_fields(variant_plan.canonical, variant_plan.file_order)
|
||||
# diff time resolved any cleared position to file order; the
|
||||
# file_order fallback covers an absent position column.
|
||||
position = fields.get("position") or variant_plan.file_order
|
||||
url = fields.get("variant_image")
|
||||
image_id = image_ids[url] if url else None
|
||||
repo.insert_variant(
|
||||
conn, product_id, position, variant_plan.canonical.options, fields, image_id
|
||||
)
|
||||
elif "variant_image" in variant_plan.changes:
|
||||
url = variant_plan.changes["variant_image"]
|
||||
repo.update_variant(
|
||||
conn, variant_plan.catalog_id, variant_plan.changes,
|
||||
image_id=image_ids[url] if url else None,
|
||||
)
|
||||
else:
|
||||
repo.update_variant(conn, variant_plan.catalog_id, variant_plan.changes)
|
||||
|
||||
|
||||
def list_runs(conn: psycopg.Connection, storefront_id: int,
|
||||
limit: int = 50, offset: int = 0) -> list[dict]:
|
||||
"""The storefront's import history, newest first (PUC-8)."""
|
||||
return repo.list_runs(conn, storefront_id, limit, offset)
|
||||
|
||||
|
||||
def get_run(conn: psycopg.Connection, storefront_id: int, run_id: int) -> dict:
|
||||
"""One run's §6.4 detail payload, errors included."""
|
||||
run = repo.get_run(conn, storefront_id, run_id)
|
||||
if run is None:
|
||||
raise RunNotFound()
|
||||
return run
|
||||
|
||||
|
||||
def summary(conn: psycopg.Connection, storefront_id: int) -> dict:
|
||||
"""The products dashboard counts (§6.4)."""
|
||||
return {
|
||||
"product_count": repo.product_count(conn, storefront_id),
|
||||
"image_problem_count": repo.image_problem_count(conn, storefront_id),
|
||||
"latest_run_id": repo.latest_run_id(conn, storefront_id),
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
"""Row validation — ParsedFile rows → canonical products + row errors (SD-0002 §6.5.1).
|
||||
|
||||
The codec (codec.py) handles file-level gates; this module is the row-semantics
|
||||
half of the PUC-5 import spine. It groups consecutive rows sharing a Handle into
|
||||
product blocks (Shopify's grammar), normalizes product/variant/image fields, and
|
||||
records every rule violation as a merchant-language RowError. Errors never raise:
|
||||
an error poisons its whole product block (the product previews as kind="error"
|
||||
and is excluded from apply) while parsing continues so the merchant gets a
|
||||
complete accounting in one pass (BUC-1a). Description HTML is sanitized with nh3
|
||||
on the way in (INV-15).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
import nh3
|
||||
|
||||
from .models import (
|
||||
COMPONENT_COLUMNS,
|
||||
OPTION_VALUE_COLUMNS,
|
||||
PRODUCT_COLUMNS,
|
||||
VARIANT_COLUMNS,
|
||||
CanonicalImage,
|
||||
CanonicalProduct,
|
||||
CanonicalVariant,
|
||||
ParsedFile,
|
||||
Row,
|
||||
RowError,
|
||||
)
|
||||
|
||||
_HANDLE_RE = re.compile(r"^[a-z0-9-]+$")
|
||||
_STATUSES = {"draft", "active", "archived"}
|
||||
# Title is handled as an attribute, not via fields{}. Option names live in BOTH
|
||||
# .option_names (the values) and fields{} (the file-presence signal the diff
|
||||
# needs: absent column == untouched, never a clear).
|
||||
_ATTRIBUTE_COLUMNS = {"Title"}
|
||||
# Sentinel: the cell failed normalization (the error is already recorded).
|
||||
_INVALID = object()
|
||||
|
||||
|
||||
def build_products(parsed: ParsedFile) -> list[CanonicalProduct]:
|
||||
"""Group rows into product blocks and validate every §6.5.1 rule."""
|
||||
products: list[CanonicalProduct] = []
|
||||
closed_handles: set[str] = set()
|
||||
block: list[Row] = []
|
||||
|
||||
def flush() -> None:
|
||||
nonlocal block
|
||||
if block:
|
||||
closed_handles.add(block[0].cells["Handle"])
|
||||
products.append(_build_block(block))
|
||||
block = []
|
||||
|
||||
for row in parsed.rows:
|
||||
handle = row.cells.get("Handle", "")
|
||||
if block and handle == block[0].cells["Handle"]:
|
||||
block.append(row)
|
||||
continue
|
||||
flush()
|
||||
if not handle:
|
||||
products.append(
|
||||
_error_block(row, "(missing)", RowError(row.line_number, "Handle", "a row needs a Handle"))
|
||||
)
|
||||
elif not _HANDLE_RE.match(handle):
|
||||
products.append(
|
||||
_error_block(
|
||||
row,
|
||||
handle,
|
||||
RowError(
|
||||
row.line_number,
|
||||
"Handle",
|
||||
f"'{handle}' isn't a valid handle — lowercase letters, numbers, and dashes only",
|
||||
),
|
||||
)
|
||||
)
|
||||
elif handle in closed_handles:
|
||||
products.append(
|
||||
_error_block(
|
||||
row,
|
||||
handle,
|
||||
RowError(
|
||||
row.line_number,
|
||||
"Handle",
|
||||
f"rows for '{handle}' must be consecutive — it already appeared earlier in the file",
|
||||
),
|
||||
)
|
||||
)
|
||||
else:
|
||||
block = [row]
|
||||
flush()
|
||||
return products
|
||||
|
||||
|
||||
def all_errors(products: list[CanonicalProduct]) -> list[RowError]:
|
||||
return [error for product in products for error in product.errors]
|
||||
|
||||
|
||||
def _error_block(row: Row, handle: str, error: RowError) -> CanonicalProduct:
|
||||
return CanonicalProduct(first_line=row.line_number, handle=handle, title="", errors=[error])
|
||||
|
||||
|
||||
def _build_block(rows: list[Row]) -> CanonicalProduct:
|
||||
first = rows[0]
|
||||
handle = first.cells["Handle"]
|
||||
errors: list[RowError] = []
|
||||
|
||||
title = first.cells.get("Title", "")
|
||||
if not title:
|
||||
errors.append(RowError(first.line_number, "Title", f"'{handle}' is missing its Title"))
|
||||
|
||||
option_names = tuple(first.cells.get(f"Option{n} Name") or None for n in (1, 2, 3))
|
||||
has_options = any(option_names)
|
||||
|
||||
# Product-level fields come from the first row only; empty cell == clear (None).
|
||||
fields: dict[str, object] = {}
|
||||
for column, field_name in PRODUCT_COLUMNS.items():
|
||||
if column in _ATTRIBUTE_COLUMNS or column not in first.cells:
|
||||
continue
|
||||
cell = first.cells[column]
|
||||
if not cell:
|
||||
fields[field_name] = None
|
||||
continue
|
||||
value = _product_value(first.line_number, column, field_name, cell, errors)
|
||||
if value is not _INVALID:
|
||||
fields[field_name] = value
|
||||
|
||||
variants: list[CanonicalVariant] = []
|
||||
images: list[CanonicalImage] = []
|
||||
seen_combos: set[tuple[str | None, str | None, str | None]] = set()
|
||||
|
||||
for index, row in enumerate(rows):
|
||||
cells = row.cells
|
||||
line = row.line_number
|
||||
|
||||
for column in COMPONENT_COLUMNS:
|
||||
if cells.get(column):
|
||||
errors.append(
|
||||
RowError(line, column, "kits arrive in a coming release — leave the Component columns empty")
|
||||
)
|
||||
|
||||
has_image = bool(cells.get("Image Src"))
|
||||
if has_image:
|
||||
_collect_image(row, images, errors)
|
||||
|
||||
# A row carries a variant iff any option value / Variant-* cell is filled;
|
||||
# the first row of a no-option product always carries the single variant.
|
||||
carries_variant = (
|
||||
any(cells.get(c) for c in OPTION_VALUE_COLUMNS)
|
||||
or any(cells.get(c) for c in VARIANT_COLUMNS)
|
||||
or (index == 0 and not has_options)
|
||||
)
|
||||
if carries_variant:
|
||||
options = tuple(cells.get(c) or None for c in OPTION_VALUE_COLUMNS)
|
||||
for n in (1, 2, 3):
|
||||
value, name = options[n - 1], option_names[n - 1]
|
||||
if value and not name:
|
||||
errors.append(
|
||||
RowError(
|
||||
line,
|
||||
f"Option{n} Value",
|
||||
f"Option{n} Value given but the product has no Option{n} Name",
|
||||
)
|
||||
)
|
||||
elif name and not value:
|
||||
errors.append(
|
||||
RowError(
|
||||
line,
|
||||
f"Option{n} Value",
|
||||
f"this variant is missing its Option{n} Value ('{name}')",
|
||||
)
|
||||
)
|
||||
if not has_options:
|
||||
if variants:
|
||||
errors.append(RowError(line, None, "a product without options can have only one variant"))
|
||||
elif options in seen_combos:
|
||||
errors.append(
|
||||
RowError(line, None, f"duplicate variant — '{handle}' already has a variant with these options")
|
||||
)
|
||||
seen_combos.add(options)
|
||||
|
||||
variant_fields: dict[str, object] = {}
|
||||
for column, field_name in VARIANT_COLUMNS.items():
|
||||
if column not in cells:
|
||||
continue
|
||||
cell = cells[column]
|
||||
if not cell:
|
||||
variant_fields[field_name] = None
|
||||
continue
|
||||
value = _variant_value(line, column, field_name, cell, errors)
|
||||
if value is _INVALID:
|
||||
continue
|
||||
variant_fields[field_name] = value
|
||||
if field_name == "variant_image" and cell not in {i.source_url for i in images}:
|
||||
images.append(
|
||||
CanonicalImage(line_number=line, source_url=cell, position=len(images) + 1, alt_text=None)
|
||||
)
|
||||
variants.append(CanonicalVariant(line_number=line, options=options, fields=variant_fields))
|
||||
elif index > 0 and not has_image:
|
||||
errors.append(RowError(line, None, "this row has no variant or image data"))
|
||||
|
||||
return CanonicalProduct(
|
||||
first_line=first.line_number,
|
||||
handle=handle,
|
||||
title=title,
|
||||
option_names=option_names,
|
||||
fields=fields,
|
||||
variants=variants,
|
||||
images=images,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
def _collect_image(row: Row, images: list[CanonicalImage], errors: list[RowError]) -> None:
|
||||
cells = row.cells
|
||||
source_url = cells["Image Src"]
|
||||
position_cell = cells.get("Image Position", "")
|
||||
position: int | None = None
|
||||
if position_cell:
|
||||
try:
|
||||
position = int(position_cell)
|
||||
if position < 1:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
position = None
|
||||
errors.append(RowError(row.line_number, "Image Position", f"'{position_cell}' is not a position"))
|
||||
# Dedupe by source URL within the block — first occurrence wins.
|
||||
if source_url in {i.source_url for i in images}:
|
||||
return
|
||||
images.append(
|
||||
CanonicalImage(
|
||||
line_number=row.line_number,
|
||||
source_url=source_url,
|
||||
position=position if position is not None else len(images) + 1,
|
||||
alt_text=cells.get("Image Alt Text") or None,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _product_value(line: int, column: str, field_name: str, cell: str, errors: list[RowError]) -> object:
|
||||
if field_name == "tags":
|
||||
return [tag.strip() for tag in cell.split(",") if tag.strip()]
|
||||
if field_name == "status":
|
||||
status = cell.lower()
|
||||
if status not in _STATUSES:
|
||||
errors.append(RowError(line, column, f"'{cell}' is not a status — use draft, active, or archived"))
|
||||
return _INVALID
|
||||
return status
|
||||
if field_name == "published":
|
||||
flag = cell.upper()
|
||||
if flag not in ("TRUE", "FALSE"):
|
||||
errors.append(RowError(line, column, f"'{cell}' is not TRUE or FALSE"))
|
||||
return _INVALID
|
||||
return flag == "TRUE"
|
||||
if field_name == "description_html":
|
||||
return nh3.clean(cell)
|
||||
if field_name == "product_type":
|
||||
if cell != "standalone":
|
||||
errors.append(RowError(line, column, "kits arrive in a coming release — Type must be 'standalone'"))
|
||||
return _INVALID
|
||||
return cell
|
||||
return cell
|
||||
|
||||
|
||||
def _variant_value(line: int, column: str, field_name: str, cell: str, errors: list[RowError]) -> object:
|
||||
if field_name in ("price", "cost"):
|
||||
return _decimal_or_error(line, column, cell, f"'{cell}' is not a price", errors)
|
||||
if field_name in ("weight", "volume"):
|
||||
return _decimal_or_error(line, column, cell, f"'{cell}' is not a number", errors)
|
||||
if field_name == "inventory_qty":
|
||||
try:
|
||||
quantity = int(cell)
|
||||
if quantity < 0:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
errors.append(RowError(line, column, f"'{cell}' is not a whole number"))
|
||||
return _INVALID
|
||||
return quantity
|
||||
if field_name == "position":
|
||||
try:
|
||||
position = int(cell)
|
||||
if position < 1:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
errors.append(RowError(line, column, f"'{cell}' is not a position"))
|
||||
return _INVALID
|
||||
return position
|
||||
return cell
|
||||
|
||||
|
||||
def _decimal_or_error(line: int, column: str, cell: str, message: str, errors: list[RowError]) -> object:
|
||||
try:
|
||||
value = Decimal(cell)
|
||||
if not value.is_finite() or value < 0:
|
||||
raise InvalidOperation
|
||||
except InvalidOperation:
|
||||
errors.append(RowError(line, column, message))
|
||||
return _INVALID
|
||||
return value
|
||||
@@ -0,0 +1,19 @@
|
||||
"""storefronts domain — storefront entity, membership, the one-storefront guard.
|
||||
|
||||
Owns INV-4 (one storefront per account is a service-layer rule) and INV-5 (tenant rows
|
||||
carry storefront_id). Knows nothing about identity (that is the accounts domain). Imported
|
||||
via this package surface only (§6.2).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .errors import AlreadyOwnsStorefront, StorefrontsError
|
||||
from .models import Storefront
|
||||
from .service import create_storefront, storefront_for
|
||||
|
||||
__all__ = [
|
||||
"Storefront",
|
||||
"StorefrontsError",
|
||||
"AlreadyOwnsStorefront",
|
||||
"create_storefront",
|
||||
"storefront_for",
|
||||
]
|
||||
@@ -0,0 +1,10 @@
|
||||
"""storefronts domain errors."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class StorefrontsError(Exception):
|
||||
"""Base for storefronts-domain errors."""
|
||||
|
||||
|
||||
class AlreadyOwnsStorefront(StorefrontsError):
|
||||
"""INV-4: the account already has its one storefront (PUC-7)."""
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Storefront record — the §6.3 entity as the domain returns it."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Storefront:
|
||||
id: int
|
||||
name: str
|
||||
@@ -0,0 +1,56 @@
|
||||
"""storefronts — the storefront + membership service (SD-0001 §6.5).
|
||||
|
||||
Owns the storefront entity, the account<->storefront membership (INV-5), the entry-routing
|
||||
answer (storefront_for), and INV-4's one-storefront guard — the single deletable check that
|
||||
makes one-per-account an MVP rule, not a schema law. Never mints identity: the BFF passes
|
||||
account_id and email down.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import psycopg
|
||||
|
||||
from .errors import AlreadyOwnsStorefront
|
||||
from .models import Storefront
|
||||
|
||||
|
||||
def _default_name(email: str) -> str:
|
||||
"""§6.3: a blank name stores a generated default — never NULL (corpus 14.01.0026)."""
|
||||
return f"{email.split('@', 1)[0]}'s storefront"
|
||||
|
||||
|
||||
def storefront_for(conn: psycopg.Connection, account_id: int) -> Storefront | None:
|
||||
"""The entry-routing answer (§6.5): which storefront, if any, this account has."""
|
||||
row = conn.execute(
|
||||
"SELECT s.id, s.name FROM storefront s"
|
||||
" JOIN storefront_membership m ON m.storefront_id = s.id"
|
||||
" WHERE m.account_id = %s ORDER BY m.created_at LIMIT 1",
|
||||
(account_id,),
|
||||
).fetchone()
|
||||
return Storefront(id=row[0], name=row[1]) if row else None
|
||||
|
||||
|
||||
def create_storefront(
|
||||
conn: psycopg.Connection, account_id: int, email: str, name: str | None
|
||||
) -> Storefront:
|
||||
"""Create the account's one storefront + owner membership (PUC-4; INV-4, INV-5).
|
||||
|
||||
Guard + insert run as one atomic unit: a transaction-scoped advisory lock keyed by
|
||||
account_id serializes concurrent creates for the same account, so the second of two
|
||||
racing requests sees the first's committed membership and is refused (§6.5).
|
||||
"""
|
||||
conn.execute("SELECT pg_advisory_xact_lock(%s)", (account_id,))
|
||||
existing = storefront_for(conn, account_id)
|
||||
if existing is not None:
|
||||
conn.rollback() # release the advisory lock; nothing was written
|
||||
raise AlreadyOwnsStorefront()
|
||||
final_name = (name or "").strip() or _default_name(email)
|
||||
sf_id = conn.execute(
|
||||
"INSERT INTO storefront (name) VALUES (%s) RETURNING id", (final_name,)
|
||||
).fetchone()[0]
|
||||
conn.execute(
|
||||
"INSERT INTO storefront_membership (account_id, storefront_id, role)"
|
||||
" VALUES (%s, %s, 'owner')",
|
||||
(account_id, sf_id),
|
||||
)
|
||||
conn.commit()
|
||||
return Storefront(id=sf_id, name=final_name)
|
||||
+354
-9
@@ -1,22 +1,118 @@
|
||||
"""ecomm backend — FastAPI app factory + the REST BFF.
|
||||
|
||||
SLICE-1 mounts only /healthz; the auth and storefront endpoints (§6.4) grow here in
|
||||
later slices. create_app() opens the connection pool and self-migrates the database
|
||||
at startup (INV-1, INV-7): the app boots against empty persistence and applies its
|
||||
own schema — there is no seed step.
|
||||
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, Response
|
||||
from fastapi import Depends, FastAPI, File, Query, Response, UploadFile
|
||||
from fastapi.responses import JSONResponse, PlainTextResponse
|
||||
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.deps import get_conn
|
||||
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
|
||||
|
||||
|
||||
def create_app(database_url: str | None = None) -> FastAPI:
|
||||
# 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
|
||||
@@ -24,12 +120,13 @@ def create_app(database_url: str | None = None) -> 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="0.1", lifespan=lifespan)
|
||||
app = FastAPI(title="ecomm", version=_APP_VERSION, lifespan=lifespan)
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz(response: Response, conn: psycopg.Connection = Depends(get_conn)):
|
||||
@@ -43,7 +140,255 @@ def create_app(database_url: str | None = None) -> FastAPI:
|
||||
if pending:
|
||||
response.status_code = 503
|
||||
return {"status": "unavailable", "reason": "migrations_pending"}
|
||||
return {"status": "ok"}
|
||||
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/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
|
||||
|
||||
|
||||
@@ -17,3 +17,54 @@ _DEFAULT_DATABASE_URL = "postgresql://ecomm:ecomm@localhost:5432/ecomm"
|
||||
def database_url() -> str:
|
||||
"""The psycopg DSN for the application database."""
|
||||
return os.environ.get("ECOMM_DATABASE_URL") or _DEFAULT_DATABASE_URL
|
||||
|
||||
|
||||
# Session signing secret. Dev default is intentionally well-known and insecure — a clean
|
||||
# checkout works with no setup; deployed environments MUST supply ECOMM_SESSION_SECRET from
|
||||
# Secret Manager (INV-8, §6.6). Rotating it invalidates all sessions at once (§6.2).
|
||||
_DEFAULT_SESSION_SECRET = "dev-insecure-session-secret-change-me"
|
||||
|
||||
|
||||
def session_secret() -> str:
|
||||
"""The HMAC key for signed session cookies (and the one-time-code pepper)."""
|
||||
return os.environ.get("ECOMM_SESSION_SECRET") or _DEFAULT_SESSION_SECRET
|
||||
|
||||
|
||||
def cookie_secure() -> bool:
|
||||
"""Whether to mark the session cookie Secure (HTTPS-only). True in deployed envs."""
|
||||
return os.environ.get("ECOMM_COOKIE_SECURE", "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def mailer_kind() -> str:
|
||||
"""Which mailer adapter to build: 'log' (dev/tests) or 'smtp' (deployed)."""
|
||||
return os.environ.get("ECOMM_MAILER") or "log"
|
||||
|
||||
|
||||
# SMTP relay coordinates (deployed envs only; INV-8 — host/port/user/from are non-secret
|
||||
# overlay values, the password is a Secret Manager reference resolved by the deploy).
|
||||
|
||||
|
||||
def smtp_host() -> str:
|
||||
return os.environ.get("ECOMM_SMTP_HOST", "")
|
||||
|
||||
|
||||
def smtp_port() -> int:
|
||||
return int(os.environ.get("ECOMM_SMTP_PORT") or "587")
|
||||
|
||||
|
||||
def smtp_user() -> str:
|
||||
return os.environ.get("ECOMM_SMTP_USER", "")
|
||||
|
||||
|
||||
def smtp_password() -> str:
|
||||
return os.environ.get("ECOMM_SMTP_PASSWORD", "")
|
||||
|
||||
|
||||
def smtp_from() -> str:
|
||||
"""The From header; defaults to the relay user."""
|
||||
return os.environ.get("ECOMM_SMTP_FROM") or smtp_user()
|
||||
|
||||
|
||||
def smtp_starttls() -> bool:
|
||||
"""STARTTLS on the relay connection (default on; disable only for odd relays)."""
|
||||
return os.environ.get("ECOMM_SMTP_STARTTLS", "1").strip().lower() not in {"0", "false", "no", "off"}
|
||||
|
||||
@@ -1,18 +1,38 @@
|
||||
"""FastAPI dependencies — per-request wiring (SD-0001 §6.2 platform/deps).
|
||||
|
||||
Yields a pooled connection per request. The pool lives on app.state, built once in
|
||||
create_app(). Later slices add the current-session and mailer dependencies here.
|
||||
Yields a pooled connection per request, the process-wide mailer, and the verified session
|
||||
payload (from the signed cookie). The pool and mailer live on app.state, built once in
|
||||
create_app(). The storefronts dependency lands in SLICE-3.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
import psycopg
|
||||
from fastapi import Request
|
||||
|
||||
from app.platform import config, session
|
||||
from app.platform.mailer import Mailer
|
||||
|
||||
SESSION_COOKIE = "ecomm_session"
|
||||
|
||||
|
||||
def get_conn(request: Request) -> Iterator[psycopg.Connection]:
|
||||
"""A pooled connection for the duration of one request."""
|
||||
pool = request.app.state.pool
|
||||
with pool.connection() as conn:
|
||||
yield conn
|
||||
|
||||
|
||||
def get_mailer(request: Request) -> Mailer:
|
||||
"""The process-wide mailer adapter (built from config at startup, INV-8)."""
|
||||
return request.app.state.mailer
|
||||
|
||||
|
||||
def get_session(request: Request) -> dict[str, Any] | None:
|
||||
"""The verified session payload from the cookie, or None if absent/invalid."""
|
||||
token = request.cookies.get(SESSION_COOKIE)
|
||||
if not token:
|
||||
return None
|
||||
return session.verify(token, config.session_secret())
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""platform/mailer — the outbound email port (SD-0001 §6.2).
|
||||
|
||||
A one-method port `send(to, subject, body)` with two adapters: `LogMailer` (dev/tests —
|
||||
the message lands in the app log and an in-memory outbox tests read back, §6.8) and the
|
||||
deployed `SmtpMailer` (relay coordinates from configuration, INV-8). The adapter is chosen
|
||||
by configuration at startup, so no deployment shape lives in the domain. LogMailer logs the
|
||||
full body on purpose: that is PUC-10's "local dev channel" — the one-time code reaches the
|
||||
developer in the terminal. SmtpMailer never logs the body or the recipient (INV-3 / §6.6
|
||||
log hygiene) and raises MailerError on delivery failure (INV-9 → the §6.4 502).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import smtplib
|
||||
from dataclasses import dataclass
|
||||
from email.message import EmailMessage
|
||||
from typing import Protocol
|
||||
|
||||
from app.platform import config
|
||||
|
||||
logger = logging.getLogger("ecomm.mailer")
|
||||
|
||||
|
||||
class MailerError(Exception):
|
||||
"""Delivery failed — the relay refused or was unreachable (INV-9)."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SentMessage:
|
||||
to: str
|
||||
subject: str
|
||||
body: str
|
||||
|
||||
|
||||
class Mailer(Protocol):
|
||||
"""The outbound-message port. Raises on delivery failure (INV-9 → §6.4 502)."""
|
||||
|
||||
def send(self, to: str, subject: str, body: str) -> None: ...
|
||||
|
||||
|
||||
class LogMailer:
|
||||
"""Dev/test adapter: records to an in-memory outbox and logs the message (PUC-10)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.outbox: list[SentMessage] = []
|
||||
|
||||
def send(self, to: str, subject: str, body: str) -> None:
|
||||
self.outbox.append(SentMessage(to=to, subject=subject, body=body))
|
||||
# Full body incl. the code — the local dev channel. Dev/test only (never deployed).
|
||||
logger.info("LogMailer -> %s | %s\n%s", to, subject, body)
|
||||
|
||||
|
||||
class SmtpMailer:
|
||||
"""Deployed adapter: real mail over an SMTP relay (STARTTLS + login by default).
|
||||
|
||||
Logs only a hashed recipient — never the address, subject, or body (§6.6).
|
||||
"""
|
||||
|
||||
def __init__(self, host: str, port: int, user: str, password: str, sender: str, starttls: bool) -> None:
|
||||
self._host, self._port = host, port
|
||||
self._user, self._password = user, password
|
||||
self._sender, self._starttls = sender, starttls
|
||||
|
||||
def send(self, to: str, subject: str, body: str) -> None:
|
||||
msg = EmailMessage()
|
||||
msg["To"] = to
|
||||
msg["From"] = self._sender
|
||||
msg["Subject"] = subject
|
||||
msg.set_content(body)
|
||||
to_hash = hashlib.sha256(to.encode("utf-8")).hexdigest()[:8]
|
||||
try:
|
||||
with smtplib.SMTP(self._host, self._port, timeout=10) as smtp:
|
||||
if self._starttls:
|
||||
smtp.starttls()
|
||||
if self._user:
|
||||
smtp.login(self._user, self._password)
|
||||
smtp.send_message(msg)
|
||||
except Exception as exc:
|
||||
logger.warning("smtp send FAILED to=%s: %s", to_hash, type(exc).__name__)
|
||||
raise MailerError(str(exc)) from exc
|
||||
logger.info("smtp sent to=%s", to_hash)
|
||||
|
||||
|
||||
def build_mailer(kind: str) -> Mailer:
|
||||
"""Select the mailer adapter by configured kind (config.mailer_kind(), INV-8)."""
|
||||
if kind == "log":
|
||||
return LogMailer()
|
||||
if kind == "smtp":
|
||||
return SmtpMailer(
|
||||
host=config.smtp_host(),
|
||||
port=config.smtp_port(),
|
||||
user=config.smtp_user(),
|
||||
password=config.smtp_password(),
|
||||
sender=config.smtp_from(),
|
||||
starttls=config.smtp_starttls(),
|
||||
)
|
||||
raise ValueError(f"unknown mailer kind: {kind!r}")
|
||||
@@ -0,0 +1,49 @@
|
||||
"""platform/session — stateless signed session tokens (SD-0001 §6.2).
|
||||
|
||||
Sessions are signed HTTP-only cookies; there is no session table. A token is
|
||||
`base64url(payload_json).base64url(hmac_sha256(payload))` keyed by the configured session
|
||||
secret (INV-8). `verify` checks the MAC in constant time and returns the payload dict, or
|
||||
None for any malformed/tampered/wrong-key token. No server-side state to revoke — a leaked
|
||||
secret is rotated, invalidating every session at once (§6.2, accepted at MVP scale).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _b64e(raw: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _b64d(s: str) -> bytes:
|
||||
pad = "=" * (-len(s) % 4)
|
||||
return base64.urlsafe_b64decode(s + pad)
|
||||
|
||||
|
||||
def _mac(payload_b64: str, secret: str) -> str:
|
||||
digest = hmac.new(secret.encode("utf-8"), payload_b64.encode("ascii"), hashlib.sha256).digest()
|
||||
return _b64e(digest)
|
||||
|
||||
|
||||
def sign(payload: dict[str, Any], secret: str) -> str:
|
||||
"""Serialize and sign a session payload into a cookie-safe token."""
|
||||
payload_b64 = _b64e(json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8"))
|
||||
return f"{payload_b64}.{_mac(payload_b64, secret)}"
|
||||
|
||||
|
||||
def verify(token: str, secret: str) -> dict[str, Any] | None:
|
||||
"""Return the payload if the token's signature is valid, else None."""
|
||||
if not token or "." not in token:
|
||||
return None
|
||||
payload_b64, _, sig = token.partition(".")
|
||||
expected = _mac(payload_b64, secret)
|
||||
if not hmac.compare_digest(sig, expected):
|
||||
return None
|
||||
try:
|
||||
return json.loads(_b64d(payload_b64))
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return None
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Structured log-event telemetry (SD-0002 §9.1). One JSON object per event on the
|
||||
`ecomm.telemetry` logger — counts and durations only; never file names, URLs,
|
||||
catalog content, or secret bytes (§6.3-handbook)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger("ecomm.telemetry")
|
||||
|
||||
|
||||
def emit(event: str, **fields: object) -> None:
|
||||
_logger.info(json.dumps({"event": event, **fields}, sort_keys=True, default=str))
|
||||
@@ -0,0 +1,130 @@
|
||||
-- 0002_products.sql — SD-0002 §6.3 data model (SLICE-5). Forward-only (INV-7):
|
||||
-- never edit once merged; add a new numbered migration.
|
||||
|
||||
-- product — one catalog product per (storefront, handle) (INV-13/14). Option *names*
|
||||
-- live here; product_type's kit values are schema-open but a service-layer rule
|
||||
-- rejects non-'standalone' until #15 (same pattern as INV-4).
|
||||
CREATE TABLE product (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
storefront_id BIGINT NOT NULL REFERENCES storefront (id),
|
||||
handle TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description_html TEXT,
|
||||
vendor TEXT,
|
||||
product_type TEXT NOT NULL DEFAULT 'standalone'
|
||||
CHECK (product_type IN ('standalone', 'kit_virtual', 'kit_assembled')),
|
||||
google_product_category TEXT,
|
||||
tags TEXT[] NOT NULL DEFAULT '{}',
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('draft', 'active', 'archived')),
|
||||
published BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
option1_name TEXT,
|
||||
option2_name TEXT,
|
||||
option3_name TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE UNIQUE INDEX product_handle_key ON product (storefront_id, handle); -- INV-13
|
||||
|
||||
-- variant — one purchasable form, identified by its option-value combo (INV-13).
|
||||
-- SKU is indexed data, never identity. image_id FK is added after product_image.
|
||||
CREATE TABLE variant (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
product_id BIGINT NOT NULL REFERENCES product (id),
|
||||
position INTEGER NOT NULL,
|
||||
option1_value TEXT,
|
||||
option2_value TEXT,
|
||||
option3_value TEXT,
|
||||
sku TEXT,
|
||||
barcode TEXT,
|
||||
price NUMERIC,
|
||||
cost NUMERIC,
|
||||
weight NUMERIC,
|
||||
weight_unit TEXT,
|
||||
volume NUMERIC,
|
||||
volume_unit TEXT,
|
||||
tax_id_1 TEXT,
|
||||
tax_id_2 TEXT,
|
||||
inventory_tracker TEXT,
|
||||
inventory_qty INTEGER,
|
||||
image_id BIGINT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
-- Postgres 16 (compose + Cloud SQL pin): NULLS NOT DISTINCT makes the all-NULL
|
||||
-- no-option combo unique too (INV-13).
|
||||
CREATE UNIQUE INDEX variant_option_combo_key
|
||||
ON variant (product_id, option1_value, option2_value, option3_value)
|
||||
NULLS NOT DISTINCT;
|
||||
CREATE INDEX variant_sku_idx ON variant (sku);
|
||||
|
||||
-- product_image — identity within a product is source_url (§6.3); bytes live in
|
||||
-- object storage from SLICE-7 (keys nullable until fetched). status starts 'pending';
|
||||
-- SLICE-5 stubs the fetch phase so rows simply stay pending.
|
||||
CREATE TABLE product_image (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
product_id BIGINT NOT NULL REFERENCES product (id),
|
||||
position INTEGER NOT NULL,
|
||||
source_url TEXT NOT NULL,
|
||||
alt_text TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'fetched', 'rejected_low_res', 'rejected_not_image', 'failed')),
|
||||
failure_reason TEXT,
|
||||
key_original TEXT,
|
||||
key_thumb TEXT,
|
||||
key_card TEXT,
|
||||
key_detail TEXT,
|
||||
import_run_id BIGINT,
|
||||
fetched_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE UNIQUE INDEX product_image_src_key ON product_image (product_id, source_url);
|
||||
ALTER TABLE variant
|
||||
ADD CONSTRAINT variant_image_fk FOREIGN KEY (image_id) REFERENCES product_image (id);
|
||||
|
||||
-- import_draft — the preview's server side (INV-11). file_bytes is the SLICE-5
|
||||
-- interim home for the upload (objectstore key from SLICE-7). Deleted outright on
|
||||
-- cancel/expiry — drafts never appear in history (PUC-3a).
|
||||
CREATE TABLE import_draft (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
storefront_id BIGINT NOT NULL REFERENCES storefront (id),
|
||||
account_id BIGINT NOT NULL REFERENCES account (id),
|
||||
file_name TEXT NOT NULL,
|
||||
dialect TEXT NOT NULL,
|
||||
file_bytes BYTEA NOT NULL,
|
||||
summary JSONB NOT NULL,
|
||||
records JSONB NOT NULL,
|
||||
fingerprint TEXT NOT NULL,
|
||||
unknown_columns TEXT[] NOT NULL DEFAULT '{}',
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- import_run — the durable record of one confirmed import (PUC-8); created only at
|
||||
-- confirm (PUC-4).
|
||||
CREATE TABLE import_run (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
storefront_id BIGINT NOT NULL REFERENCES storefront (id),
|
||||
account_id BIGINT NOT NULL REFERENCES account (id),
|
||||
file_name TEXT NOT NULL,
|
||||
dialect TEXT NOT NULL,
|
||||
products_added INTEGER NOT NULL,
|
||||
products_updated INTEGER NOT NULL,
|
||||
rows_errored INTEGER NOT NULL,
|
||||
status TEXT NOT NULL
|
||||
CHECK (status IN ('applying', 'fetching_images', 'complete', 'complete_with_problems')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
completed_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX import_run_history_idx ON import_run (storefront_id, created_at DESC);
|
||||
|
||||
-- import_run_error — one row per rejected CSV row (PUC-5), merchant-language message.
|
||||
CREATE TABLE import_run_error (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
run_id BIGINT NOT NULL REFERENCES import_run (id),
|
||||
line_number INTEGER NOT NULL,
|
||||
column_name TEXT,
|
||||
message TEXT NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE product_image
|
||||
ADD CONSTRAINT product_image_run_fk FOREIGN KEY (import_run_id) REFERENCES import_run (id);
|
||||
@@ -5,3 +5,5 @@ psycopg[binary]>=3.1
|
||||
psycopg-pool>=3.2
|
||||
pytest>=8.0
|
||||
import-linter>=2.0
|
||||
nh3>=0.2
|
||||
python-multipart>=0.0.9
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import re
|
||||
|
||||
import psycopg
|
||||
import pytest
|
||||
|
||||
from app.domains import accounts
|
||||
from app.platform import db, mailer
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def conn(fresh_db_url):
|
||||
with psycopg.connect(fresh_db_url) as c:
|
||||
db.migrate(c)
|
||||
yield c
|
||||
|
||||
|
||||
def _code_in(outbox) -> str:
|
||||
# The 6-digit code appears in the latest message body (LogMailer dev channel).
|
||||
return re.search(r"\b(\d{6})\b", outbox[-1].body).group(1)
|
||||
|
||||
|
||||
def test_14_01_0002_request_code_sends_a_six_digit_code(conn):
|
||||
m = mailer.LogMailer()
|
||||
accounts.request_code(conn, m, "Merchant@Example.com")
|
||||
assert len(m.outbox) == 1
|
||||
assert m.outbox[-1].to == "merchant@example.com" # normalized recipient (INV-2)
|
||||
assert re.search(r"\b\d{6}\b", m.outbox[-1].body)
|
||||
|
||||
|
||||
def test_14_01_0003_request_code_stores_only_a_hash(conn):
|
||||
m = mailer.LogMailer()
|
||||
accounts.request_code(conn, m, "merchant@example.com")
|
||||
code = _code_in(m.outbox)
|
||||
rows = conn.execute("SELECT code_hash FROM auth_code WHERE email = %s", ("merchant@example.com",)).fetchall()
|
||||
assert len(rows) == 1
|
||||
# The plaintext code is never stored (INV-3).
|
||||
assert code not in rows[0][0]
|
||||
assert len(rows[0][0]) >= 16 # a hash, not the 6-digit code
|
||||
|
||||
|
||||
def test_request_code_uniform_for_new_and_known(conn):
|
||||
# No account enumeration (§6.6): the call behaves identically whether or not the email
|
||||
# already has an account — it never signals existence and never raises.
|
||||
m = mailer.LogMailer()
|
||||
accounts.request_code(conn, m, "newcomer@example.com") # no account exists
|
||||
# create an account, then request again for a known email
|
||||
conn.execute("INSERT INTO account (email) VALUES (%s)", ("known@example.com",))
|
||||
conn.commit()
|
||||
accounts.request_code(conn, m, "known@example.com")
|
||||
assert len(m.outbox) == 2 # both sent a code; caller cannot tell new from known
|
||||
|
||||
|
||||
def test_puc_02c_resend_is_rate_limited(conn):
|
||||
m = mailer.LogMailer()
|
||||
accounts.request_code(conn, m, "merchant@example.com")
|
||||
with pytest.raises(accounts.ResendCooldown) as exc:
|
||||
accounts.request_code(conn, m, "merchant@example.com")
|
||||
assert 0 < exc.value.retry_after_s <= 60
|
||||
assert len(m.outbox) == 1 # the second code was NOT sent
|
||||
|
||||
|
||||
def test_request_code_rejects_invalid_email(conn):
|
||||
m = mailer.LogMailer()
|
||||
with pytest.raises(accounts.InvalidEmail):
|
||||
accounts.request_code(conn, m, "not-an-email")
|
||||
assert m.outbox == []
|
||||
|
||||
|
||||
class _FailingMailer:
|
||||
"""A mailer whose relay always refuses — the INV-9 honest-failure path."""
|
||||
|
||||
def send(self, to: str, subject: str, body: str) -> None:
|
||||
raise mailer.MailerError("relay refused")
|
||||
|
||||
|
||||
def test_delivery_failure_leaves_no_orphan_code(conn):
|
||||
# ecomm#7: send happens BEFORE commit — a failed delivery must not strand an
|
||||
# auth_code row (which would also trip the resend cooldown for 60s).
|
||||
with pytest.raises(accounts.DeliveryFailed):
|
||||
accounts.request_code(conn, _FailingMailer(), "merchant@example.com")
|
||||
rows = conn.execute("SELECT count(*) FROM auth_code").fetchone()[0]
|
||||
assert rows == 0
|
||||
|
||||
|
||||
def test_delivery_failure_does_not_trip_cooldown(conn):
|
||||
with pytest.raises(accounts.DeliveryFailed):
|
||||
accounts.request_code(conn, _FailingMailer(), "merchant@example.com")
|
||||
# the immediate retry (relay back up) succeeds — no ResendCooldown
|
||||
m = mailer.LogMailer()
|
||||
accounts.request_code(conn, m, "merchant@example.com")
|
||||
assert len(m.outbox) == 1
|
||||
@@ -0,0 +1,100 @@
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import psycopg
|
||||
import pytest
|
||||
|
||||
from app.domains import accounts
|
||||
from app.platform import db, mailer
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def conn(fresh_db_url):
|
||||
with psycopg.connect(fresh_db_url) as c:
|
||||
db.migrate(c)
|
||||
yield c
|
||||
|
||||
|
||||
def _issue(conn, email) -> str:
|
||||
m = mailer.LogMailer()
|
||||
accounts.request_code(conn, m, email)
|
||||
return re.search(r"\b(\d{6})\b", m.outbox[-1].body).group(1)
|
||||
|
||||
|
||||
def test_14_01_0004_verify_creates_account_and_reports_created(conn):
|
||||
code = _issue(conn, "merchant@example.com")
|
||||
account, created = accounts.verify(conn, "merchant@example.com", code)
|
||||
assert created is True
|
||||
assert account.email == "merchant@example.com"
|
||||
assert account.id > 0
|
||||
# the account row exists exactly once (INV-2)
|
||||
n = conn.execute("SELECT count(*) FROM account WHERE email = %s", ("merchant@example.com",)).fetchone()[0]
|
||||
assert n == 1
|
||||
|
||||
|
||||
def test_14_01_0009_returning_account_reports_not_created(conn):
|
||||
# First sign-up creates the account.
|
||||
code1 = _issue(conn, "merchant@example.com")
|
||||
a1, created1 = accounts.verify(conn, "merchant@example.com", code1)
|
||||
assert created1 is True
|
||||
# A later login resolves to the SAME account (INV-2 — email is canonical), created=False.
|
||||
code2 = _issue(conn, "Merchant@Example.com") # different casing, same identity
|
||||
a2, created2 = accounts.verify(conn, "Merchant@Example.com", code2)
|
||||
assert created2 is False
|
||||
assert a2.id == a1.id
|
||||
n = conn.execute("SELECT count(*) FROM account").fetchone()[0]
|
||||
assert n == 1 # no duplicate account (14.01.0010)
|
||||
|
||||
|
||||
def test_verify_consumes_the_code_single_use(conn):
|
||||
code = _issue(conn, "merchant@example.com")
|
||||
accounts.verify(conn, "merchant@example.com", code)
|
||||
# Reusing a consumed code is refused (single-use, INV-3): no live code -> CodeExpired.
|
||||
with pytest.raises(accounts.CodeExpired):
|
||||
accounts.verify(conn, "merchant@example.com", code)
|
||||
|
||||
|
||||
def test_puc_02a_wrong_code_decrements_attempts(conn):
|
||||
_issue(conn, "merchant@example.com")
|
||||
with pytest.raises(accounts.CodeMismatch) as exc:
|
||||
accounts.verify(conn, "merchant@example.com", "000000")
|
||||
# 5 attempts allowed; one wrong -> 4 remaining (INV-3).
|
||||
assert exc.value.attempts_remaining == 4
|
||||
|
||||
|
||||
def test_puc_02b_expired_code_is_refused(conn):
|
||||
code = _issue(conn, "merchant@example.com")
|
||||
# Force the live code to be expired.
|
||||
conn.execute(
|
||||
"UPDATE auth_code SET expires_at = %s WHERE email = %s",
|
||||
(datetime.now(timezone.utc) - timedelta(minutes=1), "merchant@example.com"),
|
||||
)
|
||||
conn.commit()
|
||||
with pytest.raises(accounts.CodeExpired):
|
||||
accounts.verify(conn, "merchant@example.com", code)
|
||||
|
||||
|
||||
def test_inv3_attempts_exhausted_invalidates_code(conn):
|
||||
code = _issue(conn, "merchant@example.com")
|
||||
# Five wrong attempts: the 5th raises CodeExhausted and invalidates the code.
|
||||
for _ in range(4):
|
||||
with pytest.raises(accounts.CodeMismatch):
|
||||
accounts.verify(conn, "merchant@example.com", "000000")
|
||||
with pytest.raises(accounts.CodeExhausted):
|
||||
accounts.verify(conn, "merchant@example.com", "000000")
|
||||
# Even the correct code no longer works — a fresh request is required.
|
||||
with pytest.raises((accounts.CodeExpired, accounts.CodeExhausted)):
|
||||
accounts.verify(conn, "merchant@example.com", code)
|
||||
|
||||
|
||||
def test_verify_with_no_request_is_code_expired(conn):
|
||||
with pytest.raises(accounts.CodeExpired):
|
||||
accounts.verify(conn, "stranger@example.com", "123456")
|
||||
|
||||
|
||||
def test_get_account_loads_by_id(conn):
|
||||
code = _issue(conn, "merchant@example.com")
|
||||
account, _ = accounts.verify(conn, "merchant@example.com", code)
|
||||
loaded = accounts.get_account(conn, account.id)
|
||||
assert loaded is not None and loaded.email == "merchant@example.com"
|
||||
assert accounts.get_account(conn, 999999) is None
|
||||
@@ -0,0 +1,146 @@
|
||||
import re
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
def _client(fresh_db_url) -> TestClient:
|
||||
# A LogMailer on app.state lets the test read back the issued code (§6.8).
|
||||
return TestClient(create_app(database_url=fresh_db_url))
|
||||
|
||||
|
||||
def _last_code(client) -> str:
|
||||
outbox = client.app.state.mailer.outbox
|
||||
return re.search(r"\b(\d{6})\b", outbox[-1].body).group(1)
|
||||
|
||||
|
||||
def test_request_code_returns_204(fresh_db_url):
|
||||
with _client(fresh_db_url) as client:
|
||||
resp = client.post("/api/auth/request-code", json={"email": "merchant@example.com"})
|
||||
assert resp.status_code == 204
|
||||
|
||||
|
||||
def test_request_code_invalid_email_is_400(fresh_db_url):
|
||||
with _client(fresh_db_url) as client:
|
||||
resp = client.post("/api/auth/request-code", json={"email": "nope"})
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["error"]["code"] == "invalid_email"
|
||||
|
||||
|
||||
def test_request_code_uniform_204_no_enumeration(fresh_db_url):
|
||||
# New and known emails both return 204 with no distinguishing body (§6.6).
|
||||
with _client(fresh_db_url) as client:
|
||||
r_new = client.post("/api/auth/request-code", json={"email": "newcomer@example.com"})
|
||||
# sign the newcomer up so the email becomes "known"
|
||||
code = _last_code(client)
|
||||
client.post("/api/auth/verify", json={"email": "newcomer@example.com", "code": code})
|
||||
r_known = client.post("/api/auth/request-code", json={"email": "newcomer@example.com"})
|
||||
assert r_new.status_code == r_known.status_code == 204
|
||||
assert r_new.content == r_known.content == b""
|
||||
|
||||
|
||||
def test_resend_cooldown_is_429_with_retry_after(fresh_db_url):
|
||||
with _client(fresh_db_url) as client:
|
||||
client.post("/api/auth/request-code", json={"email": "merchant@example.com"})
|
||||
resp = client.post("/api/auth/request-code", json={"email": "merchant@example.com"})
|
||||
assert resp.status_code == 429
|
||||
body = resp.json()["error"]
|
||||
assert body["code"] == "resend_cooldown"
|
||||
assert body["retry_after_s"] > 0
|
||||
|
||||
|
||||
def test_verify_sets_cookie_and_returns_shape(fresh_db_url):
|
||||
with _client(fresh_db_url) as client:
|
||||
client.post("/api/auth/request-code", json={"email": "merchant@example.com"})
|
||||
code = _last_code(client)
|
||||
resp = client.post("/api/auth/verify", json={"email": "merchant@example.com", "code": code})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data == {"account": {"email": "merchant@example.com"}, "storefront": None, "created": True}
|
||||
assert "ecomm_session" in resp.cookies
|
||||
|
||||
|
||||
def test_verify_returning_account_created_false(fresh_db_url):
|
||||
# BUC-2 over HTTP: a returning merchant resumes the same account, created=False. The
|
||||
# second request-code would hit the 60s cooldown, so backdate the consumed code's
|
||||
# created_at to satisfy it (rather than sleeping 60s in a test).
|
||||
import psycopg
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
with _client(fresh_db_url) as client:
|
||||
client.post("/api/auth/request-code", json={"email": "m@example.com"})
|
||||
r1 = client.post("/api/auth/verify", json={"email": "m@example.com", "code": _last_code(client)})
|
||||
assert r1.json()["created"] is True
|
||||
with psycopg.connect(fresh_db_url) as c:
|
||||
c.execute(
|
||||
"UPDATE auth_code SET created_at = %s WHERE email = %s",
|
||||
(datetime.now(timezone.utc) - timedelta(minutes=2), "m@example.com"),
|
||||
)
|
||||
c.commit()
|
||||
client.post("/api/auth/request-code", json={"email": "m@example.com"})
|
||||
r2 = client.post("/api/auth/verify", json={"email": "m@example.com", "code": _last_code(client)})
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["created"] is False
|
||||
assert r2.json()["account"] == {"email": "m@example.com"}
|
||||
|
||||
|
||||
def test_verify_wrong_code_is_400_code_mismatch(fresh_db_url):
|
||||
with _client(fresh_db_url) as client:
|
||||
client.post("/api/auth/request-code", json={"email": "merchant@example.com"})
|
||||
resp = client.post("/api/auth/verify", json={"email": "merchant@example.com", "code": "000000"})
|
||||
assert resp.status_code == 400
|
||||
body = resp.json()["error"]
|
||||
assert body["code"] == "code_mismatch"
|
||||
assert body["attempts_remaining"] == 4
|
||||
|
||||
|
||||
def test_verify_no_code_is_400_code_expired(fresh_db_url):
|
||||
with _client(fresh_db_url) as client:
|
||||
resp = client.post("/api/auth/verify", json={"email": "stranger@example.com", "code": "123456"})
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["error"]["code"] == "code_expired"
|
||||
|
||||
|
||||
def test_me_requires_session(fresh_db_url):
|
||||
with _client(fresh_db_url) as client:
|
||||
resp = client.get("/api/auth/me")
|
||||
assert resp.status_code == 401
|
||||
assert resp.json()["error"]["code"] == "unauthenticated"
|
||||
|
||||
|
||||
def test_me_returns_account_with_session(fresh_db_url):
|
||||
with _client(fresh_db_url) as client:
|
||||
client.post("/api/auth/request-code", json={"email": "merchant@example.com"})
|
||||
client.post("/api/auth/verify", json={"email": "merchant@example.com", "code": _last_code(client)})
|
||||
resp = client.get("/api/auth/me") # TestClient carries the cookie set by verify
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"account": {"email": "merchant@example.com"}, "storefront": None}
|
||||
|
||||
|
||||
def test_puc_09_logout_clears_session(fresh_db_url):
|
||||
with _client(fresh_db_url) as client:
|
||||
client.post("/api/auth/request-code", json={"email": "merchant@example.com"})
|
||||
client.post("/api/auth/verify", json={"email": "merchant@example.com", "code": _last_code(client)})
|
||||
assert client.get("/api/auth/me").status_code == 200
|
||||
logout = client.post("/api/auth/logout")
|
||||
assert logout.status_code == 204
|
||||
# after logout the cookie is cleared -> /me is unauthenticated again
|
||||
client.cookies.clear()
|
||||
assert client.get("/api/auth/me").status_code == 401
|
||||
|
||||
|
||||
def test_request_code_delivery_failure_is_502(fresh_db_url, monkeypatch):
|
||||
# INV-9 honest failure over HTTP (§6.4): the relay refused -> 502 delivery_failed,
|
||||
# never a fake 204. Force the app's mailer to fail after startup.
|
||||
from app.platform import mailer as mailer_mod
|
||||
|
||||
class _FailingMailer:
|
||||
def send(self, to, subject, body):
|
||||
raise mailer_mod.MailerError("relay refused")
|
||||
|
||||
with _client(fresh_db_url) as client:
|
||||
client.app.state.mailer = _FailingMailer()
|
||||
resp = client.post("/api/auth/request-code", json={"email": "merchant@example.com"})
|
||||
assert resp.status_code == 502
|
||||
assert resp.json()["error"]["code"] == "delivery_failed"
|
||||
@@ -0,0 +1,42 @@
|
||||
"""INV-1's enforcement (SD-0001 §6.8): from an empty database, one test walks the whole
|
||||
flow — request-code -> verify -> create-storefront -> /me — asserting no step needed
|
||||
seeded state. Migration idempotence (the second INV-1 test) lives in test_migrations.py."""
|
||||
import re
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
def test_inv_1_bootstrap_whole_flow_from_empty(fresh_db_url):
|
||||
# fresh_db_url is a brand-new empty database; create_app() self-migrates (INV-7).
|
||||
with TestClient(create_app(database_url=fresh_db_url)) as client:
|
||||
# a fresh deployment serves healthz green before any row exists
|
||||
assert client.get("/healthz").json()["status"] == "ok"
|
||||
|
||||
# PUC-2: first visitor requests a code; it reaches them via the dev channel
|
||||
assert client.post(
|
||||
"/api/auth/request-code", json={"email": "first@example.com"}
|
||||
).status_code == 204
|
||||
code = re.search(r"\b(\d{6})\b", client.app.state.mailer.outbox[-1].body).group(1)
|
||||
|
||||
# verify creates account #1 — the first row, through the product alone
|
||||
verified = client.post(
|
||||
"/api/auth/verify", json={"email": "first@example.com", "code": code}
|
||||
)
|
||||
assert verified.status_code == 200
|
||||
assert verified.json()["created"] is True
|
||||
assert verified.json()["storefront"] is None # -> create-storefront (PUC-5)
|
||||
|
||||
# PUC-4: create the storefront (blank name -> generated default)
|
||||
created = client.post("/api/storefronts", json={})
|
||||
assert created.status_code == 201
|
||||
assert created.json()["name"] == "first's storefront"
|
||||
|
||||
# PUC-6/PUC-8: the admin answer — storefront + email from /me alone
|
||||
me = client.get("/api/auth/me")
|
||||
assert me.status_code == 200
|
||||
assert me.json() == {
|
||||
"account": {"email": "first@example.com"},
|
||||
"storefront": created.json(),
|
||||
}
|
||||
@@ -11,3 +11,33 @@ def test_database_url_defaults_to_local_compose(monkeypatch):
|
||||
def test_database_url_honors_env(monkeypatch):
|
||||
monkeypatch.setenv("ECOMM_DATABASE_URL", "postgresql://x:y@db:5432/z")
|
||||
assert config.database_url() == "postgresql://x:y@db:5432/z"
|
||||
|
||||
|
||||
def test_session_secret_defaults_for_dev(monkeypatch):
|
||||
monkeypatch.delenv("ECOMM_SESSION_SECRET", raising=False)
|
||||
# A non-empty dev default so localhost works with no setup; deployed overrides it.
|
||||
assert config.session_secret()
|
||||
|
||||
|
||||
def test_session_secret_honors_env(monkeypatch):
|
||||
monkeypatch.setenv("ECOMM_SESSION_SECRET", "s3cret")
|
||||
assert config.session_secret() == "s3cret"
|
||||
|
||||
|
||||
def test_cookie_secure_defaults_false(monkeypatch):
|
||||
monkeypatch.delenv("ECOMM_COOKIE_SECURE", raising=False)
|
||||
assert config.cookie_secure() is False
|
||||
|
||||
|
||||
def test_cookie_secure_truthy_env(monkeypatch):
|
||||
monkeypatch.setenv("ECOMM_COOKIE_SECURE", "1")
|
||||
assert config.cookie_secure() is True
|
||||
monkeypatch.setenv("ECOMM_COOKIE_SECURE", "true")
|
||||
assert config.cookie_secure() is True
|
||||
monkeypatch.setenv("ECOMM_COOKIE_SECURE", "0")
|
||||
assert config.cookie_secure() is False
|
||||
|
||||
|
||||
def test_mailer_kind_defaults_to_log(monkeypatch):
|
||||
monkeypatch.delenv("ECOMM_MAILER", raising=False)
|
||||
assert config.mailer_kind() == "log"
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import create_app
|
||||
|
||||
# /healthz reports the VERSION file's value — the flotilla deploy gate compares
|
||||
# body.version against the pinned target (flotilla-core SPEC §8.1 phase 8).
|
||||
_VERSION = (Path(__file__).resolve().parents[2] / "VERSION").read_text().strip()
|
||||
|
||||
|
||||
def test_healthz_ok_on_migrated_empty_db(fresh_db_url):
|
||||
app = create_app(database_url=fresh_db_url)
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/healthz")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"status": "ok"}
|
||||
assert resp.json() == {"status": "ok", "version": _VERSION}
|
||||
|
||||
|
||||
def test_startup_migrates_from_empty(fresh_db_url):
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
from app.platform import mailer
|
||||
|
||||
|
||||
def test_logmailer_captures_to_outbox():
|
||||
m = mailer.LogMailer()
|
||||
assert m.outbox == []
|
||||
m.send("merchant@example.com", "Your ecomm code: 123456", "Code: 123456 (valid 10 min)")
|
||||
assert len(m.outbox) == 1
|
||||
msg = m.outbox[-1]
|
||||
assert msg.to == "merchant@example.com"
|
||||
assert msg.subject == "Your ecomm code: 123456"
|
||||
assert "123456" in msg.body
|
||||
|
||||
|
||||
def test_build_mailer_log_kind():
|
||||
m = mailer.build_mailer("log")
|
||||
assert isinstance(m, mailer.LogMailer)
|
||||
|
||||
|
||||
class _FakeSMTP:
|
||||
"""Stand-in for smtplib.SMTP capturing the call sequence (no network)."""
|
||||
|
||||
instances: list["_FakeSMTP"] = []
|
||||
fail_on_send = False
|
||||
|
||||
def __init__(self, host, port, timeout=None):
|
||||
self.host, self.port, self.timeout = host, port, timeout
|
||||
self.calls: list[str] = []
|
||||
self.message = None
|
||||
_FakeSMTP.instances.append(self)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def starttls(self):
|
||||
self.calls.append("starttls")
|
||||
|
||||
def login(self, user, password):
|
||||
self.calls.append(f"login:{user}")
|
||||
|
||||
def send_message(self, msg):
|
||||
if _FakeSMTP.fail_on_send:
|
||||
raise RuntimeError("relay refused")
|
||||
self.calls.append("send_message")
|
||||
self.message = msg
|
||||
|
||||
|
||||
def _smtp_env(monkeypatch):
|
||||
monkeypatch.setattr(mailer.smtplib, "SMTP", _FakeSMTP)
|
||||
_FakeSMTP.instances.clear()
|
||||
_FakeSMTP.fail_on_send = False
|
||||
monkeypatch.setenv("ECOMM_SMTP_HOST", "smtp.example.com")
|
||||
monkeypatch.setenv("ECOMM_SMTP_PORT", "587")
|
||||
monkeypatch.setenv("ECOMM_SMTP_USER", "sender@example.com")
|
||||
monkeypatch.setenv("ECOMM_SMTP_PASSWORD", "not-a-real-password")
|
||||
monkeypatch.setenv("ECOMM_SMTP_FROM", "ecomm <sender@example.com>")
|
||||
|
||||
|
||||
def test_build_mailer_smtp_builds_from_config(monkeypatch):
|
||||
_smtp_env(monkeypatch)
|
||||
m = mailer.build_mailer("smtp")
|
||||
assert isinstance(m, mailer.SmtpMailer)
|
||||
|
||||
|
||||
def test_smtpmailer_sends_via_starttls_login(monkeypatch):
|
||||
_smtp_env(monkeypatch)
|
||||
m = mailer.build_mailer("smtp")
|
||||
m.send("merchant@example.com", "Your ecomm code: 123456", "Code: 123456")
|
||||
smtp = _FakeSMTP.instances[-1]
|
||||
assert (smtp.host, smtp.port) == ("smtp.example.com", 587)
|
||||
assert smtp.calls == ["starttls", "login:sender@example.com", "send_message"]
|
||||
assert smtp.message["To"] == "merchant@example.com"
|
||||
assert smtp.message["Subject"] == "Your ecomm code: 123456"
|
||||
assert smtp.message["From"] == "ecomm <sender@example.com>"
|
||||
assert "123456" in smtp.message.get_content()
|
||||
|
||||
|
||||
def test_smtpmailer_failure_raises_mailer_error(monkeypatch):
|
||||
_smtp_env(monkeypatch)
|
||||
_FakeSMTP.fail_on_send = True
|
||||
import pytest
|
||||
|
||||
m = mailer.build_mailer("smtp")
|
||||
with pytest.raises(mailer.MailerError):
|
||||
m.send("merchant@example.com", "subject", "body")
|
||||
|
||||
|
||||
def test_smtpmailer_never_logs_the_body(monkeypatch):
|
||||
# §6.6 log hygiene: codes never appear in deployed logs. LogMailer logging the body
|
||||
# is dev-only by configuration; the deployed adapter must not. The handler attaches
|
||||
# directly to the ecomm.mailer logger (it doesn't propagate to root).
|
||||
import logging
|
||||
|
||||
_smtp_env(monkeypatch)
|
||||
records: list[str] = []
|
||||
handler = logging.Handler()
|
||||
handler.emit = lambda r: records.append(r.getMessage()) # type: ignore[method-assign]
|
||||
lg = logging.getLogger("ecomm.mailer")
|
||||
lg.addHandler(handler)
|
||||
lg.setLevel(logging.DEBUG)
|
||||
try:
|
||||
mailer.build_mailer("smtp").send(
|
||||
"merchant@example.com", "Your ecomm code: 123456", "Code: 123456"
|
||||
)
|
||||
finally:
|
||||
lg.removeHandler(handler)
|
||||
assert records, "the deployed adapter should log the send event (observability §6.6)"
|
||||
assert all("123456" not in r and "merchant@example.com" not in r for r in records)
|
||||
@@ -1,4 +1,5 @@
|
||||
import psycopg
|
||||
import pytest
|
||||
|
||||
from app.platform import db
|
||||
|
||||
@@ -12,10 +13,10 @@ def _table_names(conn) -> set[str]:
|
||||
return {r[0] for r in rows}
|
||||
|
||||
|
||||
def test_migrate_from_empty_applies_0001(fresh_db_url):
|
||||
def test_migrate_from_empty_applies_all(fresh_db_url):
|
||||
with psycopg.connect(fresh_db_url) as conn:
|
||||
applied = db.migrate(conn)
|
||||
assert applied == ["0001_init.sql"]
|
||||
assert applied == ["0001_init.sql", "0002_products.sql"]
|
||||
with psycopg.connect(fresh_db_url) as conn:
|
||||
assert _TABLES.issubset(_table_names(conn))
|
||||
|
||||
@@ -41,3 +42,24 @@ def test_membership_has_no_unique_account_constraint(fresh_db_url):
|
||||
).fetchall()
|
||||
defs = " ".join(r[0] for r in rows).lower()
|
||||
assert "unique" not in defs.replace("primary key", "") or "(account_id)" not in defs
|
||||
|
||||
|
||||
def test_0002_products_tables_exist(fresh_db_url):
|
||||
with psycopg.connect(fresh_db_url) as conn:
|
||||
db.migrate(conn)
|
||||
for table in ("product", "variant", "product_image", "import_draft", "import_run", "import_run_error"):
|
||||
assert conn.execute("SELECT to_regclass(%s)", (f"public.{table}",)).fetchone()[0] == table
|
||||
|
||||
|
||||
def test_0002_variant_option_combo_unique_treats_nulls_as_equal(fresh_db_url):
|
||||
# INV-13: the no-option product's single variant has NULL option values; a second
|
||||
# all-NULL combo must collide (NULLS NOT DISTINCT).
|
||||
with psycopg.connect(fresh_db_url) as conn:
|
||||
db.migrate(conn)
|
||||
sf = conn.execute("INSERT INTO storefront (name) VALUES ('s') RETURNING id").fetchone()[0]
|
||||
pid = conn.execute(
|
||||
"INSERT INTO product (storefront_id, handle, title) VALUES (%s,'h','T') RETURNING id", (sf,)
|
||||
).fetchone()[0]
|
||||
conn.execute("INSERT INTO variant (product_id, position) VALUES (%s, 1)", (pid,))
|
||||
with pytest.raises(psycopg.errors.UniqueViolation):
|
||||
conn.execute("INSERT INTO variant (product_id, position) VALUES (%s, 2)", (pid,))
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""§6.5.1 file-level codec — parse, caps, required columns (PUC-5a fixtures)."""
|
||||
import pytest
|
||||
|
||||
from app.domains.products import FileRejected
|
||||
from app.domains.products.codec import parse_csv
|
||||
|
||||
|
||||
def _csv(*lines: str) -> bytes:
|
||||
return ("\n".join(lines) + "\n").encode()
|
||||
|
||||
|
||||
GOOD = _csv(
|
||||
"Handle,Title,Variant Price,Bogus Column",
|
||||
"moon-mug,Moon Mug,18.00,x",
|
||||
"star-tee,Star Tee,24.00,y",
|
||||
)
|
||||
|
||||
|
||||
def test_parses_header_rows_and_unknown_columns():
|
||||
parsed = parse_csv(GOOD)
|
||||
assert parsed.dialect == "canonical"
|
||||
assert parsed.unknown_columns == ["Bogus Column"]
|
||||
assert [r.line_number for r in parsed.rows] == [2, 3]
|
||||
assert parsed.rows[0].cells["Handle"] == "moon-mug"
|
||||
assert parsed.rows[0].cells["Variant Price"] == "18.00"
|
||||
assert "Bogus Column" not in parsed.rows[0].cells
|
||||
|
||||
|
||||
def test_bom_tolerated():
|
||||
parsed = parse_csv(b"\xef\xbb\xbf" + GOOD)
|
||||
assert parsed.rows[0].cells["Handle"] == "moon-mug"
|
||||
|
||||
|
||||
def test_quoted_cells_rfc4180():
|
||||
parsed = parse_csv(_csv("Handle,Title,Tags", 'mug,"The ""Best"" Mug","a, b"'))
|
||||
assert parsed.rows[0].cells["Title"] == 'The "Best" Mug'
|
||||
assert parsed.rows[0].cells["Tags"] == "a, b"
|
||||
|
||||
|
||||
def test_empty_rows_skipped_short_rows_padded():
|
||||
parsed = parse_csv(_csv("Handle,Title,Vendor", "mug,Mug", "", ",,", "tee,Tee,Acme"))
|
||||
assert [r.cells["Handle"] for r in parsed.rows] == ["mug", "tee"]
|
||||
assert parsed.rows[0].cells["Vendor"] == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"data,code",
|
||||
[
|
||||
(b"\xff\xfe\x00garbage\x00", "not_csv"),
|
||||
(b"", "not_csv"),
|
||||
(_csv("Title,Vendor", "Mug,Acme"), "missing_required_column"),
|
||||
(_csv("Handle,Vendor", "mug,Acme"), "missing_required_column"),
|
||||
(
|
||||
_csv("Handle,Title", *(f"h{i},T{i}" for i in range(5001))),
|
||||
"too_many_rows",
|
||||
),
|
||||
(b"Handle,Title\n" + b"x" * (10 * 1024 * 1024), "file_too_large"),
|
||||
],
|
||||
)
|
||||
def test_file_level_rejections(data, code):
|
||||
with pytest.raises(FileRejected) as exc:
|
||||
parse_csv(data)
|
||||
assert exc.value.code == code
|
||||
|
||||
|
||||
def test_missing_column_message_names_the_column():
|
||||
with pytest.raises(FileRejected) as exc:
|
||||
parse_csv(_csv("Handle,Vendor", "mug,Acme"))
|
||||
assert "'Title'" in exc.value.message
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Diff engine — classification, blank-vs-absent, option matching, fingerprint (§6.8)."""
|
||||
from decimal import Decimal
|
||||
|
||||
from app.domains.products.codec import parse_csv
|
||||
from app.domains.products.diff import (
|
||||
CatalogImage, CatalogProduct, CatalogVariant, compute_diff,
|
||||
)
|
||||
from app.domains.products.validate import build_products
|
||||
|
||||
|
||||
def _canon(*lines: str):
|
||||
return build_products(parse_csv(("\n".join(lines) + "\n").encode()))
|
||||
|
||||
|
||||
def _catalog_mug(**overrides):
|
||||
fields = {
|
||||
"title": "Moon Mug", "description_html": None, "vendor": "Acme",
|
||||
"product_type": "standalone", "google_product_category": None,
|
||||
"tags": ["kitchen"], "status": "active", "published": True,
|
||||
} | overrides
|
||||
return {
|
||||
"moon-mug": CatalogProduct(
|
||||
id=1, handle="moon-mug", title=fields["title"],
|
||||
option_names=(None, None, None), fields=fields,
|
||||
variants=[CatalogVariant(id=10, options=(None, None, None), position=1,
|
||||
fields={"sku": "SKU-1", "barcode": None, "price": Decimal("18.00"),
|
||||
"cost": None, "weight": None, "weight_unit": None,
|
||||
"volume": None, "volume_unit": None, "tax_id_1": None,
|
||||
"tax_id_2": None, "inventory_tracker": None,
|
||||
"inventory_qty": 40, "variant_image": None})],
|
||||
images=[CatalogImage(id=100, source_url="https://x/a.jpg", position=1, alt_text=None)],
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
HEADER = "Handle,Title,Vendor,Tags,Status,Variant SKU,Variant Price,Variant Inventory Qty,Image Src"
|
||||
MUG_ROW = 'moon-mug,Moon Mug,Acme,kitchen,active,SKU-1,18.00,40,https://x/a.jpg'
|
||||
|
||||
|
||||
def test_new_handle_classifies_add():
|
||||
diff = compute_diff({}, _canon(HEADER, MUG_ROW))
|
||||
[rec] = diff.records
|
||||
assert rec["kind"] == "add" and rec["handle"] == "moon-mug" and rec["variant_count"] == 1
|
||||
assert diff.summary == {"adds": 1, "updates": 0, "unchanged": 0, "errors": 0}
|
||||
assert rec["detail"]["set"]["status"] == "active"
|
||||
|
||||
|
||||
def test_identical_file_classifies_unchanged():
|
||||
diff = compute_diff(_catalog_mug(), _canon(HEADER, MUG_ROW))
|
||||
assert diff.records[0]["kind"] == "unchanged"
|
||||
assert diff.summary["unchanged"] == 1
|
||||
|
||||
|
||||
def test_changed_price_classifies_update_with_before_after():
|
||||
row = MUG_ROW.replace("18.00", "21.00")
|
||||
diff = compute_diff(_catalog_mug(), _canon(HEADER, row))
|
||||
[rec] = diff.records
|
||||
assert rec["kind"] == "update"
|
||||
[vchange] = rec["detail"]["variants"]
|
||||
assert {"field": "price", "before": "18.00", "after": "21.00"} in vchange["changes"]
|
||||
|
||||
|
||||
def test_absent_column_untouched_empty_cell_clears():
|
||||
# Vendor column absent: vendor stays Acme. Status present-but-empty: clears to default 'active' (already active -> no change).
|
||||
diff = compute_diff(
|
||||
_catalog_mug(),
|
||||
_canon("Handle,Title,Status,Variant SKU,Variant Price,Variant Inventory Qty,Image Src",
|
||||
"moon-mug,Moon Mug,,SKU-1,18.00,40,https://x/a.jpg"),
|
||||
)
|
||||
assert diff.records[0]["kind"] == "unchanged"
|
||||
|
||||
|
||||
def test_empty_cell_clear_shows_in_diff():
|
||||
# Vendor present-but-empty clears Acme -> None: an explicit, previewable change (§6.5.1).
|
||||
diff = compute_diff(
|
||||
_catalog_mug(),
|
||||
_canon("Handle,Title,Vendor,Variant SKU,Variant Price,Variant Inventory Qty,Image Src",
|
||||
"moon-mug,Moon Mug,,SKU-1,18.00,40,https://x/a.jpg"),
|
||||
)
|
||||
[rec] = diff.records
|
||||
assert rec["kind"] == "update"
|
||||
assert {"field": "vendor", "before": "Acme", "after": None} in rec["detail"]["changes"]
|
||||
|
||||
|
||||
def test_new_option_combo_is_variant_add_existing_untouched():
|
||||
catalog = _catalog_mug()
|
||||
diff = compute_diff(
|
||||
catalog,
|
||||
_canon("Handle,Title,Option1 Name,Option1 Value,Variant Price",
|
||||
"moon-mug,Moon Mug,Size,Large,25.00"),
|
||||
)
|
||||
[rec] = diff.records
|
||||
assert rec["kind"] == "update"
|
||||
kinds = [v.get("kind") for v in rec["detail"]["variants"]]
|
||||
assert "add" in kinds
|
||||
|
||||
|
||||
POSITION_HEADER = HEADER + ",Variant Position"
|
||||
|
||||
|
||||
def test_matching_variant_position_column_classifies_unchanged():
|
||||
# position is an attribute on CatalogVariant (not in fields{}); the compare
|
||||
# must read it from there, not invent a before:None.
|
||||
diff = compute_diff(_catalog_mug(), _canon(POSITION_HEADER, MUG_ROW + ",1"))
|
||||
assert diff.records[0]["kind"] == "unchanged"
|
||||
|
||||
|
||||
def test_changed_variant_position_reports_honest_before():
|
||||
diff = compute_diff(_catalog_mug(), _canon(POSITION_HEADER, MUG_ROW + ",2"))
|
||||
[rec] = diff.records
|
||||
assert rec["kind"] == "update"
|
||||
[ventry] = rec["detail"]["variants"]
|
||||
assert {"field": "position", "before": 1, "after": 2} in ventry["changes"]
|
||||
|
||||
|
||||
def test_blank_position_cell_resolves_to_file_order_unchanged():
|
||||
# A present-but-empty Variant Position cell resets to file order (the spec's
|
||||
# "defaults to file order"), never to NULL — here file order matches the
|
||||
# catalog position, so nothing changes.
|
||||
diff = compute_diff(_catalog_mug(), _canon(POSITION_HEADER, MUG_ROW + ","))
|
||||
assert diff.records[0]["kind"] == "unchanged"
|
||||
|
||||
|
||||
def test_blank_position_cell_updates_to_file_order():
|
||||
catalog = _catalog_mug()
|
||||
catalog["moon-mug"].variants[0].position = 2
|
||||
diff = compute_diff(catalog, _canon(POSITION_HEADER, MUG_ROW + ","))
|
||||
[rec] = diff.records
|
||||
assert rec["kind"] == "update"
|
||||
[ventry] = rec["detail"]["variants"]
|
||||
assert {"field": "position", "before": 2, "after": 1} in ventry["changes"]
|
||||
|
||||
|
||||
def test_error_product_classifies_error():
|
||||
diff = compute_diff({}, _canon("Handle,Title,Variant Price", "mug,Mug,nope"))
|
||||
[rec] = diff.records
|
||||
assert rec["kind"] == "error"
|
||||
assert rec["detail"]["errors"][0]["column"] == "Variant Price"
|
||||
|
||||
|
||||
def test_fingerprint_stable_and_drift_sensitive():
|
||||
d1 = compute_diff(_catalog_mug(), _canon(HEADER, MUG_ROW))
|
||||
d2 = compute_diff(_catalog_mug(), _canon(HEADER, MUG_ROW))
|
||||
d3 = compute_diff(_catalog_mug(title="Renamed"), _canon(HEADER, MUG_ROW))
|
||||
assert d1.fingerprint == d2.fingerprint
|
||||
assert d1.fingerprint != d3.fingerprint
|
||||
@@ -0,0 +1,95 @@
|
||||
"""§6.4 /api/products/* endpoint scenarios (PUC-2/3/3a/4/5/5a/8 + gates)."""
|
||||
import io
|
||||
import re
|
||||
from contextlib import contextmanager
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import create_app
|
||||
|
||||
GOOD_CSV = b"Handle,Title,Vendor,Variant Price\nmoon-mug,Moon Mug,Acme,18.00\n"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _merchant_client(fresh_db_url, email="m@example.com"):
|
||||
with TestClient(create_app(database_url=fresh_db_url)) as client:
|
||||
client.post("/api/auth/request-code", json={"email": email})
|
||||
code = re.search(r"\b(\d{6})\b", client.app.state.mailer.outbox[-1].body).group(1)
|
||||
client.post("/api/auth/verify", json={"email": email, "code": code})
|
||||
client.post("/api/storefronts", json={})
|
||||
yield client
|
||||
|
||||
|
||||
def _upload(client, data=GOOD_CSV, name="cat.csv"):
|
||||
return client.post("/api/products/imports", files={"file": (name, io.BytesIO(data), "text/csv")})
|
||||
|
||||
|
||||
def test_upload_returns_201_draft(fresh_db_url):
|
||||
with _merchant_client(fresh_db_url) as client:
|
||||
resp = _upload(client)
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["summary"]["adds"] == 1 and body["dialect"] == "canonical"
|
||||
|
||||
|
||||
def test_upload_rejections_carry_codes(fresh_db_url):
|
||||
with _merchant_client(fresh_db_url) as client:
|
||||
resp = _upload(client, b"Vendor\nAcme\n")
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["error"]["code"] == "missing_required_column"
|
||||
resp = _upload(client, b"Handle,Title\n" + b"x" * (10 * 1024 * 1024 + 1))
|
||||
assert resp.status_code == 413
|
||||
|
||||
|
||||
def test_unauthenticated_401_and_no_storefront_404(fresh_db_url):
|
||||
with TestClient(create_app(database_url=fresh_db_url)) as client:
|
||||
assert _upload(client).status_code == 401
|
||||
client.post("/api/auth/request-code", json={"email": "x@example.com"})
|
||||
code = re.search(r"\b(\d{6})\b", client.app.state.mailer.outbox[-1].body).group(1)
|
||||
client.post("/api/auth/verify", json={"email": "x@example.com", "code": code})
|
||||
assert _upload(client).status_code == 404
|
||||
|
||||
|
||||
def test_preview_confirm_run_flow(fresh_db_url):
|
||||
with _merchant_client(fresh_db_url) as client:
|
||||
draft = _upload(client).json()
|
||||
recs = client.get(f"/api/products/imports/drafts/{draft['id']}/records").json()["records"]
|
||||
assert recs[0]["kind"] == "add"
|
||||
run_id = client.post(f"/api/products/imports/drafts/{draft['id']}/confirm").json()["run_id"]
|
||||
run = client.get(f"/api/products/imports/runs/{run_id}").json()
|
||||
assert run["products_added"] == 1 and run["by"] == "m@example.com"
|
||||
assert client.get("/api/products/summary").json()["product_count"] == 1
|
||||
assert client.get("/api/products/imports/runs").json()["runs"][0]["id"] == run_id
|
||||
|
||||
|
||||
def test_cancel_no_trace_puc3a(fresh_db_url):
|
||||
with _merchant_client(fresh_db_url) as client:
|
||||
draft = _upload(client).json()
|
||||
assert client.delete(f"/api/products/imports/drafts/{draft['id']}").status_code == 204
|
||||
assert client.get(f"/api/products/imports/drafts/{draft['id']}").status_code == 404
|
||||
assert client.get("/api/products/imports/runs").json()["runs"] == []
|
||||
|
||||
|
||||
def test_confirm_conflicts(fresh_db_url):
|
||||
with _merchant_client(fresh_db_url) as client:
|
||||
d1 = _upload(client).json()
|
||||
client.post(f"/api/products/imports/drafts/{d1['id']}/confirm")
|
||||
d2 = _upload(client).json()
|
||||
resp = client.post(f"/api/products/imports/drafts/{d2['id']}/confirm")
|
||||
assert resp.status_code == 409 and resp.json()["error"]["code"] == "nothing_to_apply"
|
||||
|
||||
|
||||
def test_sample_csv_served(fresh_db_url):
|
||||
with TestClient(create_app(database_url=fresh_db_url)) as client:
|
||||
resp = client.get("/api/products/sample.csv")
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"].startswith("text/csv")
|
||||
assert resp.text.startswith("Handle,Title,")
|
||||
|
||||
|
||||
def test_sample_csv_imports_clean(fresh_db_url):
|
||||
"""DOC-3 honesty: our own sample must validate with zero errors."""
|
||||
with _merchant_client(fresh_db_url) as client:
|
||||
sample = client.get("/api/products/sample.csv").content
|
||||
body = _upload(client, sample, "sample.csv").json()
|
||||
assert body["summary"]["errors"] == 0 and body["summary"]["adds"] == 2
|
||||
@@ -0,0 +1,79 @@
|
||||
"""SD-0002 invariants: INV-10 (never deletes), INV-14 (two-storefront zero bleed),
|
||||
apply transactionality (§6.8), TEL-6."""
|
||||
import json
|
||||
import logging
|
||||
|
||||
import psycopg
|
||||
import pytest
|
||||
|
||||
from app.domains import products
|
||||
from app.domains.products import repo, service
|
||||
from app.platform import db
|
||||
|
||||
CSV_A = b"Handle,Title,Variant Price\nmug,Mug,10.00\ntee,Tee,20.00\n"
|
||||
CSV_PARTIAL = b"Handle,Title,Variant Price\nmug,Mug,12.00\n"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def migrated_conn(fresh_db_url):
|
||||
with psycopg.connect(fresh_db_url) as conn:
|
||||
db.migrate(conn)
|
||||
yield conn
|
||||
|
||||
|
||||
def _merchant(conn, email="m@example.com", shop="Shop"):
|
||||
acct = conn.execute("INSERT INTO account (email) VALUES (%s) RETURNING id", (email,)).fetchone()[0]
|
||||
sf = conn.execute("INSERT INTO storefront (name) VALUES (%s) RETURNING id", (shop,)).fetchone()[0]
|
||||
conn.execute("INSERT INTO storefront_membership (account_id, storefront_id) VALUES (%s,%s)", (acct, sf))
|
||||
conn.commit()
|
||||
return acct, sf
|
||||
|
||||
|
||||
def _import(conn, acct, sf, data):
|
||||
d = products.import_validate(conn, sf, acct, "f.csv", data)
|
||||
return products.confirm_draft(conn, sf, acct, d["id"])
|
||||
|
||||
|
||||
def test_inv10_partial_file_never_deletes(migrated_conn):
|
||||
acct, sf = _merchant(migrated_conn)
|
||||
_import(migrated_conn, acct, sf, CSV_A)
|
||||
before = migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0]
|
||||
_import(migrated_conn, acct, sf, CSV_PARTIAL)
|
||||
after = migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0]
|
||||
assert after >= before == 2
|
||||
|
||||
|
||||
def test_inv14_two_storefronts_zero_bleed(migrated_conn):
|
||||
acct1, sf1 = _merchant(migrated_conn)
|
||||
acct2, sf2 = _merchant(migrated_conn, "n@example.com", "Other")
|
||||
_import(migrated_conn, acct1, sf1, CSV_A)
|
||||
assert products.summary(migrated_conn, sf2)["product_count"] == 0
|
||||
assert products.list_runs(migrated_conn, sf2) == []
|
||||
_import(migrated_conn, acct2, sf2, CSV_A)
|
||||
assert products.summary(migrated_conn, sf2)["product_count"] == 2
|
||||
run1 = products.list_runs(migrated_conn, sf1)[0]
|
||||
with pytest.raises(products.RunNotFound):
|
||||
products.get_run(migrated_conn, sf2, run1["id"])
|
||||
|
||||
|
||||
def test_apply_failure_rolls_back_whole_transaction_tel6(migrated_conn, monkeypatch, caplog):
|
||||
acct, sf = _merchant(migrated_conn)
|
||||
d = products.import_validate(migrated_conn, sf, acct, "f.csv", CSV_A)
|
||||
|
||||
def boom(*a, **k):
|
||||
raise RuntimeError("mid-apply crash")
|
||||
monkeypatch.setattr(service.repo, "insert_run_errors", boom)
|
||||
lg = logging.getLogger("ecomm")
|
||||
prior = lg.propagate
|
||||
lg.propagate = True
|
||||
try:
|
||||
with caplog.at_level(logging.INFO, logger="ecomm.telemetry"):
|
||||
with pytest.raises(RuntimeError):
|
||||
products.confirm_draft(migrated_conn, sf, acct, d["id"])
|
||||
finally:
|
||||
lg.propagate = prior
|
||||
assert migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0] == 0
|
||||
assert migrated_conn.execute("SELECT count(*) FROM import_run").fetchone()[0] == 0
|
||||
assert migrated_conn.execute("SELECT count(*) FROM import_draft").fetchone()[0] == 1
|
||||
events = [json.loads(r.message) for r in caplog.records if r.name == "ecomm.telemetry"]
|
||||
assert any(e["event"] == "import_apply_failed" and e["error_class"] == "RuntimeError" for e in events)
|
||||
@@ -0,0 +1,222 @@
|
||||
"""products service — drafts: validate/preview/discard (PUC-2/3/3a/5a; INV-11)."""
|
||||
import json
|
||||
import logging
|
||||
|
||||
import psycopg
|
||||
import pytest
|
||||
|
||||
from app.domains import products
|
||||
from app.platform import db
|
||||
|
||||
GOOD_CSV = b"Handle,Title,Vendor,Variant Price\nmoon-mug,Moon Mug,Acme,18.00\nstar-tee,Star Tee,Acme,24.00\n"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def migrated_conn(fresh_db_url):
|
||||
with psycopg.connect(fresh_db_url) as conn:
|
||||
db.migrate(conn)
|
||||
yield conn
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def merchant(migrated_conn):
|
||||
acct = migrated_conn.execute(
|
||||
"INSERT INTO account (email) VALUES ('m@example.com') RETURNING id").fetchone()[0]
|
||||
sf = migrated_conn.execute(
|
||||
"INSERT INTO storefront (name) VALUES ('Shop') RETURNING id").fetchone()[0]
|
||||
migrated_conn.execute(
|
||||
"INSERT INTO storefront_membership (account_id, storefront_id) VALUES (%s,%s)", (acct, sf))
|
||||
migrated_conn.commit()
|
||||
return {"account_id": acct, "storefront_id": sf}
|
||||
|
||||
|
||||
def test_import_validate_creates_draft_with_summary(migrated_conn, merchant):
|
||||
draft = products.import_validate(
|
||||
migrated_conn, merchant["storefront_id"], merchant["account_id"], "cat.csv", GOOD_CSV)
|
||||
assert draft["dialect"] == "canonical"
|
||||
assert draft["summary"] == {"adds": 2, "updates": 0, "unchanged": 0, "errors": 0}
|
||||
assert draft["expires_at"]
|
||||
|
||||
|
||||
def test_validate_writes_nothing_to_catalog_inv11(migrated_conn, merchant):
|
||||
products.import_validate(
|
||||
migrated_conn, merchant["storefront_id"], merchant["account_id"], "cat.csv", GOOD_CSV)
|
||||
assert migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0] == 0
|
||||
assert migrated_conn.execute("SELECT count(*) FROM variant").fetchone()[0] == 0
|
||||
|
||||
|
||||
def test_file_rejection_leaves_no_draft(migrated_conn, merchant):
|
||||
with pytest.raises(products.FileRejected):
|
||||
products.import_validate(
|
||||
migrated_conn, merchant["storefront_id"], merchant["account_id"], "bad.csv",
|
||||
b"Vendor,Price\nAcme,1\n")
|
||||
assert migrated_conn.execute("SELECT count(*) FROM import_draft").fetchone()[0] == 0
|
||||
|
||||
|
||||
def test_records_paging_and_kind_filter(migrated_conn, merchant):
|
||||
draft = products.import_validate(
|
||||
migrated_conn, merchant["storefront_id"], merchant["account_id"], "cat.csv", GOOD_CSV)
|
||||
recs = products.get_draft_records(migrated_conn, merchant["storefront_id"], draft["id"])
|
||||
assert [r["handle"] for r in recs] == ["moon-mug", "star-tee"]
|
||||
adds = products.get_draft_records(
|
||||
migrated_conn, merchant["storefront_id"], draft["id"], kind="add", limit=1)
|
||||
assert len(adds) == 1 and adds[0]["kind"] == "add"
|
||||
|
||||
|
||||
def test_discard_deletes_no_trace_puc3a(migrated_conn, merchant):
|
||||
draft = products.import_validate(
|
||||
migrated_conn, merchant["storefront_id"], merchant["account_id"], "cat.csv", GOOD_CSV)
|
||||
products.discard_draft(migrated_conn, merchant["storefront_id"], draft["id"])
|
||||
assert migrated_conn.execute("SELECT count(*) FROM import_draft").fetchone()[0] == 0
|
||||
products.discard_draft(migrated_conn, merchant["storefront_id"], draft["id"]) # idempotent
|
||||
|
||||
|
||||
def test_draft_scoped_to_storefront_inv14(migrated_conn, merchant):
|
||||
draft = products.import_validate(
|
||||
migrated_conn, merchant["storefront_id"], merchant["account_id"], "cat.csv", GOOD_CSV)
|
||||
other_sf = migrated_conn.execute(
|
||||
"INSERT INTO storefront (name) VALUES ('Other') RETURNING id").fetchone()[0]
|
||||
migrated_conn.commit()
|
||||
with pytest.raises(products.DraftNotFound):
|
||||
products.get_draft(migrated_conn, other_sf, draft["id"])
|
||||
|
||||
|
||||
def test_expired_draft_raises_and_lazily_deletes(migrated_conn, merchant):
|
||||
draft = products.import_validate(
|
||||
migrated_conn, merchant["storefront_id"], merchant["account_id"], "cat.csv", GOOD_CSV)
|
||||
migrated_conn.execute(
|
||||
"UPDATE import_draft SET expires_at = now() - interval '1 minute' WHERE id = %s",
|
||||
(draft["id"],))
|
||||
migrated_conn.commit()
|
||||
with pytest.raises(products.DraftExpired):
|
||||
products.get_draft(migrated_conn, merchant["storefront_id"], draft["id"])
|
||||
assert migrated_conn.execute("SELECT count(*) FROM import_draft").fetchone()[0] == 0
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def telemetry_propagation():
|
||||
"""create_app() sets propagate=False on the parent "ecomm" logger
|
||||
(main._ensure_app_logging), which hides ecomm.telemetry records from caplog's
|
||||
root-logger handler whenever an API test ran first. Restore propagation here."""
|
||||
lg = logging.getLogger("ecomm")
|
||||
prior = lg.propagate
|
||||
lg.propagate = True
|
||||
yield
|
||||
lg.propagate = prior
|
||||
|
||||
|
||||
def test_tel1_emitted(migrated_conn, merchant, caplog, telemetry_propagation):
|
||||
with caplog.at_level(logging.INFO, logger="ecomm.telemetry"):
|
||||
products.import_validate(
|
||||
migrated_conn, merchant["storefront_id"], merchant["account_id"], "cat.csv", GOOD_CSV)
|
||||
events = [json.loads(r.message) for r in caplog.records if r.name == "ecomm.telemetry"]
|
||||
assert any(
|
||||
e["event"] == "import_draft_created" and e["adds"] == 2 and e["row_count"] == 2
|
||||
and "duration_ms" in e and e["unknown_columns_count"] == 0
|
||||
for e in events
|
||||
)
|
||||
|
||||
|
||||
UPDATE_CSV = b"Handle,Title,Vendor,Variant Price\nmoon-mug,Moon Mug,Acme,21.00\nstar-tee,Star Tee,Acme,24.00\n"
|
||||
MIXED_CSV = b"Handle,Title,Variant Price\ngood-mug,Mug,10.00\nbad-tee,Tee,not-a-price\n"
|
||||
|
||||
|
||||
def _validate(conn, m, data=GOOD_CSV):
|
||||
return products.import_validate(conn, m["storefront_id"], m["account_id"], "cat.csv", data)
|
||||
|
||||
|
||||
def test_confirm_applies_adds_and_records_run(migrated_conn, merchant):
|
||||
draft = _validate(migrated_conn, merchant)
|
||||
run_id = products.confirm_draft(
|
||||
migrated_conn, merchant["storefront_id"], merchant["account_id"], draft["id"])
|
||||
assert migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0] == 2
|
||||
run = products.get_run(migrated_conn, merchant["storefront_id"], run_id)
|
||||
assert run["products_added"] == 2 and run["status"] == "complete"
|
||||
assert run["by"] == "m@example.com"
|
||||
assert run["image_progress"] == {"done": 0, "total": 0} and run["image_outcomes"] == []
|
||||
assert migrated_conn.execute("SELECT count(*) FROM import_draft").fetchone()[0] == 0
|
||||
|
||||
|
||||
def test_confirm_update_changes_only_diffed_fields(migrated_conn, merchant):
|
||||
d1 = _validate(migrated_conn, merchant)
|
||||
products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d1["id"])
|
||||
d2 = _validate(migrated_conn, merchant, UPDATE_CSV)
|
||||
assert d2["summary"] == {"adds": 0, "updates": 1, "unchanged": 1, "errors": 0}
|
||||
products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d2["id"])
|
||||
price = migrated_conn.execute(
|
||||
"SELECT v.price FROM variant v JOIN product p ON p.id = v.product_id WHERE p.handle='moon-mug'"
|
||||
).fetchone()[0]
|
||||
assert str(price) == "21.00"
|
||||
assert migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0] == 2 # no dupes (BUC-3)
|
||||
|
||||
|
||||
def test_confirm_blank_position_cell_round_trips(migrated_conn, merchant):
|
||||
# A present-but-empty Variant Position cell resolves to file order at diff
|
||||
# time — never SET position = NULL (which would abort the confirm on the
|
||||
# NOT NULL constraint).
|
||||
d1 = _validate(migrated_conn, merchant)
|
||||
products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d1["id"])
|
||||
blank_position_csv = (
|
||||
b"Handle,Title,Vendor,Variant Price,Variant Position\n"
|
||||
b"moon-mug,Moon Mug,Acme,21.00,\n"
|
||||
b"star-tee,Star Tee,Acme,24.00,\n"
|
||||
)
|
||||
d2 = _validate(migrated_conn, merchant, blank_position_csv)
|
||||
products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d2["id"])
|
||||
price = migrated_conn.execute(
|
||||
"SELECT v.price FROM variant v JOIN product p ON p.id = v.product_id WHERE p.handle='moon-mug'"
|
||||
).fetchone()[0]
|
||||
assert str(price) == "21.00"
|
||||
|
||||
|
||||
def test_confirm_mixed_applies_valid_records_errors(migrated_conn, merchant):
|
||||
draft = _validate(migrated_conn, merchant, MIXED_CSV)
|
||||
run_id = products.confirm_draft(
|
||||
migrated_conn, merchant["storefront_id"], merchant["account_id"], draft["id"])
|
||||
assert migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0] == 1
|
||||
run = products.get_run(migrated_conn, merchant["storefront_id"], run_id)
|
||||
assert run["rows_errored"] == 1
|
||||
assert run["errors"][0]["column"] == "Variant Price"
|
||||
|
||||
|
||||
def test_confirm_stale_fingerprint_409_inv11(migrated_conn, merchant):
|
||||
draft = _validate(migrated_conn, merchant)
|
||||
other = _validate(migrated_conn, merchant)
|
||||
products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], other["id"])
|
||||
with pytest.raises(products.PreviewStale):
|
||||
products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], draft["id"])
|
||||
|
||||
|
||||
def test_confirm_nothing_to_apply_puc10(migrated_conn, merchant):
|
||||
d1 = _validate(migrated_conn, merchant)
|
||||
products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d1["id"])
|
||||
d2 = _validate(migrated_conn, merchant)
|
||||
assert d2["summary"]["unchanged"] == 2
|
||||
with pytest.raises(products.NothingToApply):
|
||||
products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d2["id"])
|
||||
|
||||
|
||||
def test_runs_history_newest_first(migrated_conn, merchant):
|
||||
d1 = _validate(migrated_conn, merchant)
|
||||
products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d1["id"])
|
||||
d2 = _validate(migrated_conn, merchant, UPDATE_CSV)
|
||||
r2 = products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d2["id"])
|
||||
runs = products.list_runs(migrated_conn, merchant["storefront_id"])
|
||||
assert [r["id"] for r in runs][0] == r2
|
||||
|
||||
|
||||
def test_summary_counts(migrated_conn, merchant):
|
||||
assert products.summary(migrated_conn, merchant["storefront_id"]) == {
|
||||
"product_count": 0, "image_problem_count": 0, "latest_run_id": None}
|
||||
d = _validate(migrated_conn, merchant)
|
||||
rid = products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d["id"])
|
||||
s = products.summary(migrated_conn, merchant["storefront_id"])
|
||||
assert s == {"product_count": 2, "image_problem_count": 0, "latest_run_id": rid}
|
||||
|
||||
|
||||
def test_tel2_emitted_on_confirm(migrated_conn, merchant, caplog, telemetry_propagation):
|
||||
draft = _validate(migrated_conn, merchant)
|
||||
with caplog.at_level(logging.INFO, logger="ecomm.telemetry"):
|
||||
products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], draft["id"])
|
||||
events = [json.loads(r.message) for r in caplog.records if r.name == "ecomm.telemetry"]
|
||||
assert any(e["event"] == "import_run_completed" and e["added"] == 2 for e in events)
|
||||
@@ -0,0 +1,164 @@
|
||||
"""§6.5.1 row validation — every row-error rule has a fixture (SD-0002 §6.8)."""
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from app.domains.products.codec import parse_csv
|
||||
from app.domains.products.validate import build_products
|
||||
|
||||
|
||||
def _products(*lines: str):
|
||||
return build_products(parse_csv(("\n".join(lines) + "\n").encode()))
|
||||
|
||||
|
||||
def _errors(*lines: str):
|
||||
return [e for p in _products(*lines) for e in p.errors]
|
||||
|
||||
|
||||
def test_simple_product_parses_clean():
|
||||
[p] = _products(
|
||||
"Handle,Title,Vendor,Tags,Status,Published,Variant Price,Variant SKU",
|
||||
"moon-mug,Moon Mug,Acme,\"kitchen, mugs\",active,TRUE,18.00,SKU-1",
|
||||
)
|
||||
assert p.valid and p.handle == "moon-mug" and p.title == "Moon Mug"
|
||||
assert p.fields["tags"] == ["kitchen", "mugs"]
|
||||
assert p.fields["status"] == "active" and p.fields["published"] is True
|
||||
[v] = p.variants
|
||||
assert v.options == (None, None, None)
|
||||
assert v.fields["price"] == Decimal("18.00") and v.fields["sku"] == "SKU-1"
|
||||
|
||||
|
||||
def test_option_product_groups_consecutive_rows():
|
||||
[p] = _products(
|
||||
"Handle,Title,Option1 Name,Option1 Value,Variant Price",
|
||||
"tee,Tee,Size,S,24.00",
|
||||
"tee,,,M,24.00",
|
||||
"tee,,,L,26.00",
|
||||
)
|
||||
assert p.valid and p.option_names == ("Size", None, None)
|
||||
assert [v.options[0] for v in p.variants] == ["S", "M", "L"]
|
||||
|
||||
|
||||
def test_image_only_rows_and_dedupe():
|
||||
[p] = _products(
|
||||
"Handle,Title,Image Src,Image Position,Image Alt Text",
|
||||
"mug,Mug,https://x/a.jpg,1,front",
|
||||
"mug,,https://x/b.jpg,2,back",
|
||||
"mug,,https://x/a.jpg,3,dupe",
|
||||
)
|
||||
assert p.valid
|
||||
assert [(i.source_url, i.position) for i in p.images] == [
|
||||
("https://x/a.jpg", 1), ("https://x/b.jpg", 2),
|
||||
]
|
||||
|
||||
|
||||
def test_variant_image_joins_product_images():
|
||||
[p] = _products(
|
||||
"Handle,Title,Variant Image",
|
||||
"mug,Mug,https://x/v.jpg",
|
||||
)
|
||||
assert p.variants[0].fields["variant_image"] == "https://x/v.jpg"
|
||||
assert [i.source_url for i in p.images] == ["https://x/v.jpg"]
|
||||
|
||||
|
||||
def test_description_sanitized_inv15():
|
||||
[p] = _products(
|
||||
"Handle,Title,Description",
|
||||
'mug,Mug,"<p onclick=\'x()\'>hi</p><script>evil()</script>"',
|
||||
)
|
||||
html = p.fields["description_html"]
|
||||
assert "<p>" in html and "script" not in html and "onclick" not in html
|
||||
|
||||
|
||||
def test_blank_cell_clears_absent_column_missing():
|
||||
[p] = _products("Handle,Title,Vendor", "mug,Mug,")
|
||||
assert p.fields["vendor"] is None # present-but-empty == clear
|
||||
assert "status" not in p.fields # absent column == untouched
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"header,row,column,fragment",
|
||||
[
|
||||
("Handle,Title", ",NoHandle", "Handle", "needs a Handle"),
|
||||
("Handle,Title", "Bad_Handle!,T", "Handle", "isn't a valid handle"),
|
||||
("Handle,Title", "mug,", "Title", "missing its Title"),
|
||||
("Handle,Title,Type", "mug,Mug,kit_virtual", "Type", "kits arrive"),
|
||||
("Handle,Title,Component 1 SKU", "mug,Mug,ABC", "Component 1 SKU", "kits arrive"),
|
||||
("Handle,Title,Status", "mug,Mug,live", "Status", "is not a status"),
|
||||
("Handle,Title,Published", "mug,Mug,YES", "Published", "is not TRUE or FALSE"),
|
||||
("Handle,Title,Variant Price", 'mug,Mug,"12,50"', "Variant Price", "is not a price"),
|
||||
("Handle,Title,Variant Cost", "mug,Mug,-3", "Variant Cost", "is not a price"),
|
||||
("Handle,Title,Variant Weight", "mug,Mug,heavy", "Variant Weight", "is not a number"),
|
||||
("Handle,Title,Variant Inventory Qty", "mug,Mug,3.5", "Variant Inventory Qty", "is not a whole number"),
|
||||
("Handle,Title,Variant Position", "mug,Mug,0", "Variant Position", "is not a position"),
|
||||
("Handle,Title,Option1 Value", "mug,Mug,Red", "Option1 Value", "no Option1 Name"),
|
||||
],
|
||||
)
|
||||
def test_row_error_rules(header, row, column, fragment):
|
||||
errors = _errors(header, row)
|
||||
assert any(e.column == column and fragment in e.message for e in errors), errors
|
||||
|
||||
|
||||
def test_missing_option_value_for_named_option():
|
||||
errors = _errors(
|
||||
"Handle,Title,Option1 Name,Option1 Value,Variant SKU",
|
||||
"tee,Tee,Size,S,A",
|
||||
"tee,,,,B",
|
||||
)
|
||||
assert any("missing its Option1 Value" in e.message and e.line_number == 3 for e in errors)
|
||||
|
||||
|
||||
def test_duplicate_option_combo_is_error():
|
||||
errors = _errors(
|
||||
"Handle,Title,Option1 Name,Option1 Value",
|
||||
"tee,Tee,Size,S",
|
||||
"tee,,,S",
|
||||
)
|
||||
assert any("duplicate variant" in e.message for e in errors)
|
||||
|
||||
|
||||
def test_second_variant_on_no_option_product_is_error():
|
||||
errors = _errors(
|
||||
"Handle,Title,Variant SKU",
|
||||
"mug,Mug,A",
|
||||
"mug,,B",
|
||||
)
|
||||
assert any("without options can have only one variant" in e.message for e in errors)
|
||||
|
||||
|
||||
def test_non_consecutive_handle_is_error():
|
||||
errors = _errors(
|
||||
"Handle,Title",
|
||||
"mug,Mug",
|
||||
"tee,Tee",
|
||||
"mug,",
|
||||
)
|
||||
assert any("must be consecutive" in e.message and e.line_number == 4 for e in errors)
|
||||
|
||||
|
||||
def test_no_data_row_is_error():
|
||||
errors = _errors(
|
||||
"Handle,Title,Variant SKU,Image Src",
|
||||
"mug,Mug,A,",
|
||||
"mug,,,",
|
||||
)
|
||||
assert any("no variant or image data" in e.message for e in errors)
|
||||
|
||||
|
||||
def test_errors_poison_their_product_only():
|
||||
products = _products(
|
||||
"Handle,Title,Variant Price",
|
||||
"good-mug,Mug,10.00",
|
||||
"bad-tee,Tee,not-a-price",
|
||||
)
|
||||
by_handle = {p.handle: p for p in products}
|
||||
assert by_handle["good-mug"].valid
|
||||
assert not by_handle["bad-tee"].valid
|
||||
|
||||
|
||||
def test_all_errors_collected_not_first_only():
|
||||
[p] = _products(
|
||||
"Handle,Title,Status,Variant Price",
|
||||
"mug,,bogus,abc",
|
||||
)
|
||||
assert len(p.errors) == 3 # missing Title + bad status + bad price
|
||||
@@ -0,0 +1,26 @@
|
||||
from app.platform import session
|
||||
|
||||
SECRET = "test-secret"
|
||||
|
||||
|
||||
def test_sign_then_verify_round_trips():
|
||||
token = session.sign({"account_id": 7, "email": "m@example.com"}, SECRET)
|
||||
assert session.verify(token, SECRET) == {"account_id": 7, "email": "m@example.com"}
|
||||
|
||||
|
||||
def test_verify_rejects_tampered_payload():
|
||||
token = session.sign({"account_id": 7}, SECRET)
|
||||
payload_b64, _, sig = token.partition(".")
|
||||
# Flip the payload but keep the old signature -> must be rejected.
|
||||
forged = session.sign({"account_id": 8}, SECRET).split(".")[0] + "." + sig
|
||||
assert session.verify(forged, SECRET) is None
|
||||
|
||||
|
||||
def test_verify_rejects_wrong_secret():
|
||||
token = session.sign({"account_id": 7}, SECRET)
|
||||
assert session.verify(token, "other-secret") is None
|
||||
|
||||
|
||||
def test_verify_rejects_garbage():
|
||||
assert session.verify("not-a-token", SECRET) is None
|
||||
assert session.verify("", SECRET) is None
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Deployed topology (launch-app SPEC §2): nginx proxies EVERYTHING to the backend, so
|
||||
the backend must serve the built SPA (frontend/dist) itself. Dev is unaffected — Vite
|
||||
serves the frontend and the default dist dir simply doesn't exist."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
def test_spa_served_when_dist_present(fresh_db_url, tmp_path):
|
||||
(tmp_path / "index.html").write_text("<!doctype html><title>ecomm spa</title>")
|
||||
with TestClient(create_app(database_url=fresh_db_url, static_dir=tmp_path)) as client:
|
||||
root = client.get("/")
|
||||
assert root.status_code == 200
|
||||
assert "ecomm spa" in root.text
|
||||
# API + health still win over the mount
|
||||
assert client.get("/healthz").json()["status"] == "ok"
|
||||
assert client.get("/api/auth/me").status_code == 401
|
||||
|
||||
|
||||
def test_no_mount_when_dist_absent(fresh_db_url, tmp_path):
|
||||
# An empty/missing dist (the dev case) must not 500 the app — / just 404s.
|
||||
with TestClient(create_app(database_url=fresh_db_url, static_dir=tmp_path / "nope")) as client:
|
||||
assert client.get("/").status_code == 404
|
||||
assert client.get("/healthz").json()["status"] == "ok"
|
||||
@@ -0,0 +1,154 @@
|
||||
"""SLICE-3 storefront creation — service + endpoint scenario tests (SD-0001 §6.5 PUC-4/7)."""
|
||||
import re
|
||||
from contextlib import contextmanager
|
||||
|
||||
import psycopg
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import create_app
|
||||
from app.domains import storefronts
|
||||
from app.platform import db
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def migrated_conn(fresh_db_url):
|
||||
with psycopg.connect(fresh_db_url) as conn:
|
||||
db.migrate(conn)
|
||||
yield conn
|
||||
|
||||
|
||||
def _account_id(conn, email="merchant@example.com"):
|
||||
return conn.execute(
|
||||
"INSERT INTO account (email) VALUES (%s) RETURNING id", (email,)
|
||||
).fetchone()[0]
|
||||
|
||||
|
||||
def test_create_storefront_with_name(migrated_conn):
|
||||
acct = _account_id(migrated_conn)
|
||||
sf = storefronts.create_storefront(migrated_conn, acct, "merchant@example.com", "Ben's Bets")
|
||||
assert sf.name == "Ben's Bets"
|
||||
assert storefronts.storefront_for(migrated_conn, acct) == sf
|
||||
|
||||
|
||||
def test_14_01_0026_blank_name_gets_generated_default(migrated_conn):
|
||||
acct = _account_id(migrated_conn)
|
||||
sf = storefronts.create_storefront(migrated_conn, acct, "merchant@example.com", None)
|
||||
assert sf.name == "merchant's storefront"
|
||||
|
||||
|
||||
def test_whitespace_name_treated_as_blank(migrated_conn):
|
||||
acct = _account_id(migrated_conn)
|
||||
sf = storefronts.create_storefront(migrated_conn, acct, "ben@bensbets.com", " ")
|
||||
assert sf.name == "ben's storefront"
|
||||
|
||||
|
||||
def test_second_create_refused_inv_4(migrated_conn):
|
||||
acct = _account_id(migrated_conn)
|
||||
storefronts.create_storefront(migrated_conn, acct, "merchant@example.com", "First")
|
||||
with pytest.raises(storefronts.AlreadyOwnsStorefront):
|
||||
storefronts.create_storefront(migrated_conn, acct, "merchant@example.com", "Second")
|
||||
|
||||
|
||||
def test_membership_row_is_owner(migrated_conn):
|
||||
acct = _account_id(migrated_conn)
|
||||
sf = storefronts.create_storefront(migrated_conn, acct, "merchant@example.com", None)
|
||||
role = migrated_conn.execute(
|
||||
"SELECT role FROM storefront_membership WHERE account_id = %s AND storefront_id = %s",
|
||||
(acct, sf.id),
|
||||
).fetchone()[0]
|
||||
assert role == "owner"
|
||||
|
||||
|
||||
def test_storefront_for_none_when_absent(migrated_conn):
|
||||
acct = _account_id(migrated_conn)
|
||||
assert storefronts.storefront_for(migrated_conn, acct) is None
|
||||
|
||||
|
||||
# ── endpoint scenario tests (§6.4 POST /api/storefronts; corpus 14.01.*) ──────────
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _signed_in_client(fresh_db_url, email="merchant@example.com"):
|
||||
with TestClient(create_app(database_url=fresh_db_url)) as client:
|
||||
client.post("/api/auth/request-code", json={"email": email})
|
||||
code = re.search(r"\b(\d{6})\b", client.app.state.mailer.outbox[-1].body).group(1)
|
||||
client.post("/api/auth/verify", json={"email": email, "code": code})
|
||||
yield client
|
||||
|
||||
|
||||
def test_14_01_0013_create_storefront_with_name(fresh_db_url):
|
||||
with _signed_in_client(fresh_db_url) as client:
|
||||
resp = client.post("/api/storefronts", json={"name": "Ben's Bets"})
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["name"] == "Ben's Bets"
|
||||
assert isinstance(resp.json()["id"], int)
|
||||
|
||||
|
||||
def test_14_01_0015_0016_nothing_but_a_name_is_asked(fresh_db_url):
|
||||
# No payment method, plan, or commitment: the request body needs nothing at all and
|
||||
# the response carries only the storefront — no plan/trial/billing fields.
|
||||
with _signed_in_client(fresh_db_url) as client:
|
||||
resp = client.post("/api/storefronts", json={})
|
||||
assert resp.status_code == 201
|
||||
assert set(resp.json().keys()) == {"id", "name"}
|
||||
|
||||
|
||||
def test_14_01_0025_create_lands_on_admin(fresh_db_url):
|
||||
# After create, the entry-routing answer carries the storefront -> admin (PUC-6).
|
||||
with _signed_in_client(fresh_db_url) as client:
|
||||
created = client.post("/api/storefronts", json={"name": "Ben's Bets"}).json()
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["storefront"] == created
|
||||
|
||||
|
||||
def test_puc_05_returning_without_storefront_routes_to_create(fresh_db_url):
|
||||
with _signed_in_client(fresh_db_url) as client:
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["storefront"] is None
|
||||
|
||||
|
||||
def test_14_01_0027_returning_with_storefront_straight_to_admin(fresh_db_url):
|
||||
# PUC-6: a returning merchant's verify response already carries the storefront.
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
email = "returning@example.com"
|
||||
with _signed_in_client(fresh_db_url, email) as client:
|
||||
client.post("/api/storefronts", json={"name": "Ben's Bets"})
|
||||
# fresh login: backdate the consumed code to clear the 60s resend cooldown
|
||||
with psycopg.connect(fresh_db_url) as c:
|
||||
c.execute(
|
||||
"UPDATE auth_code SET created_at = %s WHERE email = %s",
|
||||
(datetime.now(timezone.utc) - timedelta(minutes=2), email),
|
||||
)
|
||||
c.commit()
|
||||
client.post("/api/auth/request-code", json={"email": email})
|
||||
code = re.search(r"\b(\d{6})\b", client.app.state.mailer.outbox[-1].body).group(1)
|
||||
resp = client.post("/api/auth/verify", json={"email": email, "code": code})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["storefront"]["name"] == "Ben's Bets"
|
||||
assert resp.json()["created"] is False
|
||||
|
||||
|
||||
def test_puc_07_second_create_refused_409(fresh_db_url):
|
||||
with _signed_in_client(fresh_db_url) as client:
|
||||
client.post("/api/storefronts", json={"name": "First"})
|
||||
resp = client.post("/api/storefronts", json={"name": "Second"})
|
||||
assert resp.status_code == 409
|
||||
assert resp.json()["error"]["code"] == "already_owns_storefront"
|
||||
|
||||
|
||||
def test_puc_08_admin_shell_answer(fresh_db_url):
|
||||
# The shell renders from /me alone: storefront name + signed-in email (§6.5 PUC-8).
|
||||
with _signed_in_client(fresh_db_url) as client:
|
||||
client.post("/api/storefronts", json={"name": "Ben's Bets"})
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["account"]["email"] == "merchant@example.com"
|
||||
assert me["storefront"]["name"] == "Ben's Bets"
|
||||
|
||||
|
||||
def test_create_storefront_requires_session(fresh_db_url):
|
||||
with TestClient(create_app(database_url=fresh_db_url)) as client:
|
||||
resp = client.post("/api/storefronts", json={"name": "Nope"})
|
||||
assert resp.status_code == 401
|
||||
assert resp.json()["error"]["code"] == "unauthenticated"
|
||||
@@ -0,0 +1,64 @@
|
||||
"""INV-4's sharp edges (SD-0001 §6.8): concurrent second-storefront refusal, and the
|
||||
membership schema staying many-capable (the rule is a deletable guard, not a schema law)."""
|
||||
import threading
|
||||
|
||||
import psycopg
|
||||
import pytest
|
||||
|
||||
from app.domains import storefronts
|
||||
from app.platform import db
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def migrated_url(fresh_db_url):
|
||||
with psycopg.connect(fresh_db_url) as conn:
|
||||
db.migrate(conn)
|
||||
return fresh_db_url
|
||||
|
||||
|
||||
def test_inv_4_concurrent_creates_exactly_one_wins(migrated_url):
|
||||
with psycopg.connect(migrated_url) as setup:
|
||||
acct = setup.execute(
|
||||
"INSERT INTO account (email) VALUES ('racer@example.com') RETURNING id"
|
||||
).fetchone()[0]
|
||||
setup.commit()
|
||||
|
||||
barrier = threading.Barrier(2)
|
||||
results: list[str] = []
|
||||
|
||||
def racer():
|
||||
with psycopg.connect(migrated_url) as conn:
|
||||
barrier.wait()
|
||||
try:
|
||||
storefronts.create_storefront(conn, acct, "racer@example.com", None)
|
||||
results.append("created")
|
||||
except storefronts.AlreadyOwnsStorefront:
|
||||
results.append("refused")
|
||||
|
||||
threads = [threading.Thread(target=racer) for _ in range(2)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert sorted(results) == ["created", "refused"]
|
||||
with psycopg.connect(migrated_url) as check:
|
||||
count = check.execute(
|
||||
"SELECT count(*) FROM storefront_membership WHERE account_id = %s", (acct,)
|
||||
).fetchone()[0]
|
||||
assert count == 1
|
||||
|
||||
|
||||
def test_inv_4_schema_stays_many_capable(migrated_url):
|
||||
# No UNIQUE(account_id) anywhere in the storefront path: the only unique constraint on
|
||||
# storefront_membership is its composite PK, so many-per-account stays a deletable
|
||||
# service rule (R-5 mitigation).
|
||||
with psycopg.connect(migrated_url) as conn:
|
||||
uniques = conn.execute(
|
||||
"SELECT i.indkey::text FROM pg_index i"
|
||||
" JOIN pg_class c ON c.oid = i.indrelid"
|
||||
" WHERE c.relname = 'storefront_membership' AND (i.indisunique OR i.indisprimary)"
|
||||
).fetchall()
|
||||
# exactly one unique index (the composite PK over two columns)
|
||||
assert len(uniques) == 1
|
||||
assert len(uniques[0][0].split()) == 2
|
||||
@@ -0,0 +1,42 @@
|
||||
# deployment.toml — generated by define-deployment (launch-app SPEC §5.1, §6.6).
|
||||
# Derived from the One Name 'ecomm' (§3.3); edit the toml, re-import to reconcile (§3.2.1).
|
||||
|
||||
[app]
|
||||
name = "ecomm"
|
||||
repo = "wiggleverse/wiggleverse-ecomm"
|
||||
gitea_host = "https://git.wiggleverse.org"
|
||||
version_source = { kind = "file", path = "VERSION" }
|
||||
gitea_read_secret_ref = "wiggleverse-ecomm/ecomm-gitea-read-token"
|
||||
|
||||
[vm]
|
||||
name = "ecomm-ppe"
|
||||
zone = "us-central1-a"
|
||||
project = "wiggleverse-ecomm"
|
||||
machine_type = "e2-micro"
|
||||
disk_gb = 10
|
||||
service_user = "ecomm"
|
||||
install_dir = "/opt/ecomm"
|
||||
systemd_unit = "ecomm.service"
|
||||
tunnel_through_iap = true
|
||||
gcloud_config = "wiggleverse-ecomm"
|
||||
|
||||
[edge]
|
||||
domain = "ecomm-ppe.wiggleverse.org"
|
||||
# ecomm's health endpoint is /healthz (SD-0001 §6.4); body carries {status, version}.
|
||||
health_url = "https://ecomm-ppe.wiggleverse.org/healthz"
|
||||
|
||||
# No DATABASE_PATH: ecomm runs Cloud SQL PostgreSQL (SD-0001 D-7/D-8) — the DSN is the
|
||||
# ECOMM_DATABASE_URL secret reference below, minted by launch-app's provision-datastore.
|
||||
[overlay] # non-secret env, plaintext (guide §8)
|
||||
APP_URL = "https://ecomm-ppe.wiggleverse.org"
|
||||
ECOMM_MAILER = "smtp"
|
||||
ECOMM_COOKIE_SECURE = "1"
|
||||
ECOMM_SMTP_HOST = "smtp.gmail.com"
|
||||
ECOMM_SMTP_PORT = "587"
|
||||
ECOMM_SMTP_USER = "ben.stull@wiggleverse.org"
|
||||
ECOMM_SMTP_FROM = "ecomm <ben.stull@wiggleverse.org>"
|
||||
|
||||
[secrets] # REFERENCES only — never bytes (§8.3)
|
||||
ECOMM_SESSION_SECRET = "wiggleverse-ecomm/ecomm-ppe-session-secret"
|
||||
ECOMM_DATABASE_URL = "wiggleverse-ecomm/ecomm-ppe-database-url"
|
||||
ECOMM_SMTP_PASSWORD = "wiggleverse-ohm/ohm-rfc-app-smtp-password"
|
||||
+79
-3
@@ -4,8 +4,9 @@ Bringing an environment from **empty persistence** to "first merchant, first
|
||||
storefront" through the product flows alone (SD-0001 BUC-5). Empty is a working state
|
||||
(INV-1): the app applies its own schema migrations at startup; there is no seed step.
|
||||
|
||||
This document grows per environment. SLICE-1 ships the **localhost** section; the
|
||||
pre-production and production sections land with SLICE-4.
|
||||
This document grows per environment. SLICE-1 shipped the **localhost** section;
|
||||
SLICE-4 adds **pre-production (PPE)**. The production section lands with the prod
|
||||
stand-up.
|
||||
|
||||
## Localhost
|
||||
|
||||
@@ -61,4 +62,79 @@ environment starts from (BUC-5a).
|
||||
`scripts/dev.sh` sets `ECOMM_DATABASE_URL` to the local compose DSN
|
||||
(`postgresql://ecomm:ecomm@localhost:5432/ecomm`). No deployment shape is baked into
|
||||
the app (INV-8); deployed environments supply this and other config from Secret
|
||||
Manager (SLICE-4).
|
||||
Manager (see PPE below).
|
||||
|
||||
## Pre-production (PPE)
|
||||
|
||||
PPE is stood up and deployed through the **launch-app / flotilla-core** suite only
|
||||
(handbook §8.5 — provisioning is an operator-run gesture). The app's deployment
|
||||
record is `deployment.toml` at this repo's root; flotilla-core consumes it.
|
||||
|
||||
### One-time provisioning (operator-run, in order)
|
||||
|
||||
1. **Cloud foundation** — `scaffold-gcp-project`: the GCP project, its dedicated
|
||||
`--no-activate` gcloud config, billing, core APIs, ADC quota pin.
|
||||
2. **Managed database** — `provision-datastore` (launch-app §6.6a, built for ecomm's
|
||||
D-7/D-8): Cloud SQL for PostgreSQL 16, private-IP-only, automated backups +
|
||||
point-in-time recovery; writes the full DSN to Secret Manager as
|
||||
`<project>/ecomm-ppe-database-url`. Bound on the deployment record as
|
||||
`ECOMM_DATABASE_URL`.
|
||||
3. **Secrets** (references only — bytes go in via stdin, never through a session):
|
||||
`ECOMM_SESSION_SECRET` (fresh random), `ECOMM_SMTP_PASSWORD` (the shared
|
||||
Wiggleverse relay credential), and flotilla's own Gitea read token for this
|
||||
private repo.
|
||||
4. **VM + edge** — `provision-vm`: the e2-micro, IAP-only SSH, nginx + Cloudflare
|
||||
origin TLS, the systemd unit, DNS for `ecomm-ppe.wiggleverse.org`.
|
||||
5. **Deployment record** — `flotilla-core deployment scaffold ecomm … -o
|
||||
deployment.toml`, validate, commit it here, `deployment import --reconcile`.
|
||||
|
||||
### Deploy
|
||||
|
||||
Each release is a git tag `v<VERSION>` matching the repo-root `VERSION` file (the
|
||||
pin). The one gesture, from the flotilla-core repo's venv:
|
||||
|
||||
```bash
|
||||
CLOUDSDK_ACTIVE_CONFIG_NAME=ecomm .venv/bin/flotilla-core deploy ecomm
|
||||
```
|
||||
|
||||
Nine phases, fail-stop; it ends by polling `https://ecomm-ppe.wiggleverse.org/healthz`
|
||||
and requiring `status == "ok"` and `version == <target>`. Watch it yourself the same
|
||||
way:
|
||||
|
||||
```bash
|
||||
curl https://ecomm-ppe.wiggleverse.org/healthz
|
||||
```
|
||||
|
||||
### Configuration surface (PPE values)
|
||||
|
||||
| Env var | Kind | Value |
|
||||
| --- | --- | --- |
|
||||
| `ECOMM_DATABASE_URL` | secret ref | `<project>/ecomm-ppe-database-url` (from provision-datastore) |
|
||||
| `ECOMM_SESSION_SECRET` | secret ref | `<project>/ecomm-ppe-session-secret` |
|
||||
| `ECOMM_SMTP_PASSWORD` | secret ref | the shared Wiggleverse relay credential |
|
||||
| `ECOMM_MAILER` | overlay | `smtp` |
|
||||
| `ECOMM_COOKIE_SECURE` | overlay | `1` |
|
||||
| `ECOMM_SMTP_HOST` / `_PORT` / `_USER` / `_FROM` | overlay | the relay coordinates (non-secret) |
|
||||
|
||||
### The rehearsal (PUC-11)
|
||||
|
||||
From empty persistence: the deploy migrates the schema at startup (INV-7); then a
|
||||
real sign-up → one-time code arriving by **real email** → create storefront → admin,
|
||||
through the public flows alone. Record each rehearsal here when it happens.
|
||||
|
||||
**Rehearsals:**
|
||||
|
||||
- **2026-06-11 — PPE first bootstrap (v0.4.0, session 0024): ✅** First-ever ecomm
|
||||
deploy (`flotilla-core deploy ecomm`, 9/9 phases green, deploys.id=40) onto a
|
||||
freshly provisioned environment — project `wiggleverse-ecomm`, Cloud SQL
|
||||
`ecomm-ppe-pg` (provisioned by launch-app `provision-datastore`, first run), VM
|
||||
`ecomm-ppe`. The app self-migrated the empty database at startup; the operator
|
||||
then walked sign-up → real emailed code (Gmail relay) → create storefront →
|
||||
honestly-empty admin in a browser, through the public flows alone. Findings
|
||||
captured: OTC email branding (#16); identity should outgrow ecomm
|
||||
(engineering#49/#50).
|
||||
|
||||
## Production
|
||||
|
||||
Lands with the prod stand-up — the **identical gesture** on a prod deployment record
|
||||
(BUC-5a): same provisioning skills, same deploy, same rehearsal.
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# Operating ecomm
|
||||
|
||||
The framework-repo operator guide (SD-0002 DOC-1), started at SLICE-5. It covers
|
||||
the app's operational surface — telemetry, runbooks, alert gestures, the E2E
|
||||
gate. Per-deployment mechanics (deploy, secrets, VM access) live in the
|
||||
deployment's flotilla docs and `deployment.toml`; environment bring-up is in
|
||||
[`BOOTSTRAP.md`](./BOOTSTRAP.md).
|
||||
|
||||
## Products import/export ops (SD-0002, SLICE-5)
|
||||
|
||||
The §6.4 surface: a merchant uploads a catalog CSV (`POST
|
||||
/api/products/imports`), the app validates it and stores an **import draft**
|
||||
with a full preview (adds / updates / unchanged / errors), the merchant
|
||||
confirms or cancels at the preview gate, and a confirm applies the previewed
|
||||
diff as one **import run** recorded in the history (`/api/products/imports/runs`).
|
||||
|
||||
Caps and behavior to know (all enforced in code, not config):
|
||||
|
||||
- **Caps (INV-18):** ≤ 5,000 data rows and ≤ 10 MB per file —
|
||||
`MAX_DATA_ROWS` / `MAX_FILE_BYTES` in `backend/app/domains/products/models.py`,
|
||||
enforced in `backend/app/domains/products/codec.py` (file-level rejection)
|
||||
plus a 413 `file_too_large` guard in the BFF (`backend/app/main.py`).
|
||||
- **Draft expiry:** ~1 hour (`expires_at = now() + interval '1 hour'`). Cleanup
|
||||
is a lazy sweep — expired drafts are deleted on the next upload and on any
|
||||
access to an expired draft; there is no background job to babysit.
|
||||
- **Upsert-only (INV-10):** an import adds and updates, never deletes. Catalog
|
||||
products/variants/images absent from the file are untouched.
|
||||
- **One-transaction apply (INV-11):** a confirm applies the whole previewed
|
||||
diff in a single DB transaction — it lands completely or not at all.
|
||||
|
||||
### Telemetry
|
||||
|
||||
Structured JSON events on the `ecomm.telemetry` logger
|
||||
(`backend/app/platform/telemetry.py`), one JSON object per line, emitted from
|
||||
`backend/app/domains/products/service.py`. The app's `ecomm.*` log handler
|
||||
writes to the process's stderr, which journald captures on the VM (and Cloud
|
||||
Logging where the agent ships it). Events carry counts and durations only —
|
||||
never file names, URLs, catalog content, or secret bytes.
|
||||
|
||||
| Event | Trigger | Payload fields |
|
||||
| --- | --- | --- |
|
||||
| TEL-1 `import_draft_created` | validation completes, draft stored | `storefront_id, dialect, row_count, adds, updates, unchanged, errors, unknown_columns_count, duration_ms` |
|
||||
| TEL-2 `import_run_completed` | apply transaction commits | `run_id, storefront_id, added, updated, errored, duration_ms` |
|
||||
| TEL-6 `import_apply_failed` | apply transaction aborts unexpectedly | `draft_id, storefront_id, error_class` |
|
||||
|
||||
### RB-2 — import apply failed
|
||||
|
||||
Triggered by ALR-2 (any TEL-6 event). The apply raised mid-transaction and
|
||||
rolled back.
|
||||
|
||||
1. **Locate the failure.** On the VM, filter the journal for the event and note
|
||||
the `draft_id`, `storefront_id`, and `error_class`:
|
||||
|
||||
```
|
||||
journalctl -u ecomm.service | grep import_apply_failed
|
||||
```
|
||||
|
||||
2. **Confirm the rollback held (INV-11).** The apply is one transaction, so a
|
||||
failure leaves the catalog exactly as it was: the storefront's runs history
|
||||
(`GET /api/products/imports/runs`) shows **no new run**, and the products
|
||||
summary (`GET /api/products/summary`) shows an unchanged `product_count`.
|
||||
3. **The merchant's draft is intact.** A failed apply does not consume the
|
||||
draft — the merchant can retry confirm, or re-upload if the draft has since
|
||||
expired (~1 h). Advise accordingly.
|
||||
4. **File a bug** on `wiggleverse/wiggleverse-ecomm` with the `error_class`
|
||||
and the surrounding log context (the traceback is in the app log next to
|
||||
the event).
|
||||
|
||||
### ALR-2 — the log-based alert (one gesture per environment)
|
||||
|
||||
Run once per environment, at this slice's PPE deploy. This is an ad hoc op on
|
||||
the existing GCP project (`wiggleverse-ecomm`), not a provisioning gesture.
|
||||
|
||||
```
|
||||
# Select the deployment's gcloud config for this one process (handbook §8.4).
|
||||
export CLOUDSDK_ACTIVE_CONFIG_NAME=wiggleverse-ecomm
|
||||
|
||||
# Log-based metric counting import-apply failures (TEL-6).
|
||||
gcloud logging metrics create ecomm_import_apply_failed \
|
||||
--description="ecomm TEL-6 import_apply_failed events (SD-0002 ALR-2)" \
|
||||
--log-filter='resource.type="gce_instance" AND jsonPayload.message:"import_apply_failed" OR textPayload:"import_apply_failed"'
|
||||
```
|
||||
|
||||
Then attach an alert policy to the metric — **operator email channel, threshold
|
||||
any event > 0 in 5 minutes, severity notify-only** (pre-v1: no paging). The
|
||||
policy is created in the Cloud Console or with
|
||||
`gcloud alpha monitoring policies create`; the exact command depends on the
|
||||
notification-channel id, so list channels first:
|
||||
|
||||
```
|
||||
# Find the operator email channel's id for the policy.
|
||||
gcloud beta monitoring channels list
|
||||
```
|
||||
|
||||
### E2E browser suite
|
||||
|
||||
- Lives at `e2e/` — Playwright, Chromium, four SLICE-5 scenarios
|
||||
(preview/confirm happy path, actionable errors, file rejection, cancel).
|
||||
- Run with `bash scripts/e2e.sh`. The harness boots a **fresh `ecomm_e2e`
|
||||
database** against the local compose Postgres and serves the built SPA from
|
||||
the backend on **:8765** (the deployed topology), so it needs the dev
|
||||
Postgres up (`scripts/dev.sh`).
|
||||
- **Not in `scripts/check.sh` / CI yet** — the Gitea runner has no browsers
|
||||
(the §10.6 machinery gap). Run it locally before merge, and against PPE per
|
||||
the §9 pipeline (the PPE browser run is still manual this slice).
|
||||
|
||||
## Cross-references
|
||||
|
||||
- SLO and alert definitions: SD-0002 §9–§10 (content repo,
|
||||
`wiggleverse-ecomm-content/specs/SD-0002-products-bulk-csv-import-export.md`).
|
||||
- Import/diff engine internals: [`products-domain.md`](./products-domain.md).
|
||||
@@ -0,0 +1,116 @@
|
||||
# products domain — developer notes
|
||||
|
||||
DOC-4 (SD-0002 §11): the import/export spine as built in SLICE-5, extended by
|
||||
SLICE-6–8. Operator-facing material is in [`OPERATIONS.md`](./OPERATIONS.md);
|
||||
the spec is SD-0002 in the content repo.
|
||||
|
||||
## Layout and pipeline
|
||||
|
||||
`backend/app/domains/products/` is layered like the rest of the app
|
||||
(main → domains → platform, enforced by import-linter):
|
||||
|
||||
- `models.py` — canonical row model + the column registry.
|
||||
- `codec.py` — bytes → `ParsedFile`; file-level gates only.
|
||||
- `validate.py` — rows → `CanonicalProduct` blocks + per-row errors.
|
||||
- `diff.py` — catalog × canonical products → apply plan + preview records.
|
||||
- `repo.py` — SQL only: catalog snapshot, draft/run CRUD, apply primitives.
|
||||
Never commits or rolls back.
|
||||
- `service.py` — use-case orchestration; owns every transaction boundary and
|
||||
emits the TEL events via `backend/app/platform/telemetry.py`.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph validate_path [import_validate]
|
||||
A[parse_csv] --> B[build_products] --> D[compute_diff] --> E[(import_draft)]
|
||||
C[load_catalog] --> D
|
||||
end
|
||||
subgraph confirm_path [confirm_draft]
|
||||
E --> F[re-derive: parse → build → diff] --> G{fingerprint match?}
|
||||
G -- yes --> H[one-transaction apply<br/>run + products + delete draft]
|
||||
G -- no --> I[PreviewStale<br/>draft kept]
|
||||
end
|
||||
```
|
||||
|
||||
Confirm re-derives everything from the draft's stored `file_bytes` against the
|
||||
live catalog, then checks the fingerprint — so what lands is exactly what the
|
||||
preview showed, or the confirm refuses (`preview_stale`). A confirm with no
|
||||
adds and no updates refuses with `nothing_to_apply`.
|
||||
|
||||
## Canonical model and blank-vs-absent
|
||||
|
||||
`models.py` is the one model every dialect maps to (INV-17). `KNOWN_COLUMNS`
|
||||
is the registry header detection, unknown-column warnings, and validation all
|
||||
read; `CLEAR_DEFAULTS` holds the reset values for clearable fields
|
||||
(`status`, `published`, `product_type`, `tags`).
|
||||
|
||||
The §6.5.1 cell semantics, as implemented:
|
||||
|
||||
- **Absent column** → the field never enters `fields{}` → untouched by diff
|
||||
and apply (never a change).
|
||||
- **Present-but-empty cell** → `fields[name] = None` (an explicit clear) →
|
||||
resolved at diff time to its `CLEAR_DEFAULTS` entry, or NULL where none
|
||||
exists.
|
||||
- **The position exception:** a cleared `Variant Position` has no
|
||||
`CLEAR_DEFAULTS` entry — `diff.resolved_variant_fields` resolves it to the
|
||||
variant's 1-based file order within its product, never to NULL.
|
||||
|
||||
Option names live both in `CanonicalProduct.option_names` (the values) and in
|
||||
`fields{}` (the file-presence marker the diff needs for the absent-vs-clear
|
||||
distinction).
|
||||
|
||||
## Error granularity
|
||||
|
||||
`validate.py` never raises on a row problem: every violation is recorded as a
|
||||
merchant-language `RowError` and **poisons its whole product block** — the
|
||||
product previews as `kind="error"` and is excluded from apply, while parsing
|
||||
continues so one pass yields a complete accounting (BUC-1a). On apply, error
|
||||
rows are recorded per line in `import_run_error`; `rows_errored` on the run is
|
||||
the **error-row count**, while the preview's errors tile counts error
|
||||
**products** — the two numbers legitimately differ.
|
||||
|
||||
File-level problems (`not_csv`, `missing_required_column`, `too_many_rows`,
|
||||
`file_too_large`) raise `FileRejected` in `codec.py` instead: no draft is
|
||||
created. The BFF adds an early 413 for oversized uploads (`main.py`).
|
||||
|
||||
## INV-11 mechanics
|
||||
|
||||
`compute_diff` makes **one walk** that produces two views of the same
|
||||
computation: the typed apply `plan` (resolved natives — `Decimal`, `bool`,
|
||||
lists) that `confirm_draft` executes, and the JSON-safe preview `records`
|
||||
stored as draft JSONB and served verbatim to the SPA. Because both derive from
|
||||
the same walk they cannot diverge. The `fingerprint` is
|
||||
`sha256(json.dumps(records, sort_keys=True, separators=(",", ":")))`; a
|
||||
mismatch at confirm means the catalog drifted since preview → `PreviewStale`
|
||||
(409 `preview_stale`, draft kept for re-validation). The whole apply — run row,
|
||||
product/variant/image writes, error rows, draft delete — is one transaction;
|
||||
any exception rolls it back and emits TEL-6.
|
||||
|
||||
## Named seams (what later slices replace)
|
||||
|
||||
- **`import_draft.file_bytes` → objectstore key (SLICE-7).** The upload
|
||||
currently lives as BYTEA on the draft row (`0002_products.sql`); SLICE-7
|
||||
moves the bytes to object storage and stores a key.
|
||||
- **`codec.detect_dialect` → Shopify (SLICE-8).** Today it always returns
|
||||
`"canonical"`; SLICE-8 recognizes Shopify's exact header set here (INV-17).
|
||||
- **Run-status complete shortcut → `fetching_images` (SLICE-7).**
|
||||
`confirm_draft` inserts the run with `status="complete"` directly; SLICE-7
|
||||
inserts it as `fetching_images` and hands off to the image-fetch task.
|
||||
Relatedly, image rows are created with the schema default
|
||||
`status='pending'` **today and stay pending** — placeholder behavior until
|
||||
SLICE-7's fetch phase (the run-detail payload already carries the stable
|
||||
`image_progress` / `image_outcomes` shape, zeroed).
|
||||
|
||||
## Test map
|
||||
|
||||
| File | Covers |
|
||||
| --- | --- |
|
||||
| `backend/tests/test_products_codec.py` | file-level gates: parse, caps (INV-18), required columns, dialect |
|
||||
| `backend/tests/test_products_validate.py` | every §6.5.1 row-error rule, one fixture each |
|
||||
| `backend/tests/test_products_diff.py` | classification, blank-vs-absent, option matching, fingerprint |
|
||||
| `backend/tests/test_products_service.py` | draft lifecycle: validate/preview/discard, expiry, TEL-1 |
|
||||
| `backend/tests/test_products_invariants.py` | INV-10 (never deletes), INV-14 (storefront isolation), apply transactionality, TEL-6 |
|
||||
| `backend/tests/test_products_endpoints.py` | §6.4 API scenarios + auth/storefront gates |
|
||||
| `e2e/tests/import-preview-confirm.spec.ts` | happy path: upload → preview → confirm → history |
|
||||
| `e2e/tests/import-errors.spec.ts` | actionable row errors at preview and on the run report |
|
||||
| `e2e/tests/import-file-rejected.spec.ts` | file-level rejection, picker stays live, no trace |
|
||||
| `e2e/tests/import-cancel.spec.ts` | cancel at preview leaves no trace |
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
||||
# SLICE-4 (part 1) — Deploy-Contract Code Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make ecomm deployable by flotilla-core's 9-phase gesture and rehearsable on PPE: versioned health, SPA served by the backend, real `SmtpMailer` with honest delivery failure (closes ecomm#7), per SD-0001 §7.2 (SLICE-4) — the code half. Provisioning (Cloud SQL via the new launch-app `provision-datastore`, VM, deployment.toml) and the PPE rehearsal follow as operator gestures in the same session.
|
||||
|
||||
**Anchor:** SD-0001 §7.2 SLICE-4 (R2a, checked this session). flotilla contract: flotilla-core SPEC §8.1 (checkout `v<VERSION>` tag → pip → `npm ci && npm run build` → write `backend/.env` → restart → verify `body.version == target && body.status == "ok"`); provision-vm: nginx proxies ALL routes to uvicorn `app.main:app` (WorkingDirectory `backend/`), so the backend must serve `frontend/dist`.
|
||||
|
||||
**Architecture:** `VERSION` at repo root is the single version source (healthz body + FastAPI version + the deploy pin). `create_app()` mounts `frontend/dist` (when present) after all API routes — SPA fallback via `StaticFiles(html=True)`. `SmtpMailer` is the second adapter of the existing mailer port (STARTTLS smtplib, config from `ECOMM_SMTP_*`, INV-8); delivery failure raises `MailerError` → `accounts.request_code` sends **before** commit (rollback on failure — no orphan code, no tripped cooldown; ecomm#7) → BFF surfaces `502 delivery_failed` (INV-9).
|
||||
|
||||
**Tech Stack:** stdlib `smtplib`/`email.message`; FastAPI `StaticFiles`; no new dependencies.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: VERSION + versioned /healthz
|
||||
|
||||
**Files:** Create `VERSION` (root). Modify `backend/app/main.py`, `backend/tests/test_healthz.py`.
|
||||
|
||||
- [ ] Write `VERSION` containing `0.4.0`.
|
||||
- [ ] Test first: `test_healthz_ok_on_migrated_empty_db` asserts `{"status": "ok", "version": "0.4.0"}` read from the VERSION file (compare against `(repo_root/"VERSION").read_text().strip()`, not a literal). Run → FAIL.
|
||||
- [ ] `main.py`: add `_APP_VERSION = (Path(__file__).resolve().parents[2] / "VERSION").read_text().strip()` (fallback `"0.0.0"` when missing); healthz returns `{"status": "ok", "version": _APP_VERSION}`; `FastAPI(version=_APP_VERSION)`. Run → PASS. Commit.
|
||||
|
||||
### Task 2: backend serves the SPA (deploy phase-8 contract)
|
||||
|
||||
**Files:** Modify `backend/app/main.py`. Test `backend/tests/test_static_spa.py`.
|
||||
|
||||
- [ ] Test first: `create_app(database_url=..., static_dir=tmp_path)` with a `tmp_path/index.html`; GET `/` → 200 + the html; GET `/healthz` and `/api/auth/me` still answer JSON (API wins over the mount). Default `static_dir=None` → resolves `repo_root/frontend/dist`, skipped silently when absent (dev: Vite serves). Run → FAIL.
|
||||
- [ ] `create_app(database_url=None, static_dir: str | Path | None = None)`; after the last route: resolve dir, `if dir/index.html exists: app.mount("/", StaticFiles(directory=dir, html=True), name="spa")`. Run → PASS (whole suite). Commit.
|
||||
|
||||
### Task 3: SmtpMailer + config surface (INV-8)
|
||||
|
||||
**Files:** Modify `backend/app/platform/{mailer,config}.py`. Test `backend/tests/test_mailer.py` (extend).
|
||||
|
||||
- [ ] Config additions: `smtp_host()` (`ECOMM_SMTP_HOST`), `smtp_port()` (`ECOMM_SMTP_PORT`, 587), `smtp_user()`, `smtp_password()`, `smtp_from()` (default = user), `smtp_starttls()` (default on).
|
||||
- [ ] Tests first: `MailerError` exists; `build_mailer("smtp")` returns `SmtpMailer` wired from env (monkeypatched); `SmtpMailer.send` drives a monkeypatched `smtplib.SMTP` (starttls → login → send_message with To/Subject/From + body) and never logs the body; SMTP exception → `MailerError`. Run → FAIL.
|
||||
- [ ] Implement: `MailerError(Exception)`; `SmtpMailer` (EmailMessage; `smtplib.SMTP(host, port, timeout=10)`, STARTTLS per config, login when user set, `send_message`; `except Exception → raise MailerError`; logs only `smtp sent to=<sha256[:8] of recipient>` — §6.6 log hygiene); `build_mailer("smtp")` builds it from config. Run → PASS. Commit.
|
||||
|
||||
### Task 4: honest delivery failure — send-before-commit + 502 (closes ecomm#7)
|
||||
|
||||
**Files:** Modify `backend/app/domains/accounts/{errors,service,__init__}.py`, `backend/app/main.py`. Test `backend/tests/test_accounts_request_code.py` + `test_auth_endpoints.py` (extend).
|
||||
|
||||
- [ ] Tests first: a mailer whose `send` raises `MailerError` → service raises `accounts.DeliveryFailed`, **no `auth_code` row remains**, and an immediate retry is **not** cooldown-blocked; endpoint test: 502 `{"error": {"code": "delivery_failed"}}`. Run → FAIL.
|
||||
- [ ] Implement: `DeliveryFailed(AccountsError)`; `request_code` moves `conn.commit()` **after** `mailer.send(...)`, wrapping send in `try/except MailerError → conn.rollback(); raise DeliveryFailed`; BFF maps it to `_error(502, "delivery_failed", "We couldn't send the code — try again.")`. Run → PASS (whole backend suite). Commit.
|
||||
|
||||
### Task 5: BOOTSTRAP.md PPE section + housekeeping + gate
|
||||
|
||||
**Files:** Modify `docs/BOOTSTRAP.md`, `README.md`, `frontend/package.json` (0.4.0).
|
||||
|
||||
- [ ] BOOTSTRAP.md: replace the "land with SLICE-4" sentence; add **Pre-production (PPE)** section — prerequisites (suite-run provisioning: scaffold-gcp-project → provision-datastore (Cloud SQL, engineering#46) → provision-vm → define-deployment/import; secrets as references), the one deploy gesture (`CLOUDSDK_ACTIVE_CONFIG_NAME=<config> flotilla-core deploy <name>`), how to watch `/healthz`, the rehearsal walk (PUC-11), reset-to-empty note; Prod section: placeholder "lands with the prod stand-up" honestly.
|
||||
- [ ] README status: deploy contract in place; frontend package 0.4.0.
|
||||
- [ ] `./scripts/check.sh` → all green. Commit. Push, PR citing SD-0001 §7.2 SLICE-4 + ecomm#7, merge, **tag `v0.4.0` on the merge commit and push the tag** (the deploy pin).
|
||||
|
||||
## Self-review
|
||||
|
||||
Spec coverage: SmtpMailer + config wiring INV-8 ✓ (T3), §6.6 hardening — Secure cookies already config-driven, log hygiene ✓ (T3 no-body logging; LogMailer is dev-only by config), `deployment.toml` + provisioning deliberately deferred to the Phase-C suite gestures (needs the GCP project id that scaffold-gcp-project mints), BOOTSTRAP.md PPE ✓ (T5), versioned health for the §8.1 verify ✓ (T1), SPA serving for the §2 topology ✓ (T2), ecomm#7 ✓ (T4). E2E browser tests still deferred per §6.8. Type consistency: `MailerError` lives in platform/mailer; `DeliveryFailed` in accounts errors; both exported via package surfaces.
|
||||
@@ -0,0 +1,118 @@
|
||||
# ui/designs Content-Repo Collection Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Establish `ui/designs/` as a standard content-repo collection (alongside `specs/` and `plans/`) — concretely in `wiggleverse-ecomm-content`, and centrally in the engineering repo's schema docs so it binds all `*-content` repos.
|
||||
|
||||
**Architecture:** Docs/convention change only, two repos, one PR each. The collection convention's canonical home is the engineering repo's `schemas/` docs (the `content` descriptor description strings in `app.schema.json` + `schemas/README.md` changelog), so the central change is a docs-only minor schema bump (1.2 → 1.3). No tooling changes: the spec-linkage gate, backfill verb, and GUIDE/TEMPLATE Design field are tracked separately as `wiggleverse-dev-claude-plugin#93`.
|
||||
|
||||
**Tech Stack:** Markdown, JSON Schema (description strings only), git + Gitea PRs over SSH.
|
||||
|
||||
**Anchor:** `wiggleverse/wiggleverse-ecomm#8` (type/task, ELIGIBLE R2b). Related: `wiggleverse/wiggleverse-dev-claude-plugin#93`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `ui/designs/` collection in wiggleverse-ecomm-content
|
||||
|
||||
**Files:**
|
||||
- Create: `/Users/benstull/git/wiggleverse.org/wiggleverse/wiggleverse-ecomm-content/ui/designs/README.md`
|
||||
- Modify: `/Users/benstull/git/wiggleverse.org/wiggleverse/wiggleverse-ecomm-content/README.md` (layout table, lines 10–14)
|
||||
|
||||
- [ ] **Step 1: Branch**
|
||||
|
||||
```bash
|
||||
git -C /Users/benstull/git/wiggleverse.org/wiggleverse/wiggleverse-ecomm-content checkout -b ui-designs-collection
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create the collection README**
|
||||
|
||||
`ui/designs/README.md`:
|
||||
|
||||
```markdown
|
||||
# ui/designs — UI-design artifacts
|
||||
|
||||
Standard content-repo collection (alongside `specs/` and `plans/`) holding this
|
||||
app's UI-design artifacts — primarily Claude Design outputs generated from a
|
||||
Solution Design (rubric: `engineering/solution-design/claude-design-vs-code.md`).
|
||||
|
||||
A Solution Design with a UX-involving slice references its design artifact here
|
||||
by path. The spec-linkage gate and the backfill gesture for adding that
|
||||
reference once a design exists are tracked in
|
||||
`wiggleverse/wiggleverse-dev-claude-plugin#93`.
|
||||
|
||||
Suggested layout: one subfolder per design, named for the spec/slice it serves,
|
||||
e.g. `ui/designs/SD-0001-slice-3-storefront/`.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add the layout-table row**
|
||||
|
||||
In the top-level `README.md`, extend the table:
|
||||
|
||||
```markdown
|
||||
| Path | Holds |
|
||||
| --- | --- |
|
||||
| `specs/` | reviewed Solution-Design specs (submitted at session finalize) |
|
||||
| `plans/` | archived implementation plans |
|
||||
| `ui/designs/` | UI-design artifacts (Claude Design outputs), referenced from specs |
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit, push, PR, merge**
|
||||
|
||||
```bash
|
||||
git -C …/wiggleverse-ecomm-content add ui/designs/README.md README.md
|
||||
git -C …/wiggleverse-ecomm-content commit -m "content: add ui/designs/ collection (ecomm#8)"
|
||||
git -C …/wiggleverse-ecomm-content push -u origin ui-designs-collection
|
||||
```
|
||||
|
||||
PR via Gitea API (default per-host token, NOT the issue-scoped one — TOKENS.md), then merge; body cites `wiggleverse/wiggleverse-ecomm#8` + plugin `#93`.
|
||||
|
||||
### Task 2: Standardize centrally in engineering schemas docs
|
||||
|
||||
**Files:**
|
||||
- Modify: `/Users/benstull/git/wiggleverse.org/wiggleverse/engineering/schemas/app.schema.json` (lines 13, 94, 181, 184)
|
||||
- Modify: `/Users/benstull/git/wiggleverse.org/wiggleverse/engineering/schemas/README.md` (repos[] bullets + changelog)
|
||||
|
||||
- [ ] **Step 1: Branch**
|
||||
|
||||
```bash
|
||||
git -C /Users/benstull/git/wiggleverse.org/wiggleverse/engineering checkout -b ui-designs-collection
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Schema description strings + enum**
|
||||
|
||||
1. `schemaVersion.enum`: `["1.0", "1.1", "1.2"]` → `["1.0", "1.1", "1.2", "1.3"]`
|
||||
2. `content` property description (line 94): "…where this app's reviewed specs/ and archived plans/ collections live…" → "…where this app's reviewed specs/, archived plans/, and ui/designs/ collections live…"
|
||||
3. `$defs.content` description (line 181): "(reviewed specs/, archived plans/)" → "(reviewed specs/, archived plans/, ui/designs/ UI-design artifacts)"; and "The specs/ and plans/ collection subdirs are appended by the submit tooling" → "The specs/, plans/, and ui/designs/ collection subdirs are conventions (specs/ and plans/ are appended by the submit tooling; ui/designs/ holds Claude Design outputs referenced from specs)"
|
||||
4. `$defs.content.subdir` description (line 184): "under which the specs/ and plans/ collections live" → "under which the specs/, plans/, and ui/designs/ collections live"
|
||||
|
||||
- [ ] **Step 3: schemas/README.md**
|
||||
|
||||
Changelog entry above 1.2:
|
||||
|
||||
```markdown
|
||||
- **1.3** — docs-only: the content-repo collection convention gains a third
|
||||
standard collection, `ui/designs/` — UI-design artifacts (Claude Design
|
||||
outputs generated from a Solution Design), referenced from specs. Like
|
||||
`specs/`/`plans/`, the subdir is a convention, not a schema field; no
|
||||
validation change (the spec-linkage gate/backfill tooling is
|
||||
wiggleverse-dev-claude-plugin#93). Existing files stay valid.
|
||||
```
|
||||
|
||||
If the repos[] bullet list documents the `content` descriptor, name the three collections there too; if 1.2 never added a `content` bullet, add one.
|
||||
|
||||
- [ ] **Step 4: Validate JSON, commit, push, PR, merge**
|
||||
|
||||
```bash
|
||||
python3 -m json.tool /Users/benstull/git/wiggleverse.org/wiggleverse/engineering/schemas/app.schema.json > /dev/null && echo OK
|
||||
git -C …/engineering add schemas/app.schema.json schemas/README.md
|
||||
git -C …/engineering commit -m "schemas: 1.3 — ui/designs/ standard content collection (ecomm#8, plugin#93)"
|
||||
git -C …/engineering push -u origin ui-designs-collection
|
||||
```
|
||||
|
||||
PR + merge (default token for `/pulls`).
|
||||
|
||||
### Task 3: Cross-link and close out
|
||||
|
||||
- [ ] **Step 1: Comment on plugin #93** noting the location is now standard (link both merged PRs) — its gate/backfill work can assume `ui/designs/` exists.
|
||||
- [ ] **Step 2: Close ecomm#8** with a comment naming both merged PRs.
|
||||
- [ ] **Step 3: Checkpoint the transcript** (`publish-transcript.sh` on the `--INPROGRESS` file).
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
.backend.log
|
||||
test-results/
|
||||
playwright-report/
|
||||
@@ -0,0 +1,4 @@
|
||||
Handle,Title,Vendor,Tags,Status,Published,Option1 Name,Option1 Value,Variant SKU,Variant Price,Variant Inventory Qty
|
||||
moon-mug,Moon Mug,Wiggle Goods,"kitchen, mugs",active,TRUE,,,WG-MUG-001,18.00,40
|
||||
star-tee,Star Tee,Wiggle Goods,apparel,active,TRUE,Size,S,WG-TEE-S,24.00,12
|
||||
star-tee,,,,,,,M,WG-TEE-M,24.00,18
|
||||
|
@@ -0,0 +1,2 @@
|
||||
Handle,Vendor
|
||||
mug,Acme
|
||||
|
@@ -0,0 +1,4 @@
|
||||
Handle,Title,Variant Price
|
||||
good-mug,Good Mug,10.00
|
||||
bad-tee,Bad Tee,not-a-price
|
||||
also-good,Also Good,5.00
|
||||
|
@@ -0,0 +1,74 @@
|
||||
// Shared E2E helpers — the sign-up journey (SD-0001 §5.1–§5.4) and products-page
|
||||
// navigation (SD-0002 §5.2). Selectors are role/label-based against the real screens
|
||||
// (Landing.tsx, SignIn.tsx, CreateStorefront.tsx, Admin.tsx, ProductsPage.tsx).
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
const LOG = join(__dirname, ".backend.log");
|
||||
let seq = 0;
|
||||
|
||||
// The storefront name every helper-created merchant uses; tests assert against it.
|
||||
export const STOREFRONT_NAME = "E2E Test Goods";
|
||||
|
||||
export function freshEmail(): string {
|
||||
return `merchant${Date.now()}-${seq++}@example.com`;
|
||||
}
|
||||
|
||||
// LogMailer's exact line (backend/app/platform/mailer.py):
|
||||
// INFO:ecomm.mailer: LogMailer -> <to> | Your ecomm code: <6 digits>\n<body>
|
||||
// The first 6-digit group after the marker is the code.
|
||||
async function codeFor(email: string): Promise<string> {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const log = await readFile(LOG, "utf8").catch(() => "");
|
||||
const at = log.lastIndexOf(`LogMailer -> ${email}`);
|
||||
if (at >= 0) {
|
||||
const m = log.slice(at).match(/\b(\d{6})\b/);
|
||||
if (m) return m[1];
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}
|
||||
throw new Error(`no code logged for ${email}`);
|
||||
}
|
||||
|
||||
export async function signUpWithStorefront(page: Page, email = freshEmail()): Promise<string> {
|
||||
// Landing → the sign-up door.
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Create your storefront →" }).click();
|
||||
|
||||
// Sign-in step 1: request the one-time code.
|
||||
await page.getByLabel("Email").fill(email);
|
||||
await page.getByRole("button", { name: "Send code" }).click();
|
||||
|
||||
// Sign-in step 2: read the code from the backend log and verify it.
|
||||
await expect(page.getByRole("heading", { name: "Check your email" })).toBeVisible();
|
||||
const code = await codeFor(email);
|
||||
await page.getByLabel("One-time code").fill(code);
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// Create-storefront screen (a new account has none yet).
|
||||
await expect(page.getByRole("heading", { name: "Create your storefront" })).toBeVisible();
|
||||
await page.getByLabel("Storefront name").fill(STOREFRONT_NAME);
|
||||
await page.getByRole("button", { name: "Create storefront", exact: true }).click();
|
||||
|
||||
// Admin shell: nav strip + the storefront identity in the topbar.
|
||||
await expect(page.getByRole("navigation", { name: "Admin sections" })).toBeVisible();
|
||||
await expect(page.locator(".storeid__name")).toHaveText(STOREFRONT_NAME);
|
||||
return email;
|
||||
}
|
||||
|
||||
export async function gotoProducts(page: Page) {
|
||||
await page
|
||||
.getByRole("navigation", { name: "Admin sections" })
|
||||
.getByRole("link", { name: "Products" })
|
||||
.click();
|
||||
await expect(page.getByRole("heading", { level: 1, name: "Products" })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function uploadFixture(page: Page, fixture: string) {
|
||||
// Two "Import products" links render on the empty products page (header + empty
|
||||
// state) — same destination, so take the first.
|
||||
await page.getByRole("link", { name: "Import products" }).first().click();
|
||||
await expect(page.getByRole("heading", { name: "Import products" })).toBeVisible();
|
||||
await page.locator('input[type="file"]').setInputFiles(join(__dirname, "fixtures", fixture));
|
||||
}
|
||||
Generated
+76
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"name": "wiggleverse-ecomm-e2e",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wiggleverse-ecomm-e2e",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.48.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
|
||||
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "wiggleverse-ecomm-e2e",
|
||||
"private": true,
|
||||
"scripts": { "test": "playwright test" },
|
||||
"devDependencies": { "@playwright/test": "^1.48.0" }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
timeout: 60_000,
|
||||
retries: 0,
|
||||
// One shared backend + log file; parallel sign-ins would interleave codes.
|
||||
workers: 1,
|
||||
use: { baseURL: "http://localhost:8765" },
|
||||
webServer: {
|
||||
command: "bash ./serve.sh",
|
||||
url: "http://localhost:8765/healthz",
|
||||
reuseExistingServer: false,
|
||||
timeout: 120_000,
|
||||
},
|
||||
});
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
# E2E server: fresh ecomm_e2e database, LogMailer (codes land in .backend.log),
|
||||
# backend on :8765 serving the built SPA (the deployed topology, SD-0001 §6.2).
|
||||
set -euo pipefail
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
if [ ! -f "$repo_root/frontend/dist/index.html" ]; then
|
||||
( cd "$repo_root/frontend" && npm run build )
|
||||
fi
|
||||
|
||||
"$repo_root/.venv/bin/python" - <<'PY'
|
||||
import psycopg
|
||||
admin = psycopg.connect("postgresql://ecomm:ecomm@localhost:5432/postgres", autocommit=True)
|
||||
admin.execute("SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='ecomm_e2e' AND pid <> pg_backend_pid()")
|
||||
admin.execute("DROP DATABASE IF EXISTS ecomm_e2e")
|
||||
admin.execute("CREATE DATABASE ecomm_e2e")
|
||||
PY
|
||||
|
||||
export ECOMM_DATABASE_URL="postgresql://ecomm:ecomm@localhost:5432/ecomm_e2e"
|
||||
export ECOMM_MAILER=log
|
||||
cd "$repo_root/backend"
|
||||
exec "$repo_root/.venv/bin/python" -m uvicorn app.main:app --port 8765 \
|
||||
> "$repo_root/e2e/.backend.log" 2>&1
|
||||
@@ -0,0 +1,23 @@
|
||||
// DoD scenario e2e_import_cancel_no_trace (SD-0002 §6.8): cancelling at the
|
||||
// preview gate (PUC-3a) leaves no trace — no products, no run in the history.
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { gotoProducts, signUpWithStorefront, uploadFixture } from "../helpers";
|
||||
|
||||
test("e2e_import_cancel_no_trace", async ({ page }) => {
|
||||
await signUpWithStorefront(page);
|
||||
await gotoProducts(page);
|
||||
await uploadFixture(page, "good.csv");
|
||||
|
||||
// The preview gate is up.
|
||||
await expect(page.getByRole("heading", { name: "Import preview — good.csv" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "2 to add" })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Cancel" }).click();
|
||||
|
||||
// Lands back on Products — still the empty state, no run recorded.
|
||||
await expect(page.getByRole("heading", { level: 1, name: "Products" })).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("No products yet. Bulk import is how product data gets in."),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("No imports yet.")).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
// DoD scenario e2e_import_errors_actionable (SD-0002 §6.8): row-level errors are
|
||||
// actionable — line, column, message — at the preview gate AND on the run report
|
||||
// card (PUC-5), while the good rows still import.
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { gotoProducts, signUpWithStorefront, uploadFixture } from "../helpers";
|
||||
|
||||
test("e2e_import_errors_actionable", async ({ page }) => {
|
||||
await signUpWithStorefront(page);
|
||||
await gotoProducts(page);
|
||||
await uploadFixture(page, "mixed-errors.csv");
|
||||
|
||||
// Preview tiles: two good products, one errored row.
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Import preview — mixed-errors.csv" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "2 to add" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "1 errors" }).click();
|
||||
|
||||
// The error table names the line, the column, and the problem (actionable, PUC-5).
|
||||
const previewRow = page.locator(".errortable tbody tr");
|
||||
await expect(previewRow).toHaveCount(1);
|
||||
await expect(previewRow.locator("td").nth(0)).toHaveText("3");
|
||||
await expect(previewRow.locator("td").nth(1)).toHaveText("Variant Price");
|
||||
await expect(previewRow.locator("td").nth(2)).toContainText("is not a price");
|
||||
|
||||
// Good rows still import.
|
||||
await page.getByRole("button", { name: "Import 2 products" }).click();
|
||||
|
||||
// Run detail: counts include the errored row, and the same error row persists.
|
||||
await expect(page.getByRole("heading", { level: 1, name: "mixed-errors.csv" })).toBeVisible();
|
||||
await expect(page.getByText("2 added · 0 updated · 1 rows in error")).toBeVisible();
|
||||
const runRow = page.locator(".errortable tbody tr");
|
||||
await expect(runRow).toHaveCount(1);
|
||||
await expect(runRow.locator("td").nth(0)).toHaveText("3");
|
||||
await expect(runRow.locator("td").nth(1)).toHaveText("Variant Price");
|
||||
await expect(runRow.locator("td").nth(2)).toContainText("is not a price");
|
||||
|
||||
// The catalog gained the two good products.
|
||||
await gotoProducts(page);
|
||||
await expect(page.getByRole("heading", { level: 1, name: "Products · 2" })).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
// DoD scenario e2e_import_file_rejected (SD-0002 §6.8): a file-level rejection
|
||||
// (PUC-5a) renders in place on the upload screen, the picker stays live for a
|
||||
// retry, and nothing is recorded — no products, no run.
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { gotoProducts, signUpWithStorefront, uploadFixture } from "../helpers";
|
||||
|
||||
test("e2e_import_file_rejected", async ({ page }) => {
|
||||
await signUpWithStorefront(page);
|
||||
await gotoProducts(page);
|
||||
await uploadFixture(page, "missing-title.csv");
|
||||
|
||||
// The rejection renders in place, naming the missing column.
|
||||
await expect(page.getByText("That file can't be imported")).toBeVisible();
|
||||
await expect(page.getByText("missing the required column 'Title'")).toBeVisible();
|
||||
|
||||
// The picker is live again for a retry.
|
||||
await expect(page.locator('input[type="file"]')).toBeEnabled();
|
||||
|
||||
// No trace: still the empty catalog, and no run recorded.
|
||||
await gotoProducts(page);
|
||||
await expect(
|
||||
page.getByText("No products yet. Bulk import is how product data gets in."),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("No imports yet.")).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
// DoD scenario e2e_import_preview_confirm (SD-0002 §6.8): the happy path end to
|
||||
// end — sign up, empty catalog, upload good.csv, preview gate (PUC-2/3), confirm,
|
||||
// run report card, and the catalog + history reflecting the import. Subsumes the
|
||||
// Task-15 harness smoke (sign-up journey + products empty state).
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { gotoProducts, signUpWithStorefront, STOREFRONT_NAME, uploadFixture } from "../helpers";
|
||||
|
||||
test("e2e_import_preview_confirm", async ({ page }) => {
|
||||
await signUpWithStorefront(page);
|
||||
|
||||
// Admin topbar: storefront identity + the signed-in account chip (ex-smoke).
|
||||
await expect(page.locator(".storeid__name")).toHaveText(STOREFRONT_NAME);
|
||||
await expect(page.getByRole("button", { name: "Sign out" })).toBeVisible();
|
||||
|
||||
await gotoProducts(page);
|
||||
|
||||
// Empty state + the import affordances (SD-0002 §5.2, ex-smoke).
|
||||
await expect(
|
||||
page.getByText("No products yet. Bulk import is how product data gets in."),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Download sample CSV" })).toBeVisible();
|
||||
|
||||
await uploadFixture(page, "good.csv");
|
||||
|
||||
// Preview (§5.4): summary tiles + the file's name in the heading.
|
||||
await expect(page.getByRole("heading", { name: "Import preview — good.csv" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "2 to add" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "0 errors" })).toBeVisible();
|
||||
|
||||
// Drill in: open star-tee's diff row and see field-level detail (the S-variant SKU).
|
||||
const starTee = page.locator(".difflist__item", { hasText: "star-tee" });
|
||||
await starTee.locator("summary").click();
|
||||
await expect(starTee.getByText("WG-TEE-S")).toBeVisible();
|
||||
|
||||
// Consent gate (PUC-3): confirm the import.
|
||||
await page.getByRole("button", { name: "Import 2 products" }).click();
|
||||
|
||||
// Run detail (§5.5): report card with the file name, counts, and status.
|
||||
await expect(page.getByRole("heading", { level: 1, name: "good.csv" })).toBeVisible();
|
||||
await expect(page.getByText("2 added · 0 updated · 0 rows in error")).toBeVisible();
|
||||
await expect(page.getByText("Complete", { exact: true })).toBeVisible();
|
||||
|
||||
// Back on Products: the catalog count and exactly one history row for this run.
|
||||
await gotoProducts(page);
|
||||
await expect(page.getByRole("heading", { level: 1, name: "Products · 2" })).toBeVisible();
|
||||
const rows = page.locator(".datatable tbody tr");
|
||||
await expect(rows).toHaveCount(1);
|
||||
await expect(rows.first().getByRole("link", { name: "good.csv" })).toBeVisible();
|
||||
});
|
||||
@@ -3,6 +3,9 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0E1230" />
|
||||
<style>html { background: #0E1230; }</style>
|
||||
<link rel="icon" type="image/svg+xml" href="/brand/mark-tile.svg" />
|
||||
<title>ecomm</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Generated
+406
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wiggleverse-ecomm-frontend",
|
||||
"version": "0.1.0",
|
||||
"version": "0.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wiggleverse-ecomm-frontend",
|
||||
"version": "0.1.0",
|
||||
"version": "0.4.0",
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
@@ -16,7 +16,8 @@
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.11"
|
||||
"vite": "^5.4.11",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
@@ -1239,6 +1240,129 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz",
|
||||
"integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "2.1.9",
|
||||
"@vitest/utils": "2.1.9",
|
||||
"chai": "^5.1.2",
|
||||
"tinyrainbow": "^1.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz",
|
||||
"integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "2.1.9",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz",
|
||||
"integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^1.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz",
|
||||
"integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "2.1.9",
|
||||
"pathe": "^1.1.2"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz",
|
||||
"integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "2.1.9",
|
||||
"magic-string": "^0.30.12",
|
||||
"pathe": "^1.1.2"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz",
|
||||
"integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyspy": "^3.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz",
|
||||
"integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "2.1.9",
|
||||
"loupe": "^3.1.2",
|
||||
"tinyrainbow": "^1.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.35",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.35.tgz",
|
||||
@@ -1286,6 +1410,16 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/cac": {
|
||||
"version": "6.7.14",
|
||||
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
||||
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001797",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz",
|
||||
@@ -1307,6 +1441,33 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "5.3.3",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
|
||||
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"assertion-error": "^2.0.1",
|
||||
"check-error": "^2.1.1",
|
||||
"deep-eql": "^5.0.1",
|
||||
"loupe": "^3.1.0",
|
||||
"pathval": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/check-error": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
|
||||
"integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
@@ -1339,6 +1500,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/deep-eql": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
|
||||
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.371",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.371.tgz",
|
||||
@@ -1346,6 +1517,13 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
||||
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||
@@ -1395,6 +1573,26 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -1464,6 +1662,13 @@
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/loupe": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
||||
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
@@ -1474,6 +1679,16 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -1510,6 +1725,23 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
|
||||
"integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pathval": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
|
||||
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14.16"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -1645,6 +1877,13 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -1655,6 +1894,64 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "3.10.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
||||
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
|
||||
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinypool": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
|
||||
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.0.0 || >=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyrainbow": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz",
|
||||
"integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyspy": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz",
|
||||
"integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
@@ -1760,6 +2057,112 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite-node": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz",
|
||||
"integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cac": "^6.7.14",
|
||||
"debug": "^4.3.7",
|
||||
"es-module-lexer": "^1.5.4",
|
||||
"pathe": "^1.1.2",
|
||||
"vite": "^5.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"vite-node": "vite-node.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || >=20.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz",
|
||||
"integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "2.1.9",
|
||||
"@vitest/mocker": "2.1.9",
|
||||
"@vitest/pretty-format": "^2.1.9",
|
||||
"@vitest/runner": "2.1.9",
|
||||
"@vitest/snapshot": "2.1.9",
|
||||
"@vitest/spy": "2.1.9",
|
||||
"@vitest/utils": "2.1.9",
|
||||
"chai": "^5.1.2",
|
||||
"debug": "^4.3.7",
|
||||
"expect-type": "^1.1.0",
|
||||
"magic-string": "^0.30.12",
|
||||
"pathe": "^1.1.2",
|
||||
"std-env": "^3.8.0",
|
||||
"tinybench": "^2.9.0",
|
||||
"tinyexec": "^0.3.1",
|
||||
"tinypool": "^1.0.1",
|
||||
"tinyrainbow": "^1.2.0",
|
||||
"vite": "^5.0.0",
|
||||
"vite-node": "2.1.9",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"vitest": "vitest.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || >=20.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@edge-runtime/vm": "*",
|
||||
"@types/node": "^18.0.0 || >=20.0.0",
|
||||
"@vitest/browser": "2.1.9",
|
||||
"@vitest/ui": "2.1.9",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edge-runtime/vm": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/ui": {
|
||||
"optional": true
|
||||
},
|
||||
"happy-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"jsdom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"siginfo": "^2.0.0",
|
||||
"stackback": "0.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"why-is-node-running": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "wiggleverse-ecomm-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.5.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
@@ -17,6 +18,7 @@
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.11"
|
||||
"vite": "^5.4.11",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120" fill="none" role="img" aria-label="Wiggleverse">
|
||||
<title>Wiggleverse — mono gold</title>
|
||||
<path d="M98 60 Q105 86 79 93 Q60 82 41 93 Q15 86 22 60 Q41 49 41 27 Q60 8 79 27 Q79 49 98 60 Z" stroke="#F4C76B" stroke-width="2.4" fill="none" stroke-linejoin="round"></path>
|
||||
<g fill="#F4C76B">
|
||||
<circle cx="98" cy="60" r="4.5"></circle><circle cx="79" cy="93" r="4.5"></circle><circle cx="41" cy="93" r="4.5"></circle>
|
||||
<circle cx="22" cy="60" r="4.5"></circle><circle cx="41" cy="27" r="4.5"></circle><circle cx="79" cy="27" r="4.5"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 648 B |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120" fill="none" role="img" aria-label="Wiggleverse">
|
||||
<title>Wiggleverse</title>
|
||||
<rect width="120" height="120" rx="26" fill="#0E1230"></rect>
|
||||
<path d="M98 60 Q105 86 79 93 Q60 82 41 93 Q15 86 22 60 Q41 49 41 27 Q60 8 79 27 Q79 49 98 60 Z" stroke="#F4C76B" stroke-width="5" fill="none" stroke-linejoin="round"></path>
|
||||
<g fill="#EDEAFF">
|
||||
<circle cx="98" cy="60" r="7"></circle><circle cx="79" cy="93" r="7"></circle><circle cx="41" cy="93" r="7"></circle>
|
||||
<circle cx="22" cy="60" r="7"></circle><circle cx="41" cy="27" r="7"></circle><circle cx="79" cy="27" r="7"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 684 B |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+87
-6
@@ -1,10 +1,91 @@
|
||||
// SLICE-1 shell only — the Landing, Sign-in, Create-storefront, and Admin screens
|
||||
// (SD-0001 §5) land in SLICE-2/3. This proves the build and dev server work.
|
||||
// App shell — loads the session, applies the entry-routing rule (SD-0001 §6.5), and renders
|
||||
// the matching screen. The rule is exhaustive: no state lands nowhere (BUC-4). The verify
|
||||
// response carries the same shape as /me, so onAuthed feeds the session directly.
|
||||
import { useEffect, useState } from "react";
|
||||
import { getMe, type StorefrontResult, type VerifyResult } from "./api";
|
||||
import { routeFor, type SessionState } from "./routing";
|
||||
import Admin from "./screens/Admin";
|
||||
import CreateStorefront from "./screens/CreateStorefront";
|
||||
import Landing from "./screens/Landing";
|
||||
import SignIn from "./screens/SignIn";
|
||||
|
||||
type Door = "signup" | "login";
|
||||
type Welcome = "new" | "back" | null;
|
||||
|
||||
export default function App() {
|
||||
const [session, setSession] = useState<SessionState | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [door, setDoor] = useState<Door | null>(null);
|
||||
const [welcome, setWelcome] = useState<Welcome>(null);
|
||||
|
||||
async function reload() {
|
||||
setLoading(true);
|
||||
setSession(await getMe());
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="screen screen--plain">
|
||||
<main className="screen__main">
|
||||
<p className="note">Loading…</p>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const route = routeFor(session ?? { account: null, storefront: null });
|
||||
|
||||
if (route === "landing") {
|
||||
if (door) {
|
||||
return (
|
||||
<SignIn
|
||||
door={door}
|
||||
onBack={() => setDoor(null)}
|
||||
onAuthed={(result: VerifyResult) => {
|
||||
setWelcome(result.created ? "new" : "back");
|
||||
setDoor(null);
|
||||
setSession({ account: result.account, storefront: result.storefront });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <Landing onGetStarted={() => setDoor("signup")} onLogIn={() => setDoor("login")} />;
|
||||
}
|
||||
|
||||
const email = session!.account!.email;
|
||||
|
||||
function signedOut() {
|
||||
setWelcome(null);
|
||||
setDoor(null);
|
||||
setSession(null);
|
||||
}
|
||||
|
||||
if (route === "create-storefront") {
|
||||
return (
|
||||
<CreateStorefront
|
||||
email={email}
|
||||
welcome={welcome}
|
||||
onCreated={(sf: StorefrontResult) => {
|
||||
setWelcome(null);
|
||||
setSession({ account: { email }, storefront: sf });
|
||||
}}
|
||||
onAlreadyOwns={() => void reload()}
|
||||
onSignedOut={signedOut}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>ecomm</h1>
|
||||
<p>Honest commerce. Your storefront is yours.</p>
|
||||
</main>
|
||||
<Admin
|
||||
storefrontName={session!.storefront!.name}
|
||||
email={email}
|
||||
welcome={welcome}
|
||||
onSignedOut={signedOut}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { adminViewFor, hashFor, type AdminView } from "./adminRouting";
|
||||
|
||||
const VIEWS: AdminView[] = [
|
||||
{ view: "home" }, { view: "products" }, { view: "import-upload" },
|
||||
{ view: "import-preview", draftId: 7 }, { view: "run-detail", runId: 12 },
|
||||
];
|
||||
|
||||
describe("adminRouting", () => {
|
||||
it("round-trips every view", () => {
|
||||
for (const v of VIEWS) expect(adminViewFor(hashFor(v))).toEqual(v);
|
||||
});
|
||||
it("defaults junk to home/products", () => {
|
||||
expect(adminViewFor("")).toEqual({ view: "home" });
|
||||
expect(adminViewFor("#/nonsense")).toEqual({ view: "home" });
|
||||
expect(adminViewFor("#/products/imports/drafts/abc")).toEqual({ view: "products" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
// Hash routing for admin sections (SD-0002 §5). The URL is the durable handle on a
|
||||
// section (PUC-8: run detail can be left and returned to); the SD-0001 entry-routing
|
||||
// rule (routing.ts) still decides whether the admin renders at all.
|
||||
export type AdminView =
|
||||
| { view: "home" }
|
||||
| { view: "products" }
|
||||
| { view: "import-upload" }
|
||||
| { view: "import-preview"; draftId: number }
|
||||
| { view: "run-detail"; runId: number };
|
||||
|
||||
export function adminViewFor(hash: string): AdminView {
|
||||
const parts = hash.replace(/^#\/?/, "").split("/").filter(Boolean);
|
||||
if (parts[0] !== "products") return { view: "home" };
|
||||
if (parts.length === 1) return { view: "products" };
|
||||
if (parts[1] === "import" && parts.length === 2) return { view: "import-upload" };
|
||||
if (parts[1] === "imports" && parts[2] === "drafts" && /^\d+$/.test(parts[3] ?? ""))
|
||||
return { view: "import-preview", draftId: Number(parts[3]) };
|
||||
if (parts[1] === "imports" && parts[2] === "runs" && /^\d+$/.test(parts[3] ?? ""))
|
||||
return { view: "run-detail", runId: Number(parts[3]) };
|
||||
return { view: "products" };
|
||||
}
|
||||
|
||||
export function hashFor(v: AdminView): string {
|
||||
switch (v.view) {
|
||||
case "home": return "#/";
|
||||
case "products": return "#/products";
|
||||
case "import-upload": return "#/products/import";
|
||||
case "import-preview": return `#/products/imports/drafts/${v.draftId}`;
|
||||
case "run-detail": return `#/products/imports/runs/${v.runId}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Typed fetch wrappers for the /api/auth/* surface (SD-0001 §6.4). Same-origin via the Vite
|
||||
// proxy; cookies carry the session. Errors return the §6.4 envelope; helpers normalize them.
|
||||
import type { SessionState } from "./routing";
|
||||
|
||||
export interface ApiError {
|
||||
code: string;
|
||||
message: string;
|
||||
retry_after_s?: number;
|
||||
attempts_remaining?: number;
|
||||
}
|
||||
|
||||
export interface VerifyResult {
|
||||
account: { email: string };
|
||||
storefront: { id: number; name: string } | null;
|
||||
created: boolean;
|
||||
}
|
||||
|
||||
export async function errorOf(resp: Response): Promise<ApiError> {
|
||||
try {
|
||||
const body = await resp.json();
|
||||
if (body && body.error) return body.error as ApiError;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
return { code: "unexpected", message: "Something went wrong. Please try again." };
|
||||
}
|
||||
|
||||
export async function getMe(): Promise<SessionState | null> {
|
||||
const resp = await fetch("/api/auth/me", { credentials: "include" });
|
||||
if (resp.status === 401) return null;
|
||||
if (!resp.ok) return null;
|
||||
return (await resp.json()) as SessionState;
|
||||
}
|
||||
|
||||
export async function requestCode(email: string): Promise<ApiError | null> {
|
||||
const resp = await fetch("/api/auth/request-code", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
return resp.ok ? null : await errorOf(resp);
|
||||
}
|
||||
|
||||
export async function verifyCode(
|
||||
email: string,
|
||||
code: string,
|
||||
): Promise<{ ok: true; result: VerifyResult } | { ok: false; error: ApiError }> {
|
||||
const resp = await fetch("/api/auth/verify", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ email, code }),
|
||||
});
|
||||
if (resp.ok) return { ok: true, result: (await resp.json()) as VerifyResult };
|
||||
return { ok: false, error: await errorOf(resp) };
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
|
||||
}
|
||||
|
||||
export interface StorefrontResult {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export async function createStorefront(
|
||||
name: string,
|
||||
): Promise<{ ok: true; storefront: StorefrontResult } | { ok: false; error: ApiError }> {
|
||||
const resp = await fetch("/api/storefronts", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify(name.trim() ? { name: name.trim() } : {}),
|
||||
});
|
||||
if (resp.ok) return { ok: true, storefront: (await resp.json()) as StorefrontResult };
|
||||
return { ok: false, error: await errorOf(resp) };
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { cooldownAppliesTo } from "./cooldown";
|
||||
|
||||
describe("resend cooldown keying (SD-0001 §5.2, INV-3; bug #20)", () => {
|
||||
it("no cooldown running -> send not blocked", () => {
|
||||
expect(cooldownAppliesTo("a@example.com", null, 0)).toBe(false);
|
||||
});
|
||||
|
||||
it("cooldown running for the same address -> blocked", () => {
|
||||
expect(cooldownAppliesTo("a@example.com", "a@example.com", 31)).toBe(true);
|
||||
});
|
||||
|
||||
it("same address modulo case/whitespace -> still blocked", () => {
|
||||
expect(cooldownAppliesTo(" A@Example.COM ", "a@example.com", 31)).toBe(true);
|
||||
});
|
||||
|
||||
it("cooldown running but the input is a different address -> not blocked", () => {
|
||||
expect(cooldownAppliesTo("right@example.com", "wrong@example.com", 31)).toBe(false);
|
||||
});
|
||||
|
||||
it("cooldown expired -> not blocked even for the same address", () => {
|
||||
expect(cooldownAppliesTo("a@example.com", "a@example.com", 0)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
// Resend-cooldown keying (SD-0001 §5.2, INV-3; bug #20). The server's cooldown is
|
||||
// per-address, so the client countdown must be too: it blocks sending only while it
|
||||
// is running AND the current input is the address it was set for. Normalization
|
||||
// mirrors the backend's normalize_email (lowercase + strip, INV-2).
|
||||
|
||||
function normalize(email: string): string {
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function cooldownAppliesTo(
|
||||
input: string,
|
||||
cooldownEmail: string | null,
|
||||
secondsLeft: number,
|
||||
): boolean {
|
||||
if (secondsLeft <= 0 || cooldownEmail === null) return false;
|
||||
return normalize(input) === normalize(cooldownEmail);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import "./styles/index.css";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// Typed fetch wrappers for the /api/products/* surface (SD-0002 §6.4). Same
|
||||
// conventions as api.ts: same-origin, cookie session, §6.4 error envelope.
|
||||
import { errorOf, type ApiError } from "./api";
|
||||
|
||||
export interface DiffSummary { adds: number; updates: number; unchanged: number; errors: number; }
|
||||
export interface Draft {
|
||||
id: number; file_name: string; dialect: string;
|
||||
summary: DiffSummary; unknown_columns: string[]; expires_at: string;
|
||||
}
|
||||
export type RecordKind = "add" | "update" | "unchanged" | "error";
|
||||
export interface RowErrorDetail { line: number; column: string | null; message: string; }
|
||||
export interface FieldChange { field: string; before: unknown; after: unknown; }
|
||||
export interface VariantEntry {
|
||||
options: (string | null)[]; kind?: string;
|
||||
set?: Record<string, unknown>; changes?: FieldChange[];
|
||||
}
|
||||
export interface ImageEntry { src: string; kind?: string; position?: number; alt_text?: string | null; changes?: FieldChange[]; }
|
||||
export interface DraftRecord {
|
||||
handle: string; title: string; kind: RecordKind; variant_count: number;
|
||||
detail: {
|
||||
set?: Record<string, unknown>;
|
||||
option_names?: (string | null)[];
|
||||
changes?: FieldChange[];
|
||||
variants?: VariantEntry[];
|
||||
images?: ImageEntry[];
|
||||
errors?: RowErrorDetail[];
|
||||
};
|
||||
}
|
||||
export interface RunSummary {
|
||||
id: number; file_name: string; dialect: string; created_at: string;
|
||||
completed_at: string | null; status: string; by: string;
|
||||
products_added: number; products_updated: number; rows_errored: number;
|
||||
}
|
||||
export interface RunDetail extends RunSummary {
|
||||
errors: RowErrorDetail[];
|
||||
image_progress: { done: number; total: number };
|
||||
image_outcomes: unknown[];
|
||||
}
|
||||
export interface ProductsSummary {
|
||||
product_count: number; image_problem_count: number; latest_run_id: number | null;
|
||||
}
|
||||
|
||||
export type Result<T> = { ok: true; value: T } | { ok: false; error: ApiError; status: number };
|
||||
|
||||
// One label rule for CSV dialects, shared by Products history / preview / run detail.
|
||||
export function dialectLabel(d: string): string {
|
||||
return d === "canonical" ? "Canonical format" : d;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<Result<T>> {
|
||||
const resp = await fetch(path, { credentials: "include", ...init });
|
||||
if (!resp.ok) return { ok: false, error: await errorOf(resp), status: resp.status };
|
||||
if (resp.status === 204) return { ok: true, value: undefined as T };
|
||||
return { ok: true, value: (await resp.json()) as T };
|
||||
}
|
||||
|
||||
export function getProductsSummary(): Promise<Result<ProductsSummary>> {
|
||||
return request("/api/products/summary");
|
||||
}
|
||||
|
||||
export function uploadImport(file: File): Promise<Result<Draft>> {
|
||||
const body = new FormData();
|
||||
body.append("file", file);
|
||||
// No content-type header: the browser sets the multipart boundary.
|
||||
return request("/api/products/imports", { method: "POST", body });
|
||||
}
|
||||
|
||||
export function getDraft(id: number): Promise<Result<Draft>> {
|
||||
return request(`/api/products/imports/drafts/${id}`);
|
||||
}
|
||||
|
||||
export async function getDraftRecords(
|
||||
id: number, kind?: RecordKind, limit = 100, offset = 0,
|
||||
): Promise<Result<DraftRecord[]>> {
|
||||
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) });
|
||||
if (kind) params.set("kind", kind);
|
||||
const resp = await request<{ records: DraftRecord[] }>(
|
||||
`/api/products/imports/drafts/${id}/records?${params}`,
|
||||
);
|
||||
return resp.ok ? { ok: true, value: resp.value.records } : resp;
|
||||
}
|
||||
|
||||
export function confirmDraft(id: number): Promise<Result<{ run_id: number }>> {
|
||||
return request(`/api/products/imports/drafts/${id}/confirm`, { method: "POST" });
|
||||
}
|
||||
|
||||
export function cancelDraft(id: number): Promise<Result<void>> {
|
||||
return request(`/api/products/imports/drafts/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function listRuns(): Promise<Result<RunSummary[]>> {
|
||||
const resp = await request<{ runs: RunSummary[] }>("/api/products/imports/runs");
|
||||
return resp.ok ? { ok: true, value: resp.value.runs } : resp;
|
||||
}
|
||||
|
||||
export function getRun(id: number): Promise<Result<RunDetail>> {
|
||||
return request(`/api/products/imports/runs/${id}`);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { routeFor } from "./routing";
|
||||
|
||||
describe("entry routing (SD-0001 §6.5)", () => {
|
||||
it("no session -> landing", () => {
|
||||
expect(routeFor({ account: null, storefront: null })).toBe("landing");
|
||||
});
|
||||
|
||||
it("session without storefront -> create-storefront", () => {
|
||||
expect(routeFor({ account: { email: "m@example.com" }, storefront: null })).toBe(
|
||||
"create-storefront",
|
||||
);
|
||||
});
|
||||
|
||||
it("session with storefront -> admin", () => {
|
||||
expect(
|
||||
routeFor({ account: { email: "m@example.com" }, storefront: { id: 1, name: "Shop" } }),
|
||||
).toBe("admin");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
// The single client-side entry-routing rule (SD-0001 §6.5). Exhaustive: every state lands
|
||||
// somewhere (BUC-4 — never stranded). Fed by one server answer (GET /api/auth/me, or the
|
||||
// verify response, which share this shape). storefront is always null in SLICE-2; SLICE-3
|
||||
// makes "admin" reachable.
|
||||
export interface SessionState {
|
||||
account: { email: string } | null;
|
||||
storefront: { id: number; name: string } | null;
|
||||
}
|
||||
|
||||
export type Screen = "landing" | "create-storefront" | "admin";
|
||||
|
||||
export function routeFor(s: SessionState): Screen {
|
||||
if (!s.account) return "landing";
|
||||
if (!s.storefront) return "create-storefront";
|
||||
return "admin";
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// Admin shell (SD-0001 §5.4) — the storefront's stable home; the home view is honestly
|
||||
// empty this release (PUC-8; PUC-9 sign-out). Renders from /me alone: storefront name +
|
||||
// signed-in email. No zeroed metric tiles, no locked-feature teasers (OHM: Agency &
|
||||
// Anti-Manipulation). Visuals per the ui/designs export (hf-admin). SD-0002 §5 adds the
|
||||
// admin nav strip + hash-routed products section (adminRouting.ts).
|
||||
import { useEffect, useState } from "react";
|
||||
import { adminViewFor, type AdminView } from "../adminRouting";
|
||||
import { logout } from "../api";
|
||||
import { AccountChip, Banner, Eyebrow, Screen, TopBar } from "../ui/kit";
|
||||
import ImportPreview from "./products/ImportPreview";
|
||||
import ImportUpload from "./products/ImportUpload";
|
||||
import ProductsPage from "./products/ProductsPage";
|
||||
import RunDetail from "./products/RunDetail";
|
||||
|
||||
interface Props {
|
||||
storefrontName: string;
|
||||
email: string;
|
||||
welcome: "new" | "back" | null;
|
||||
onSignedOut: () => void;
|
||||
}
|
||||
|
||||
export default function Admin({ storefrontName, email, welcome, onSignedOut }: Props) {
|
||||
const [view, setView] = useState<AdminView>(adminViewFor(window.location.hash));
|
||||
|
||||
useEffect(() => {
|
||||
const onHashChange = () => setView(adminViewFor(window.location.hash));
|
||||
window.addEventListener("hashchange", onHashChange);
|
||||
return () => window.removeEventListener("hashchange", onHashChange);
|
||||
}, []);
|
||||
|
||||
async function signOut() {
|
||||
await logout();
|
||||
onSignedOut();
|
||||
}
|
||||
|
||||
return (
|
||||
<Screen plain>
|
||||
<TopBar
|
||||
left={
|
||||
<span className="storeid">
|
||||
<img src="/brand/mark-tile.svg" width={24} height={24} alt="" />
|
||||
<span className="storeid__col">
|
||||
<span className="storeid__name">{storefrontName}</span>
|
||||
<span className="storeid__sub">ecomm storefront</span>
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
right={<AccountChip email={email} onSignOut={signOut} />}
|
||||
/>
|
||||
<nav className="adminnav" aria-label="Admin sections">
|
||||
<a className={`adminnav__item${view.view === "home" ? " adminnav__item--active" : ""}`} href="#/">
|
||||
Overview
|
||||
</a>
|
||||
<a className={`adminnav__item${view.view !== "home" ? " adminnav__item--active" : ""}`} href="#/products">
|
||||
Products
|
||||
</a>
|
||||
</nav>
|
||||
<main className="screen__main">
|
||||
{view.view === "home" && (
|
||||
<div className="empty">
|
||||
{welcome && (
|
||||
<div style={{ marginBottom: 24, width: "100%" }}>
|
||||
<Banner tone="info" title={welcome === "new" ? "Welcome to ecomm" : "Welcome back"}>
|
||||
{welcome === "new"
|
||||
? "A new account was created for this email."
|
||||
: "Signed in to your existing account."}
|
||||
</Banner>
|
||||
</div>
|
||||
)}
|
||||
<div className="empty__seal" aria-hidden="true">
|
||||
<img src="/brand/mark-mono-gold.svg" width={36} height={36} alt="" />
|
||||
</div>
|
||||
<Eyebrow>Your storefront</Eyebrow>
|
||||
<h1>{storefrontName}</h1>
|
||||
<p className="empty__copy">
|
||||
There's nothing to manage yet — and that's a finished state, not a missing one.
|
||||
Catalog, orders, and settings will appear here as ecomm grows.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{view.view === "products" && <ProductsPage />}
|
||||
{view.view === "import-upload" && <ImportUpload />}
|
||||
{view.view === "import-preview" && <ImportPreview draftId={view.draftId} />}
|
||||
{view.view === "run-detail" && <RunDetail runId={view.runId} />}
|
||||
</main>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Create storefront (SD-0001 §5.3) — a storefront-less Merchant establishes their one
|
||||
// storefront (PUC-4/5; PUC-7 defense-in-depth on 409). Visuals per the ui/designs export
|
||||
// (hf-storefront). The welcome banner carries PUC-3's honest copy from the verify step.
|
||||
import { useState } from "react";
|
||||
import { createStorefront, logout, type StorefrontResult } from "../api";
|
||||
import { AccountChip, AuthCard, Banner, Eyebrow, Field, Footer, PrimaryButton, Screen, TopBar, Wordmark } from "../ui/kit";
|
||||
|
||||
interface Props {
|
||||
email: string;
|
||||
welcome: "new" | "back" | null;
|
||||
onCreated: (storefront: StorefrontResult) => void;
|
||||
onAlreadyOwns: () => void;
|
||||
onSignedOut: () => void;
|
||||
}
|
||||
|
||||
export default function CreateStorefront({ email, welcome, onCreated, onAlreadyOwns, onSignedOut }: Props) {
|
||||
const [name, setName] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [alreadyOwns, setAlreadyOwns] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function signOut() {
|
||||
await logout();
|
||||
onSignedOut();
|
||||
}
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const res = await createStorefront(name);
|
||||
setBusy(false);
|
||||
if (res.ok) {
|
||||
onCreated(res.storefront);
|
||||
return;
|
||||
}
|
||||
if (res.error.code === "already_owns_storefront") setAlreadyOwns(true);
|
||||
else setError(res.error.message);
|
||||
}
|
||||
|
||||
return (
|
||||
<Screen horizon>
|
||||
<TopBar left={<Wordmark size={22} />} right={<AccountChip email={email} onSignOut={signOut} />} />
|
||||
<main className="screen__main">
|
||||
<AuthCard>
|
||||
<form onSubmit={submit} style={{ display: "contents" }}>
|
||||
<div>
|
||||
<Eyebrow>One storefront, fully yours</Eyebrow>
|
||||
<h1 style={{ marginTop: 12 }}>Create your storefront</h1>
|
||||
<p className="card__sub">
|
||||
This is the one thing to do right now. It costs nothing and commits you to nothing.
|
||||
</p>
|
||||
</div>
|
||||
{welcome && (
|
||||
<Banner tone="info" title={welcome === "new" ? "Welcome to ecomm" : "Welcome back"}>
|
||||
{welcome === "new"
|
||||
? "A new account was created for this email."
|
||||
: "Signed in to your existing account."}
|
||||
</Banner>
|
||||
)}
|
||||
{alreadyOwns && (
|
||||
<Banner title="Your account already has its storefront">
|
||||
ecomm is one storefront per account today.{" "}
|
||||
<button type="button" className="lk" onClick={onAlreadyOwns}>
|
||||
Go to your admin →
|
||||
</button>
|
||||
</Banner>
|
||||
)}
|
||||
{error && <Banner title="That didn't work">{error} Try again.</Banner>}
|
||||
<Field
|
||||
label="Storefront name"
|
||||
optional
|
||||
helper="You can leave this blank — we'll pick a placeholder name you can change any time."
|
||||
inputProps={{
|
||||
type: "text",
|
||||
value: name,
|
||||
placeholder: "e.g. Ben's Bets",
|
||||
autoFocus: true,
|
||||
onChange: (ev) => setName(ev.target.value),
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<PrimaryButton busy={busy}>{busy ? "Creating…" : "Create storefront"}</PrimaryButton>
|
||||
<p className="note">You can rename, configure, or close your storefront whenever you like.</p>
|
||||
</div>
|
||||
</form>
|
||||
</AuthCard>
|
||||
</main>
|
||||
<Footer />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Landing (SD-0001 §5.1) — a Visitor learns what ecomm is and picks a door. Honest voice,
|
||||
// no trial/pricing/fee (corpus 14.01.0011). Both doors open the same sign-in flow (§5.2);
|
||||
// they differ only in framing. Visuals per the ui/designs export (hf-landing, Direction A).
|
||||
import { Eyebrow, Footer, PrimaryButton, Screen, TopBar, Wordmark } from "../ui/kit";
|
||||
|
||||
interface Props {
|
||||
onGetStarted: () => void;
|
||||
onLogIn: () => void;
|
||||
}
|
||||
|
||||
const PROMISES: Array<[string, string]> = [
|
||||
["No trial clock", "Your storefront never expires or locks."],
|
||||
["No plan wall", "We take only what it takes to run."],
|
||||
["Your data, yours", "Nothing collected you didn't agree to give."],
|
||||
];
|
||||
|
||||
export default function Landing({ onGetStarted, onLogIn }: Props) {
|
||||
return (
|
||||
<Screen horizon>
|
||||
<TopBar
|
||||
left={<Wordmark />}
|
||||
right={
|
||||
<button type="button" className="lk lk--nav" onClick={onLogIn}>
|
||||
Log in
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<main className="screen__main">
|
||||
<div className="hero">
|
||||
<Eyebrow>One storefront, fully yours</Eyebrow>
|
||||
<h1>
|
||||
Sell online,
|
||||
<br />
|
||||
<em>honestly.</em>
|
||||
</h1>
|
||||
<p className="hero__lead">
|
||||
Claim the one storefront that's yours on a platform that takes only what it takes
|
||||
to run — no trial countdown, no plan wall, no data you didn't agree to give.
|
||||
</p>
|
||||
<div className="hero__actions">
|
||||
<PrimaryButton type="button" onClick={onGetStarted}>
|
||||
Create your storefront →
|
||||
</PrimaryButton>
|
||||
<button type="button" className="lk lk--mute" onClick={onLogIn}>
|
||||
Already selling with us? <span style={{ color: "var(--wv-lilac)" }}>Log in</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="promises">
|
||||
{PROMISES.map(([title, copy]) => (
|
||||
<div key={title}>
|
||||
<div className="promises__title">{title}</div>
|
||||
<p>{copy}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// Sign in (SD-0001 §5.2) — the single email + one-time-code flow behind both doors. Step 1
|
||||
// requests a code; step 2 verifies it. Honest copy for every §5.2 state; storefront routing
|
||||
// is handled by App on success. Visuals per the ui/designs export (hf-signin).
|
||||
import { useEffect, useState } from "react";
|
||||
import { requestCode, verifyCode, type ApiError, type VerifyResult } from "../api";
|
||||
import { cooldownAppliesTo } from "../cooldown";
|
||||
import CodeInput from "../ui/CodeInput";
|
||||
import { AuthCard, Banner, Field, Footer, PrimaryButton, Screen, TopBar, Wordmark } from "../ui/kit";
|
||||
|
||||
type Door = "signup" | "login";
|
||||
|
||||
interface Props {
|
||||
door: Door;
|
||||
onAuthed: (result: VerifyResult) => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
function useCooldown(): [number, (s: number) => void] {
|
||||
const [left, setLeft] = useState(0);
|
||||
const ticking = left > 0;
|
||||
useEffect(() => {
|
||||
if (!ticking) return;
|
||||
const t = setInterval(() => setLeft((v) => Math.max(0, v - 1)), 1000);
|
||||
return () => clearInterval(t);
|
||||
}, [ticking]);
|
||||
return [left, setLeft];
|
||||
}
|
||||
|
||||
export default function SignIn({ door, onAuthed, onBack }: Props) {
|
||||
const [step, setStep] = useState<"email" | "code">("email");
|
||||
const [email, setEmail] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [fieldError, setFieldError] = useState<string | null>(null);
|
||||
const [codeError, setCodeError] = useState<string | null>(null);
|
||||
const [bannerError, setBannerError] = useState<ApiError | null>(null);
|
||||
const [cooldown, setCooldown] = useCooldown();
|
||||
// The address the running cooldown belongs to — the server's cooldown is per-address
|
||||
// (INV-3), so a different address must not be blocked by it (bug #20).
|
||||
const [cooldownFor, setCooldownFor] = useState<string | null>(null);
|
||||
|
||||
function applyError(err: ApiError) {
|
||||
setFieldError(null);
|
||||
setCodeError(null);
|
||||
setBannerError(null);
|
||||
if (err.code === "invalid_email") setFieldError(err.message);
|
||||
else if (err.code === "resend_cooldown") setCooldown(err.retry_after_s ?? 60);
|
||||
else if (err.code === "code_mismatch" || err.code === "code_expired") setCodeError(err.message);
|
||||
else setBannerError(err); // delivery_failed, code_exhausted, unexpected
|
||||
}
|
||||
|
||||
async function sendCode(): Promise<boolean> {
|
||||
setBusy(true);
|
||||
setFieldError(null);
|
||||
setBannerError(null);
|
||||
const err = await requestCode(email);
|
||||
setBusy(false);
|
||||
if (err) {
|
||||
applyError(err);
|
||||
if (err.code === "resend_cooldown") setCooldownFor(email);
|
||||
return err.code === "resend_cooldown"; // a cooldown still means a code is out there
|
||||
}
|
||||
setCooldown(60);
|
||||
setCooldownFor(email);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function submitEmail(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (await sendCode()) {
|
||||
setCode("");
|
||||
setCodeError(null);
|
||||
setStep("code");
|
||||
}
|
||||
}
|
||||
|
||||
async function resend() {
|
||||
setCodeError(null);
|
||||
setCode("");
|
||||
await sendCode();
|
||||
}
|
||||
|
||||
async function submitCode(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setCodeError(null);
|
||||
setBannerError(null);
|
||||
const res = await verifyCode(email, code);
|
||||
setBusy(false);
|
||||
if (!res.ok) {
|
||||
applyError(res.error);
|
||||
return;
|
||||
}
|
||||
onAuthed(res.result);
|
||||
}
|
||||
|
||||
const heading = door === "signup" ? "Create your storefront" : "Log in";
|
||||
const sub =
|
||||
door === "signup"
|
||||
? "Enter your email to begin. There's no password to create."
|
||||
: "Enter your email and we'll send a one-time code to sign in.";
|
||||
|
||||
return (
|
||||
<Screen horizon>
|
||||
<TopBar
|
||||
left={<Wordmark size={22} />}
|
||||
right={
|
||||
<button type="button" className="lk lk--mute" onClick={onBack}>
|
||||
← Back
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<main className="screen__main">
|
||||
<AuthCard>
|
||||
{step === "email" ? (
|
||||
<form onSubmit={submitEmail} style={{ display: "contents" }}>
|
||||
<div>
|
||||
<h1>{heading}</h1>
|
||||
<p className="card__sub">{sub}</p>
|
||||
</div>
|
||||
{bannerError && (
|
||||
<Banner title="We couldn't send the code">
|
||||
The email didn't go out. Try again in a moment — nothing was lost.
|
||||
</Banner>
|
||||
)}
|
||||
<Field
|
||||
label="Email"
|
||||
error={fieldError}
|
||||
inputProps={{
|
||||
type: "email",
|
||||
value: email,
|
||||
placeholder: "you@example.com",
|
||||
autoFocus: true,
|
||||
required: true,
|
||||
autoComplete: "email",
|
||||
onChange: (ev) => setEmail(ev.target.value),
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<PrimaryButton busy={busy} disabled={cooldownAppliesTo(email, cooldownFor, cooldown)}>
|
||||
{busy
|
||||
? "Sending…"
|
||||
: cooldownAppliesTo(email, cooldownFor, cooldown)
|
||||
? `Resend in ${cooldown}s`
|
||||
: "Send code"}
|
||||
</PrimaryButton>
|
||||
<p className="note">
|
||||
We'll email you a one-time code. That's all we need — no password, ever.
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={submitCode} style={{ display: "contents" }}>
|
||||
<div>
|
||||
<h1>Check your email</h1>
|
||||
<p className="card__sub">
|
||||
We sent a 6-digit code to <span style={{ color: "var(--wv-starlight)" }}>{email}</span>.{" "}
|
||||
<button type="button" className="lk" onClick={() => setStep("email")}>
|
||||
Wrong address?
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
{bannerError && (
|
||||
<Banner title={bannerError.code === "code_exhausted" ? "Too many attempts" : "Something went wrong"}>
|
||||
{bannerError.code === "code_exhausted"
|
||||
? "That code is no longer valid. Request a fresh one to keep going."
|
||||
: bannerError.message}
|
||||
</Banner>
|
||||
)}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<CodeInput value={code} onChange={setCode} error={!!codeError} />
|
||||
{codeError && (
|
||||
<p className="note note--attn" role="alert">
|
||||
{codeError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<PrimaryButton busy={busy}>{busy ? "Checking…" : "Continue"}</PrimaryButton>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<p className="note">Good for 10 minutes.</p>
|
||||
{cooldown > 0 ? (
|
||||
<span className="note">Resend in {cooldown}s</span>
|
||||
) : (
|
||||
<button type="button" className="lk" onClick={resend}>
|
||||
Resend code
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</AuthCard>
|
||||
</main>
|
||||
<Footer />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
// Import preview (SD-0002 §5.4) — the consent gate. Summary tiles filter a
|
||||
// drill-in diff list; the sticky footer carries confirm (PUC-3) / cancel (PUC-3a).
|
||||
// Diff glyphs pair with color, never color alone (§6.6).
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
cancelDraft,
|
||||
confirmDraft,
|
||||
dialectLabel,
|
||||
getDraft,
|
||||
getDraftRecords,
|
||||
type Draft,
|
||||
type DraftRecord,
|
||||
type FieldChange,
|
||||
type RecordKind,
|
||||
type VariantEntry,
|
||||
} from "../../productsApi";
|
||||
import { Banner } from "../../ui/kit";
|
||||
|
||||
const PAGE = 100;
|
||||
|
||||
function fmt(v: unknown): string {
|
||||
if (v === null || v === undefined) return "—";
|
||||
if (Array.isArray(v)) return v.length ? v.join(", ") : "—";
|
||||
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
|
||||
return String(v);
|
||||
}
|
||||
|
||||
function KindChip({ kind }: { kind: RecordKind }) {
|
||||
return <span className={`kindchip kindchip--${kind}`}>{kind}</span>;
|
||||
}
|
||||
|
||||
function SetLine({ field, value }: { field: string; value: unknown }) {
|
||||
return (
|
||||
<div className="diffchange">
|
||||
<span className="diffchange__glyph--add">+ </span>
|
||||
{field}: {fmt(value)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangeRow({ change }: { change: FieldChange }) {
|
||||
return (
|
||||
<div className="diffchange">
|
||||
{change.field}: <span className="diffchange__glyph--del">− {fmt(change.before)}</span> →{" "}
|
||||
<span className="diffchange__glyph--add">+ {fmt(change.after)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function variantLabel(v: VariantEntry): string {
|
||||
const opts = v.options.filter((o): o is string => o != null);
|
||||
return opts.length ? `Variant ${opts.join(" / ")}` : "Variant";
|
||||
}
|
||||
|
||||
function RecordDetail({ record }: { record: DraftRecord }) {
|
||||
const d = record.detail;
|
||||
if (record.kind === "error") {
|
||||
return (
|
||||
<div>
|
||||
{(d.errors ?? []).map((e, i) => (
|
||||
<div className="diffchange" key={i}>
|
||||
line {e.line}: {e.column != null && `'${e.column}' — `}
|
||||
{e.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
{Object.entries(d.set ?? {}).map(([field, value]) => (
|
||||
<SetLine key={field} field={field} value={value} />
|
||||
))}
|
||||
{(d.changes ?? []).map((c, i) => (
|
||||
<ChangeRow key={i} change={c} />
|
||||
))}
|
||||
{(d.variants ?? []).map((v, i) => (
|
||||
<div key={i}>
|
||||
<div className="diffchange diffchange--head">
|
||||
{variantLabel(v)}
|
||||
{v.kind ? ` (${v.kind})` : ""}
|
||||
</div>
|
||||
{Object.entries(v.set ?? {}).map(([field, value]) => (
|
||||
<SetLine key={field} field={field} value={value} />
|
||||
))}
|
||||
{(v.changes ?? []).map((c, j) => (
|
||||
<ChangeRow key={j} change={c} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{(d.images ?? []).map((img, i) =>
|
||||
img.kind && img.kind !== "add" ? (
|
||||
<div key={i}>
|
||||
<div className="diffchange diffchange--head">
|
||||
image: {img.src} ({img.kind})
|
||||
</div>
|
||||
{(img.changes ?? []).map((c, j) => (
|
||||
<ChangeRow key={j} change={c} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="diffchange" key={i}>
|
||||
<span className="diffchange__glyph--add">+ </span>image: {img.src}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorTable({ records }: { records: DraftRecord[] }) {
|
||||
const rows = records.flatMap((r) => r.detail.errors ?? []);
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
<table className="errortable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Line</th>
|
||||
<th>Column</th>
|
||||
<th>Problem</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((e, i) => (
|
||||
<tr key={i}>
|
||||
<td>{e.line}</td>
|
||||
<td>{e.column ?? "—"}</td>
|
||||
<td>{e.message}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ImportPreview({ draftId }: { draftId: number }) {
|
||||
const [draft, setDraft] = useState<Draft | null>(null);
|
||||
const [loadFail, setLoadFail] = useState<"gone" | "expired" | "failed" | null>(null);
|
||||
const [filter, setFilter] = useState<RecordKind | null>(null);
|
||||
const [records, setRecords] = useState<DraftRecord[] | null>(null);
|
||||
const [recordsError, setRecordsError] = useState<"load" | "more" | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [moreBusy, setMoreBusy] = useState(false);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const [stale, setStale] = useState(false);
|
||||
const [nothingNote, setNothingNote] = useState(false);
|
||||
const [confirmError, setConfirmError] = useState<string | null>(null);
|
||||
// Generation counter for the records list: bumped on every page-0 (re)load, so a
|
||||
// page-0 or show-more response that resolves after a tile/filter (or draft) switch
|
||||
// is recognized as stale and dropped instead of clobbering/appending to the new list.
|
||||
const recordsGen = useRef(0);
|
||||
|
||||
async function loadDraft() {
|
||||
setLoadFail(null);
|
||||
const resp = await getDraft(draftId);
|
||||
if (!resp.ok) {
|
||||
setLoadFail(resp.status === 404 ? "gone" : resp.status === 410 ? "expired" : "failed");
|
||||
return;
|
||||
}
|
||||
setDraft(resp.value);
|
||||
}
|
||||
useEffect(() => {
|
||||
// A new draft means a fresh consent gate — reset everything the old one set.
|
||||
setFilter(null);
|
||||
setStale(false);
|
||||
setConfirmError(null);
|
||||
setNothingNote(false);
|
||||
setRecords(null);
|
||||
setRecordsError(null);
|
||||
setHasMore(false);
|
||||
void loadDraft();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [draftId]);
|
||||
|
||||
function loadRecords(kind: RecordKind | null) {
|
||||
recordsGen.current += 1;
|
||||
const gen = recordsGen.current;
|
||||
setRecords(null);
|
||||
setRecordsError(null);
|
||||
setHasMore(false);
|
||||
void getDraftRecords(draftId, kind ?? undefined, PAGE, 0).then((resp) => {
|
||||
if (gen !== recordsGen.current) return;
|
||||
if (!resp.ok) {
|
||||
setRecordsError("load");
|
||||
return;
|
||||
}
|
||||
setRecords(resp.value);
|
||||
setHasMore(resp.value.length === PAGE);
|
||||
});
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!draft) return;
|
||||
loadRecords(filter);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [draft, draftId, filter]);
|
||||
|
||||
async function showMore() {
|
||||
if (!records) return;
|
||||
const gen = recordsGen.current;
|
||||
setMoreBusy(true);
|
||||
setRecordsError(null);
|
||||
const resp = await getDraftRecords(draftId, filter ?? undefined, PAGE, records.length);
|
||||
setMoreBusy(false);
|
||||
// Filter/draft switched while this page was in flight — drop the stale page.
|
||||
if (gen !== recordsGen.current) return;
|
||||
if (!resp.ok) {
|
||||
setRecordsError("more");
|
||||
return;
|
||||
}
|
||||
setRecords((prev) => [...(prev ?? []), ...resp.value]);
|
||||
setHasMore(resp.value.length === PAGE);
|
||||
}
|
||||
|
||||
async function onConfirm() {
|
||||
setConfirming(true);
|
||||
setConfirmError(null);
|
||||
const resp = await confirmDraft(draftId);
|
||||
if (resp.ok) {
|
||||
window.location.hash = `#/products/imports/runs/${resp.value.run_id}`;
|
||||
return;
|
||||
}
|
||||
setConfirming(false);
|
||||
if ((resp.status === 409 && resp.error.code === "preview_stale") || resp.status === 410) {
|
||||
setStale(true);
|
||||
} else if (resp.status === 409 && resp.error.code === "nothing_to_apply") {
|
||||
setNothingNote(true);
|
||||
} else {
|
||||
setConfirmError(resp.error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function onCancel() {
|
||||
setCancelling(true);
|
||||
// PUC-3a — cancel even on draft-gone (404) still navigates home.
|
||||
await cancelDraft(draftId);
|
||||
window.location.hash = "#/products";
|
||||
}
|
||||
|
||||
if (loadFail === "gone") {
|
||||
return (
|
||||
<Banner tone="attn" title="This preview is gone">
|
||||
<a href="#/products">Back to Products</a>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
if (loadFail === "expired") {
|
||||
return (
|
||||
<Banner tone="attn" title="This preview expired — upload the file again">
|
||||
<a href="#/products/import">Upload the file again</a>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
if (loadFail === "failed") {
|
||||
return (
|
||||
<Banner tone="attn" title="Couldn't load this preview">
|
||||
Something went wrong on our side.{" "}
|
||||
<button type="button" className="linklike" onClick={() => void loadDraft()}>
|
||||
Retry
|
||||
</button>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
if (!draft) {
|
||||
return (
|
||||
<p className="note" role="status">
|
||||
Loading…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const { summary } = draft;
|
||||
const tiles: { kind: RecordKind; num: number; label: string }[] = [
|
||||
{ kind: "add", num: summary.adds, label: "to add" },
|
||||
{ kind: "update", num: summary.updates, label: "to update" },
|
||||
{ kind: "unchanged", num: summary.unchanged, label: "unchanged" },
|
||||
{ kind: "error", num: summary.errors, label: "errors" },
|
||||
];
|
||||
const toApply = summary.adds + summary.updates;
|
||||
|
||||
return (
|
||||
<div className="products">
|
||||
<p className="note">
|
||||
<a href="#/products">← Products</a>
|
||||
</p>
|
||||
<h1>Import preview — {draft.file_name}</h1>
|
||||
<p className="note">{dialectLabel(draft.dialect)}</p>
|
||||
{draft.unknown_columns.length > 0 && (
|
||||
<Banner tone="info" title="Columns not imported">
|
||||
{draft.unknown_columns.length > 8 ? (
|
||||
<details>
|
||||
<summary>{draft.unknown_columns.length} columns not imported</summary>
|
||||
{draft.unknown_columns.join(", ")}
|
||||
</details>
|
||||
) : (
|
||||
draft.unknown_columns.join(", ")
|
||||
)}
|
||||
</Banner>
|
||||
)}
|
||||
<div className="tiles">
|
||||
{tiles.map((t) => (
|
||||
<button
|
||||
key={t.kind}
|
||||
type="button"
|
||||
className={`tile tile--${t.kind}${filter === t.kind ? " tile--active" : ""}`}
|
||||
aria-pressed={filter === t.kind}
|
||||
onClick={() => setFilter(filter === t.kind ? null : t.kind)}
|
||||
>
|
||||
<span className="tile__num">{t.num.toLocaleString()}</span>
|
||||
<span className="tile__label">{t.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{filter === "error" && records && <ErrorTable records={records} />}
|
||||
{recordsError === "load" ? (
|
||||
<p className="note note--attn" role="alert">
|
||||
Couldn't load these records.{" "}
|
||||
<button type="button" className="linklike" onClick={() => loadRecords(filter)}>
|
||||
Retry
|
||||
</button>
|
||||
</p>
|
||||
) : records === null ? (
|
||||
<p className="note" role="status">
|
||||
Loading…
|
||||
</p>
|
||||
) : records.length === 0 ? (
|
||||
<p className="note">Nothing to show here.</p>
|
||||
) : (
|
||||
<div className="difflist">
|
||||
{records.map((r, i) => (
|
||||
<details className="difflist__item" key={`${r.handle}-${i}`}>
|
||||
<summary>
|
||||
<span className="difflist__handle">{r.handle}</span> · {r.title} ·{" "}
|
||||
<KindChip kind={r.kind} /> · {r.variant_count}{" "}
|
||||
{r.variant_count === 1 ? "variant" : "variants"}
|
||||
</summary>
|
||||
<RecordDetail record={r} />
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hasMore && (
|
||||
<p>
|
||||
<button type="button" className="btn-secondary" disabled={moreBusy} onClick={() => void showMore()}>
|
||||
{moreBusy ? "Loading…" : "Show more"}
|
||||
</button>
|
||||
{recordsError === "more" && (
|
||||
<span className="note note--attn" role="alert">
|
||||
Couldn't load more records.{" "}
|
||||
<button type="button" className="linklike" onClick={() => void showMore()}>
|
||||
Retry
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<div className="sticky-footer" aria-live="polite">
|
||||
{stale ? (
|
||||
<Banner tone="attn" title="Your catalog changed since this preview — upload the file again">
|
||||
<a href="#/products/import">Upload the file again</a>
|
||||
</Banner>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
disabled={toApply === 0 || confirming || cancelling}
|
||||
onClick={() => void onConfirm()}
|
||||
>
|
||||
{confirming ? "Importing…" : `Import ${toApply.toLocaleString()} products`}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
disabled={confirming || cancelling}
|
||||
onClick={() => void onCancel()}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{(toApply === 0 || nothingNote) && (
|
||||
<span className="note">Nothing to change — your catalog already matches this file</span>
|
||||
)}
|
||||
{confirmError && (
|
||||
<span className="note note--attn" role="alert">
|
||||
{confirmError}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Import — upload (SD-0002 §5.3). Selecting a file starts upload + validation
|
||||
// immediately (PUC-2); file-level rejections (PUC-5a) render in place with the
|
||||
// picker live for retry. No notifications — errors render here.
|
||||
import { useRef, useState } from "react";
|
||||
import { uploadImport } from "../../productsApi";
|
||||
import { Banner } from "../../ui/kit";
|
||||
|
||||
export default function ImportUpload() {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
async function onPick(files: FileList | null) {
|
||||
const file = files?.[0];
|
||||
if (!file) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const resp = await uploadImport(file);
|
||||
setBusy(false);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
if (!resp.ok) {
|
||||
setError(resp.error.message);
|
||||
return;
|
||||
}
|
||||
window.location.hash = `#/products/imports/drafts/${resp.value.id}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="products products--narrow">
|
||||
<p className="note">
|
||||
<a href="#/products">← Products</a>
|
||||
</p>
|
||||
<h1>Import products</h1>
|
||||
{error && (
|
||||
<Banner tone="attn" title="That file can't be imported">
|
||||
{error}
|
||||
</Banner>
|
||||
)}
|
||||
<label className={`dropzone${busy ? " dropzone--busy" : ""}`}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
disabled={busy}
|
||||
onChange={(e) => void onPick(e.target.files)}
|
||||
/>
|
||||
<span className="dropzone__title" aria-live="polite">
|
||||
{busy ? "Validating…" : "Choose a CSV file"}
|
||||
</span>
|
||||
<span className="note">CSV, up to 5,000 rows</span>
|
||||
</label>
|
||||
<p className="note">
|
||||
Works with the canonical format.{" "}
|
||||
<a href="/api/products/sample.csv" download>
|
||||
Download sample CSV
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// Products page (SD-0002 §5.2) — the catalog's home: where imports start and history
|
||||
// lives. SLICE-5: export is disabled (SLICE-6 ships it); the browsable list is #14's.
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
dialectLabel,
|
||||
getProductsSummary,
|
||||
listRuns,
|
||||
type ProductsSummary,
|
||||
type RunSummary,
|
||||
} from "../../productsApi";
|
||||
import { Banner } from "../../ui/kit";
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
applying: "Importing…",
|
||||
fetching_images: "Fetching images…",
|
||||
complete: "Complete",
|
||||
complete_with_problems: "Complete with problems",
|
||||
};
|
||||
|
||||
export default function ProductsPage() {
|
||||
const [summary, setSummary] = useState<ProductsSummary | null>(null);
|
||||
const [runs, setRuns] = useState<RunSummary[] | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
async function load() {
|
||||
setFailed(false);
|
||||
const [s, r] = await Promise.all([getProductsSummary(), listRuns()]);
|
||||
if (!s.ok || !r.ok) {
|
||||
setFailed(true);
|
||||
return;
|
||||
}
|
||||
setSummary(s.value);
|
||||
setRuns(r.value);
|
||||
}
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<Banner tone="attn" title="Couldn't load your products">
|
||||
Something went wrong on our side.{" "}
|
||||
<button type="button" className="linklike" onClick={() => void load()}>
|
||||
Retry
|
||||
</button>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
if (!summary || !runs) {
|
||||
return (
|
||||
<p className="note" role="status">
|
||||
Loading…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const empty = summary.product_count === 0;
|
||||
return (
|
||||
<div className="products">
|
||||
<header className="products__header">
|
||||
<h1>
|
||||
Products
|
||||
{!empty && <span className="products__count"> · {summary.product_count.toLocaleString()}</span>}
|
||||
</h1>
|
||||
<div className="products__actions">
|
||||
<div className="products__export">
|
||||
<button type="button" className="btn-secondary" disabled title="Export arrives in a coming release">
|
||||
Export
|
||||
</button>
|
||||
<span className="note">Export arrives in a coming release</span>
|
||||
</div>
|
||||
<a className="btn-primary" href="#/products/import">
|
||||
Import products
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
{empty ? (
|
||||
<div className="empty">
|
||||
<p className="empty__copy">No products yet. Bulk import is how product data gets in.</p>
|
||||
<a className="btn-primary" href="#/products/import">
|
||||
Import products
|
||||
</a>
|
||||
<p className="note">
|
||||
<a href="/api/products/sample.csv" download>
|
||||
Download sample CSV
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="note">Your catalog is loaded. The browsable product list arrives with an upcoming release.</p>
|
||||
)}
|
||||
<section className="products__history">
|
||||
<h2>Import history</h2>
|
||||
{runs.length === 0 ? (
|
||||
<p className="note">No imports yet.</p>
|
||||
) : (
|
||||
<table className="datatable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th><th>File</th><th>Dialect</th><th>Added</th><th>Updated</th><th>Errors</th><th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{runs.map((r) => (
|
||||
<tr
|
||||
key={r.id}
|
||||
className="datatable__rowlink"
|
||||
onClick={() => {
|
||||
window.location.hash = `#/products/imports/runs/${r.id}`;
|
||||
}}
|
||||
>
|
||||
<td>{new Date(r.created_at).toLocaleString()}</td>
|
||||
<td>
|
||||
{/* Anchor = the keyboard/SR path (§6.6); the row onClick stays as a
|
||||
mouse convenience. Both set the same hash, so the double fire on
|
||||
an anchor click is idempotent. */}
|
||||
<a href={`#/products/imports/runs/${r.id}`}>{r.file_name}</a>
|
||||
</td>
|
||||
<td>{dialectLabel(r.dialect)}</td>
|
||||
<td>{r.products_added}</td>
|
||||
<td>{r.products_updated}</td>
|
||||
<td>{r.rows_errored}</td>
|
||||
<td>{STATUS_LABELS[r.status] ?? r.status}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Run detail (SD-0002 §5.5) — report card for a completed or in-progress import run.
|
||||
// NO images section this slice (SLICE-7). PUC-4/5/8.
|
||||
import { useEffect, useState } from "react";
|
||||
import { dialectLabel, getRun, type RunDetail as RunDetailType } from "../../productsApi";
|
||||
import { Banner } from "../../ui/kit";
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
applying: "Importing…",
|
||||
fetching_images: "Fetching images…",
|
||||
complete: "Complete",
|
||||
complete_with_problems: "Complete with problems",
|
||||
};
|
||||
|
||||
export default function RunDetail({ runId }: { runId: number }) {
|
||||
const [run, setRun] = useState<RunDetailType | null>(null);
|
||||
const [loadFail, setLoadFail] = useState<"gone" | "failed" | null>(null);
|
||||
|
||||
async function load() {
|
||||
setLoadFail(null);
|
||||
const resp = await getRun(runId);
|
||||
if (!resp.ok) {
|
||||
setLoadFail(resp.status === 404 ? "gone" : "failed");
|
||||
return;
|
||||
}
|
||||
setRun(resp.value);
|
||||
}
|
||||
useEffect(() => {
|
||||
void load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [runId]);
|
||||
|
||||
if (loadFail === "gone") {
|
||||
return (
|
||||
<Banner tone="attn" title="No such import run">
|
||||
<a href="#/products">← Products</a>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
if (loadFail === "failed") {
|
||||
return (
|
||||
<Banner tone="attn" title="Couldn't load this import run">
|
||||
Something went wrong on our side.{" "}
|
||||
<button type="button" className="linklike" onClick={() => void load()}>
|
||||
Retry
|
||||
</button>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
if (!run) {
|
||||
return (
|
||||
<p className="note" role="status">
|
||||
Loading…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="products">
|
||||
<p className="note">
|
||||
<a href="#/products">← Products</a>
|
||||
</p>
|
||||
<h1>{run.file_name}</h1>
|
||||
<p className="note">
|
||||
Imported {new Date(run.created_at).toLocaleString()} by {run.by} · {dialectLabel(run.dialect)}
|
||||
</p>
|
||||
<p className="note">
|
||||
{run.products_added} added · {run.products_updated} updated · {run.rows_errored} rows in
|
||||
error
|
||||
</p>
|
||||
<p className="note">{STATUS_LABELS[run.status] ?? run.status}</p>
|
||||
{run.errors.length > 0 && (
|
||||
<table className="errortable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Line</th>
|
||||
<th>Column</th>
|
||||
<th>Problem</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{run.errors.map((e, i) => (
|
||||
<tr key={i}>
|
||||
<td>{e.line}</td>
|
||||
<td>{e.column ?? "—"}</td>
|
||||
<td>{e.message}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
/* ecomm app chrome — the hf-kit primitives (Direction A · Quiet centered) as CSS.
|
||||
Depth = layered surfaces + hairlines; hover = small lift, no new shadows. */
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body, #root { height: 100%; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--wv-midnight);
|
||||
color: var(--wv-starlight);
|
||||
font-family: var(--wv-font-body);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ── screen ground: barely-there starfield + optional gold horizon ─────────── */
|
||||
.screen {
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
background:
|
||||
radial-gradient(1.3px 1.3px at 16% 22%, rgba(237, 234, 255, .34), transparent),
|
||||
radial-gradient(1.2px 1.2px at 78% 14%, rgba(237, 234, 255, .22), transparent),
|
||||
radial-gradient(1.1px 1.1px at 88% 46%, rgba(155, 140, 255, .30), transparent),
|
||||
radial-gradient(1.3px 1.3px at 30% 72%, rgba(237, 234, 255, .24), transparent),
|
||||
radial-gradient(1.1px 1.1px at 62% 86%, rgba(237, 234, 255, .18), transparent),
|
||||
radial-gradient(1.2px 1.2px at 7% 56%, rgba(155, 140, 255, .26), transparent),
|
||||
var(--wv-midnight);
|
||||
}
|
||||
.screen--horizon {
|
||||
background:
|
||||
radial-gradient(120% 70% at 50% 142%, rgba(244, 199, 107, .16) 0%, rgba(244, 199, 107, .05) 38%, transparent 64%),
|
||||
radial-gradient(1.3px 1.3px at 16% 22%, rgba(237, 234, 255, .34), transparent),
|
||||
radial-gradient(1.2px 1.2px at 78% 14%, rgba(237, 234, 255, .22), transparent),
|
||||
radial-gradient(1.1px 1.1px at 88% 46%, rgba(155, 140, 255, .30), transparent),
|
||||
radial-gradient(1.3px 1.3px at 30% 72%, rgba(237, 234, 255, .24), transparent),
|
||||
radial-gradient(1.1px 1.1px at 62% 86%, rgba(237, 234, 255, .18), transparent),
|
||||
radial-gradient(1.2px 1.2px at 7% 56%, rgba(155, 140, 255, .26), transparent),
|
||||
var(--wv-midnight);
|
||||
}
|
||||
.screen--plain { background: var(--wv-midnight); }
|
||||
|
||||
.screen__main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 24px;
|
||||
}
|
||||
|
||||
/* ── top bar: glass chrome ──────────────────────────────────────────────────── */
|
||||
.topbar {
|
||||
flex: 0 0 auto;
|
||||
height: 68px;
|
||||
padding: 0 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
background: var(--glass-sky);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
}
|
||||
.topbar__side { display: flex; align-items: center; gap: 16px; min-width: 0; }
|
||||
|
||||
/* ── wordmark ───────────────────────────────────────────────────────────────── */
|
||||
.wordmark { display: inline-flex; align-items: center; gap: 10px; }
|
||||
.wordmark img { display: block; border-radius: 6px; }
|
||||
.wordmark__name {
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-bold);
|
||||
font-size: 22px;
|
||||
letter-spacing: var(--tracking-display);
|
||||
color: var(--wv-starlight);
|
||||
}
|
||||
|
||||
/* ── links & text buttons ───────────────────────────────────────────────────── */
|
||||
.lk {
|
||||
color: var(--wv-lilac);
|
||||
text-decoration: none;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
transition: color var(--dur-fast) var(--ease);
|
||||
}
|
||||
.lk:hover { color: var(--wv-gold); }
|
||||
.lk--mute { color: var(--text-on-dark-mute); }
|
||||
.lk--nav {
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-medium);
|
||||
font-size: 14px;
|
||||
color: var(--wv-starlight);
|
||||
}
|
||||
|
||||
.signout {
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-medium);
|
||||
font-size: 14px;
|
||||
color: var(--wv-starlight);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: color var(--dur-fast) var(--ease);
|
||||
}
|
||||
.signout:hover { color: var(--wv-gold); }
|
||||
|
||||
/* ── account chip (top-bar right, signed in) ────────────────────────────────── */
|
||||
.chip { display: flex; align-items: center; gap: 14px; min-width: 0; }
|
||||
.chip__avatar {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 15px;
|
||||
background: var(--wv-lilac-16);
|
||||
border: 1px solid var(--border-card);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-bold);
|
||||
font-size: 13px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.chip__email {
|
||||
font-size: 14px;
|
||||
color: var(--text-on-dark-soft);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chip__divider { width: 1px; height: 18px; background: var(--border-soft); flex: 0 0 auto; }
|
||||
|
||||
/* ── eyebrow ────────────────────────────────────────────────────────────────── */
|
||||
.eyebrow {
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-medium);
|
||||
font-size: var(--text-eyebrow);
|
||||
letter-spacing: var(--tracking-eyebrow);
|
||||
text-transform: uppercase;
|
||||
color: var(--wv-lilac);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── auth card — the one surface with a real shadow ─────────────────────────── */
|
||||
.card {
|
||||
width: 440px;
|
||||
max-width: 100%;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border-card);
|
||||
border-radius: var(--radius-card);
|
||||
padding: 40px 40px 34px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
box-shadow: var(--shadow-soft);
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
.card h1 {
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-bold);
|
||||
letter-spacing: var(--tracking-display);
|
||||
font-size: 27px;
|
||||
line-height: 1.08;
|
||||
margin: 0 0 9px;
|
||||
}
|
||||
.card__sub { font-size: 14.5px; line-height: 1.55; color: var(--text-on-dark-soft); margin: 0; }
|
||||
|
||||
/* ── fields ─────────────────────────────────────────────────────────────────── */
|
||||
.field { display: flex; flex-direction: column; gap: 8px; }
|
||||
.field__label {
|
||||
font-weight: var(--weight-medium);
|
||||
font-size: 13.5px;
|
||||
color: var(--text-on-dark-soft);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
}
|
||||
.field__optional { font-size: 12.5px; color: var(--text-on-dark-mute); font-weight: var(--weight-regular); }
|
||||
.field__input {
|
||||
height: 52px;
|
||||
border-radius: 10px;
|
||||
padding: 0 16px;
|
||||
background: rgba(237, 234, 255, .035);
|
||||
border: 1.5px solid var(--border-soft);
|
||||
font-family: var(--wv-font-body);
|
||||
font-size: 16px;
|
||||
color: var(--wv-starlight);
|
||||
width: 100%;
|
||||
transition: border-color var(--dur-fast) var(--ease), box-shadow var(--dur-fast) var(--ease);
|
||||
}
|
||||
.field__input::placeholder { color: var(--text-on-dark-mute); }
|
||||
.field__input:hover { border-color: var(--border-strong); }
|
||||
.field__input:focus {
|
||||
outline: none;
|
||||
border-color: var(--wv-gold);
|
||||
box-shadow: 0 0 0 3px var(--wv-gold-28);
|
||||
background: rgba(237, 234, 255, .06);
|
||||
}
|
||||
.field__input--error { border-color: var(--wv-gold); }
|
||||
|
||||
.note { font-size: 13px; line-height: 1.45; color: var(--text-on-dark-mute); margin: 0; }
|
||||
.note--attn { color: var(--wv-gold); display: flex; gap: 7px; align-items: baseline; }
|
||||
.note--attn::before { content: "◆"; font-size: 11px; }
|
||||
|
||||
/* ── primary button: gold pill ──────────────────────────────────────────────── */
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: .4em;
|
||||
width: 100%;
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-medium);
|
||||
font-size: 15.5px;
|
||||
line-height: 1;
|
||||
padding: .85rem 1.4rem;
|
||||
border-radius: var(--radius-pill);
|
||||
border: var(--btn-border-w) solid transparent;
|
||||
background: var(--cta);
|
||||
color: var(--cta-text);
|
||||
cursor: pointer;
|
||||
transition: transform var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease);
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) { background: var(--cta-hover); transform: var(--lift-1); }
|
||||
.btn-primary:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.btn-primary--auto { width: auto; }
|
||||
.btn-primary:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
|
||||
|
||||
/* ── banner: gold attention / lilac info (no red in the palette) ────────────── */
|
||||
.banner {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 12px;
|
||||
align-items: flex-start;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-on-dark-soft);
|
||||
text-align: left;
|
||||
}
|
||||
.banner--attn { background: rgba(244, 199, 107, .10); border: 1px solid var(--wv-gold-40); }
|
||||
.banner--info { background: var(--wv-lilac-08); border: 1px solid var(--wv-lilac-18); }
|
||||
.banner__icon { font-size: 13px; line-height: 22px; flex: 0 0 auto; }
|
||||
.banner--attn .banner__icon { color: var(--wv-gold); }
|
||||
.banner--info .banner__icon { color: var(--wv-lilac); }
|
||||
.banner__title { color: var(--wv-starlight); font-weight: var(--weight-semibold); margin-bottom: 2px; }
|
||||
|
||||
/* ── code input: 6 cells over one invisible input ───────────────────────────── */
|
||||
.code { position: relative; display: flex; gap: 11px; justify-content: center; }
|
||||
.code__hidden {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
border: none;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.code__cell {
|
||||
width: 52px;
|
||||
height: 64px;
|
||||
border-radius: 11px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(237, 234, 255, .035);
|
||||
border: 1.5px solid var(--border-soft);
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-medium);
|
||||
font-size: 26px;
|
||||
transition: border-color var(--dur-fast) var(--ease), box-shadow var(--dur-fast) var(--ease);
|
||||
}
|
||||
.code--focus .code__cell--active {
|
||||
border-color: var(--wv-gold);
|
||||
box-shadow: 0 0 0 3px var(--wv-gold-28);
|
||||
}
|
||||
.code--error .code__cell { border-color: var(--wv-gold); }
|
||||
|
||||
/* ── footer ─────────────────────────────────────────────────────────────────── */
|
||||
.footer {
|
||||
flex: 0 0 auto;
|
||||
padding: 20px 36px;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-on-dark-mute);
|
||||
background: rgba(9, 12, 34, .4);
|
||||
}
|
||||
.footer__id { display: inline-flex; align-items: center; gap: 9px; }
|
||||
.footer__id img { opacity: .85; }
|
||||
|
||||
/* ── landing ────────────────────────────────────────────────────────────────── */
|
||||
.hero { max-width: 680px; display: flex; flex-direction: column; align-items: center; text-align: center; }
|
||||
.hero h1 {
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-bold);
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.02;
|
||||
font-size: clamp(36px, 6.5vw, 62px);
|
||||
margin: 20px 0 22px;
|
||||
}
|
||||
.hero h1 em { font-style: normal; color: var(--wv-gold); }
|
||||
.hero__lead {
|
||||
font-size: clamp(15.5px, 1.8vw, 18.5px);
|
||||
line-height: 1.6;
|
||||
color: var(--text-on-dark-soft);
|
||||
max-width: 520px;
|
||||
margin: 0 0 32px;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
.hero__actions { display: flex; gap: 14px; align-items: center; flex-wrap: wrap; justify-content: center; }
|
||||
.hero__actions .btn-primary { width: auto; }
|
||||
|
||||
.promises {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
width: 100%;
|
||||
margin-top: 44px;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
padding-top: 24px;
|
||||
text-align: left;
|
||||
}
|
||||
.promises > div { padding: 0 22px; }
|
||||
.promises > div + div { border-left: 1px solid var(--border-soft); }
|
||||
.promises__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 5px;
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-medium);
|
||||
font-size: 14.5px;
|
||||
}
|
||||
.promises__title::before { content: "◆"; color: var(--wv-gold); font-size: 10px; }
|
||||
.promises p { font-size: 13px; line-height: 1.5; color: var(--text-on-dark-mute); margin: 0; }
|
||||
|
||||
/* ── admin shell ────────────────────────────────────────────────────────────── */
|
||||
.storeid { display: inline-flex; align-items: center; gap: 11px; min-width: 0; }
|
||||
.storeid img { display: block; border-radius: 7px; flex: 0 0 auto; }
|
||||
.storeid__col { display: inline-flex; flex-direction: column; line-height: 1.12; min-width: 0; }
|
||||
.storeid__name {
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-bold);
|
||||
font-size: 17px;
|
||||
letter-spacing: -0.01em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.storeid__sub { font-size: 11px; color: var(--text-on-dark-mute); }
|
||||
|
||||
.empty { display: flex; flex-direction: column; align-items: center; text-align: center; max-width: 560px; }
|
||||
.empty__seal {
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border-card);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 26px;
|
||||
background: var(--wv-lilac-08);
|
||||
}
|
||||
.empty h1 {
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-bold);
|
||||
letter-spacing: var(--tracking-display);
|
||||
line-height: 1.04;
|
||||
font-size: clamp(30px, 4.5vw, 42px);
|
||||
margin: 12px 0 16px;
|
||||
}
|
||||
.empty__copy { font-size: 17px; line-height: 1.6; color: var(--text-on-dark-soft); max-width: 460px; margin: 0; text-wrap: pretty; }
|
||||
|
||||
/* ── small screens ──────────────────────────────────────────────────────────── */
|
||||
@media (max-width: 720px) {
|
||||
.topbar { padding: 0 20px; }
|
||||
.footer { padding: 16px 20px; flex-direction: column; }
|
||||
.card { padding: 28px 22px 24px; }
|
||||
.promises { grid-template-columns: 1fr; gap: 14px; }
|
||||
.promises > div { padding: 0; }
|
||||
.promises > div + div { border-left: none; }
|
||||
.code__cell { width: 44px; height: 56px; font-size: 22px; }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/* Wiggleverse webfonts (from the design-system export) — Space Grotesk (display) +
|
||||
Inter (body/UI). Fraunces (human register) deliberately omitted: no screen uses it yet. */
|
||||
@font-face {
|
||||
font-family: "Space Grotesk";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("/fonts/space-grotesk-v22-latin-500.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Space Grotesk";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("/fonts/space-grotesk-v22-latin-700.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("/fonts/inter-v20-latin-regular.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("/fonts/inter-v20-latin-500.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("/fonts/inter-v20-latin-600.woff2") format("woff2");
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/* Global style entry point — Wiggleverse design-system tokens (copied from the
|
||||
ui/designs export) + the ecomm app chrome built on them. */
|
||||
@import "./fonts.css";
|
||||
@import "./tokens-colors.css";
|
||||
@import "./tokens-typography.css";
|
||||
@import "./tokens-spacing.css";
|
||||
@import "./app.css";
|
||||
@import "./products.css";
|
||||
@@ -0,0 +1,260 @@
|
||||
/* Products section (SD-0002 §5) — admin nav strip, the catalog's home page, and the
|
||||
import-flow primitives (dropzone, tiles, difflist — Tasks 12–14 consume these).
|
||||
Same language as app.css: dark ground, glass chrome, hairline borders, small lifts. */
|
||||
|
||||
/* Status accents (SD-0002 design bundle): add / update / error. */
|
||||
:root {
|
||||
--st-add: #1F8A5B;
|
||||
--st-update: #B5830F;
|
||||
--st-error: #C2513E;
|
||||
}
|
||||
|
||||
/* ── admin nav: horizontal strip under the topbar ───────────────────────────── */
|
||||
.adminnav {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
gap: 26px;
|
||||
padding: 0 36px;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
background: rgba(9, 12, 34, .35);
|
||||
}
|
||||
.adminnav__item {
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-medium);
|
||||
font-size: 14px;
|
||||
color: var(--text-on-dark-mute);
|
||||
text-decoration: none;
|
||||
padding: 13px 2px 11px;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color var(--dur-fast) var(--ease);
|
||||
}
|
||||
.adminnav__item:hover { color: var(--wv-starlight); }
|
||||
.adminnav__item--active { color: var(--wv-starlight); border-bottom-color: var(--wv-gold); }
|
||||
|
||||
/* ── products page frame ────────────────────────────────────────────────────── */
|
||||
/* margin-bottom auto pins the page to the top of the centered .screen__main. */
|
||||
.products { width: 100%; max-width: 880px; margin-bottom: auto; }
|
||||
.products--narrow { max-width: 560px; }
|
||||
|
||||
.products__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 16px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.products__header h1 {
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-bold);
|
||||
letter-spacing: var(--tracking-display);
|
||||
font-size: 28px;
|
||||
line-height: 1.1;
|
||||
margin: 0;
|
||||
}
|
||||
.products__count { color: var(--text-on-dark-mute); font-weight: var(--weight-medium); }
|
||||
.products__actions { display: flex; gap: 12px; align-items: center; }
|
||||
/* Disabled Export + its visible "coming release" caption, stacked. */
|
||||
.products__export { display: flex; flex-direction: column; gap: 4px; align-items: center; }
|
||||
.products__export .note { font-size: 11.5px; }
|
||||
.products .btn-primary { width: auto; text-decoration: none; }
|
||||
.products .empty { margin: 24px auto 0; }
|
||||
|
||||
.products__history { margin-top: 44px; }
|
||||
.products__history h2 {
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-semibold);
|
||||
font-size: 17px;
|
||||
letter-spacing: var(--tracking-display);
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
/* ── secondary button: outline twin of .btn-primary ─────────────────────────── */
|
||||
.btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: .4em;
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-medium);
|
||||
font-size: 15.5px;
|
||||
line-height: 1;
|
||||
padding: .85rem 1.4rem;
|
||||
border-radius: var(--radius-pill);
|
||||
border: var(--btn-border-w) solid var(--border-strong);
|
||||
background: transparent;
|
||||
color: var(--text-on-dark-soft);
|
||||
cursor: pointer;
|
||||
transition: border-color var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease),
|
||||
transform var(--dur-fast) var(--ease);
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) { border-color: var(--wv-lilac); color: var(--wv-starlight); transform: var(--lift-1); }
|
||||
.btn-secondary:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.btn-secondary:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
|
||||
|
||||
/* ── button that reads as a link (inline retry etc.) ────────────────────────── */
|
||||
.linklike {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: var(--wv-lilac);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
transition: color var(--dur-fast) var(--ease);
|
||||
}
|
||||
.linklike:hover { color: var(--wv-gold); }
|
||||
|
||||
/* ── data tables (import history; errortable shares the bones) ──────────────── */
|
||||
.datatable, .errortable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.datatable th, .errortable th {
|
||||
text-align: left;
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-medium);
|
||||
font-size: 12px;
|
||||
letter-spacing: .06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-on-dark-mute);
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border-card);
|
||||
}
|
||||
.datatable td, .errortable td {
|
||||
padding: 11px 12px;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
color: var(--text-on-dark-soft);
|
||||
}
|
||||
.datatable__rowlink { cursor: pointer; transition: background var(--dur-fast) var(--ease); }
|
||||
.datatable__rowlink:hover { background: var(--wv-lilac-08); }
|
||||
.errortable td:last-child { color: var(--st-error); }
|
||||
|
||||
/* ── summary tiles (preview, Task 13) ───────────────────────────────────────── */
|
||||
.tiles { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; }
|
||||
.tile {
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border-card);
|
||||
border-radius: var(--radius-panel);
|
||||
padding: 16px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
align-items: flex-start;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease);
|
||||
}
|
||||
.tile:hover { background: var(--surface-raised-hi); }
|
||||
.tile--active { border-color: var(--wv-gold); }
|
||||
.tile__num {
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-bold);
|
||||
font-size: 26px;
|
||||
line-height: 1;
|
||||
color: var(--text-on-dark-soft);
|
||||
}
|
||||
.tile__label { font-size: 12.5px; color: var(--text-on-dark-mute); }
|
||||
.tile--add .tile__num { color: var(--st-add); }
|
||||
.tile--update .tile__num { color: var(--st-update); }
|
||||
.tile--error .tile__num { color: var(--st-error); }
|
||||
|
||||
/* ── diff list (preview records, Task 13) ───────────────────────────────────── */
|
||||
.difflist { list-style: none; margin: 0; padding: 0; }
|
||||
.difflist__item { padding: 12px 4px; border-bottom: 1px solid var(--border-soft); }
|
||||
.difflist__item > summary {
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: var(--text-on-dark-soft);
|
||||
transition: color var(--dur-fast) var(--ease);
|
||||
}
|
||||
.difflist__item > summary:hover { color: var(--wv-starlight); }
|
||||
.difflist__item[open] > summary { margin-bottom: 8px; }
|
||||
.difflist__handle { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 12.5px; }
|
||||
.diffchange {
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-on-dark-soft);
|
||||
}
|
||||
.diffchange--head { color: var(--text-on-dark-mute); margin-top: 6px; }
|
||||
.diffchange__glyph--add { color: var(--st-add); }
|
||||
.diffchange__glyph--del { color: var(--st-error); }
|
||||
|
||||
/* ── kind chip (preview record summaries, Task 13) ──────────────────────────── */
|
||||
.kindchip {
|
||||
display: inline-block;
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-medium);
|
||||
font-size: 11px;
|
||||
letter-spacing: .06em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1;
|
||||
padding: 3px 9px 2px;
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid var(--border-strong);
|
||||
color: var(--text-on-dark-mute);
|
||||
}
|
||||
.kindchip--add { color: var(--st-add); border-color: var(--st-add); }
|
||||
.kindchip--update { color: var(--st-update); border-color: var(--st-update); }
|
||||
.kindchip--error { color: var(--st-error); border-color: var(--st-error); }
|
||||
|
||||
/* preview layout rhythm: tiles + errortable sit between header and difflist */
|
||||
.products .tiles { margin: 24px 0 18px; }
|
||||
.products .errortable { margin: 0 0 18px; }
|
||||
|
||||
/* ── sticky confirm/cancel footer (preview, Task 13) ────────────────────────── */
|
||||
.sticky-footer {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 14px 0;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
background: var(--glass-sky);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
}
|
||||
|
||||
/* ── upload dropzone (Task 12) ──────────────────────────────────────────────── */
|
||||
.dropzone {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
border: 2px dashed var(--border-strong);
|
||||
border-radius: var(--radius-card);
|
||||
cursor: pointer;
|
||||
transition: border-color var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease);
|
||||
}
|
||||
.dropzone:hover { border-color: var(--wv-lilac); background: var(--wv-lilac-08); }
|
||||
.dropzone--busy { opacity: .55; pointer-events: none; }
|
||||
.dropzone input[type="file"] {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dropzone__title {
|
||||
display: block;
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-medium);
|
||||
font-size: 17px;
|
||||
color: var(--text-on-dark-soft);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
/* ── small screens ──────────────────────────────────────────────────────────── */
|
||||
@media (max-width: 720px) {
|
||||
.adminnav { padding: 0 20px; }
|
||||
.tiles { grid-template-columns: repeat(2, 1fr); }
|
||||
.products__header { flex-wrap: wrap; }
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/* Wiggleverse — Color tokens
|
||||
Source of truth: wiggleverse-www/assets/tokens.css (brand BRAND.md §8–9).
|
||||
Dark "sky" is the primary ground; Paper is for long reading. No-center motif. */
|
||||
|
||||
:root {
|
||||
/* ---- Brand palette (base values) ---- */
|
||||
--wv-midnight: #0E1230; /* sky / ground — primary dark background */
|
||||
--wv-indigo: #1C2150; /* raised surfaces on dark (cards, mobile nav) */
|
||||
--wv-indigo-2: #232A63; /* hover state for raised surfaces */
|
||||
--wv-lilac: #9B8CFF; /* accent — "the bonds between us"; links, nodes */
|
||||
--wv-violet: #7C6FE0; /* secondary links / strokes (on light) */
|
||||
--wv-gold: #F4C76B; /* warmth / horizon / primary CTAs */
|
||||
--wv-gold-hi: #F7D488; /* gold hover */
|
||||
--wv-starlight:#EDEAFF; /* nodes / text on dark */
|
||||
--wv-paper: #F6F4FB; /* light-mode background */
|
||||
--wv-ink: #3B2F7A; /* text on light */
|
||||
--wv-night: #090C22; /* footer / deepest ground */
|
||||
|
||||
/* CTA text-on-gold (very dark gold-brown, not pure black) */
|
||||
--wv-gold-ink: #2A2003;
|
||||
--wv-gold-ink-soft: #6B4E10; /* "soon" tag text on gold tint */
|
||||
|
||||
/* ---- Alpha derivations (lilac / gold / starlight washes) ---- */
|
||||
--wv-lilac-08: rgba(155, 140, 255, .08);
|
||||
--wv-lilac-12: rgba(155, 140, 255, .12);
|
||||
--wv-lilac-16: rgba(155, 140, 255, .16);
|
||||
--wv-lilac-18: rgba(155, 140, 255, .18);
|
||||
--wv-lilac-32: rgba(155, 140, 255, .32);
|
||||
--wv-gold-28: rgba(244, 199, 107, .28);
|
||||
--wv-gold-40: rgba(244, 199, 107, .40);
|
||||
--wv-starlight-85: rgba(237, 234, 255, .85);
|
||||
--wv-starlight-78: rgba(237, 234, 255, .78);
|
||||
--wv-starlight-60: rgba(237, 234, 255, .60);
|
||||
--wv-starlight-55: rgba(237, 234, 255, .55);
|
||||
|
||||
/* ---- Semantic aliases ---- */
|
||||
--surface-sky: var(--wv-midnight); /* page ground (dark) */
|
||||
--surface-raised: var(--wv-indigo); /* cards / panels on dark */
|
||||
--surface-raised-hi: var(--wv-indigo-2); /* raised hover */
|
||||
--surface-paper: var(--wv-paper); /* long-reading light sections */
|
||||
--surface-card-light:#FFFFFF; /* build-cards on paper */
|
||||
--surface-footer: var(--wv-night);
|
||||
|
||||
--text-on-dark: var(--wv-starlight);
|
||||
--text-on-dark-soft: var(--wv-starlight-78);
|
||||
--text-on-dark-mute: var(--wv-starlight-60);
|
||||
--text-on-light: var(--wv-ink);
|
||||
--text-on-light-soft:#4B4170;
|
||||
|
||||
--accent: var(--wv-lilac); /* links + nodes on dark */
|
||||
--accent-on-light: var(--wv-violet); /* links + strokes on light */
|
||||
--cta: var(--wv-gold); /* primary action / horizon */
|
||||
--cta-hover: var(--wv-gold-hi);
|
||||
--cta-text: var(--wv-gold-ink);
|
||||
|
||||
--border-soft: var(--wv-lilac-16); /* hairlines on dark */
|
||||
--border-card: var(--wv-lilac-18);
|
||||
--border-strong: var(--wv-lilac-32);
|
||||
--focus-ring: var(--wv-gold);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user