Pak Jabit: full stack — storefront, Spree customisations, catalogue tooling

A South African men's apparel store on Spree 5.6 + Next.js 15. 160 products
live, three markets, live Malaysian courier rates, customer-owned carts.

What is here:
  storefront/  the Next.js app
  backend/     ONLY the custom Spree code. Spree itself is a prebuilt image
               that mounts no app code, so every file here needs an explicit
               volume entry in docker-compose.yml or it is silently ignored.
  catalogue/   the scripts that loaded and maintain the products
  docs/        manifest, mock registry, napkins

Real .env files are committed deliberately. This lives on the house's private
Gitea, and a clone that runs beats a clone that needs a secrets handover.
LANDMINE.md maps every key, what it opens, and what must be rotated before this
repo goes anywhere less private.

The admin API key used to be a literal in six catalogue scripts. They now read
SPREE_ADMIN_KEY from the environment and refuse to run without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-15 22:09:55 +08:00
co-authored by Claude Opus 5
commit a58a61b98c
98 changed files with 15524 additions and 0 deletions
+218
View File
@@ -0,0 +1,218 @@
#!/usr/bin/env python3
"""
Pak Jabit — Photoroom return leg.
The missing half of photoroom_pipeline.py: takes a source image, runs it through
Photoroom, and pushes the result back onto the Spree product as a media asset.
source jpg -> Photoroom /v2/edit -> Spree POST /products/:id/media -> verify
Verification is not optional here. Spree's media#create has a url path that
enqueues a background job and answers `head :accepted` — a 202 with no body and
no way to know if the image ever landed. This uses the multipart path, which
returns 201 plus the serialized asset, and then re-reads the product's media to
confirm the asset id is really there.
Usage:
photoroom_to_spree.py --slug 19-electro-denim-lab-chino-pants --live
photoroom_to_spree.py --slug <slug> --position 1 # make it the thumbnail
photoroom_to_spree.py --slug <slug> --dry-run
Keys come from /root/.photoroom/credentials. Sandbox is the default and
watermarks every image; --live uses the paid allowance (10/month) and is the
only way to get a clean result onto a live storefront.
"""
import argparse
import json
import mimetypes
import pathlib
import sys
import urllib.error
import urllib.request
import uuid
import os as _os
def _spree_admin_key() -> str:
"""The Spree admin API key. Required — never defaulted."""
key = _os.environ.get("SPREE_ADMIN_KEY", "").strip()
if not key:
raise SystemExit(
"SPREE_ADMIN_KEY is not set.\n"
" export SPREE_ADMIN_KEY=<the admin API key>\n"
"It is deliberately not stored in this file — see README.md."
)
return key
SPREE_API = "https://api.jux-lab.com/api/v3/admin"
SPREE_KEY = _spree_admin_key()
PHOTOROOM_EDIT = "https://image-api.photoroom.com/v2/edit"
CREDS = pathlib.Path("/root/.photoroom/credentials")
SOURCE_ROOT = pathlib.Path(
"/root/Opus/Projects/PeakyBuildr/scraped-catalogue/products"
)
def fatal(msg):
print(f"FATAL: {msg}", file=sys.stderr)
sys.exit(1)
def load_key(live: bool) -> str:
if not CREDS.exists():
fatal(f"{CREDS} missing — no API key to use")
want = "PHOTOROOM_LIVE_KEY" if live else "PHOTOROOM_SANDBOX_KEY"
for line in CREDS.read_text().splitlines():
if line.startswith(want + "="):
return line.split("=", 1)[1].strip()
fatal(f"{want} not found in {CREDS}")
def multipart(fields: dict, file_field: str, filename: str, blob: bytes, content_type: str):
boundary = uuid.uuid4().hex
parts = []
for k, v in fields.items():
parts.append(
f'--{boundary}\r\nContent-Disposition: form-data; name="{k}"\r\n\r\n{v}\r\n'.encode()
)
parts.append(
f'--{boundary}\r\nContent-Disposition: form-data; name="{file_field}"; '
f'filename="{filename}"\r\nContent-Type: {content_type}\r\n\r\n'.encode()
+ blob
+ b"\r\n"
)
parts.append(f"--{boundary}--\r\n".encode())
return b"".join(parts), f"multipart/form-data; boundary={boundary}"
def spree_get(path):
req = urllib.request.Request(
f"{SPREE_API}{path}", headers={"X-Spree-Api-Key": SPREE_KEY}
)
with urllib.request.urlopen(req, timeout=60) as r:
return json.loads(r.read())
def find_product(slug):
data = spree_get(f"/products?filter[slug_eq]={slug}&per_page=50")
for p in data.get("data", []):
if p.get("slug") == slug or p.get("attributes", {}).get("slug") == slug:
return p
# The filter key varies by Spree build; fall back to a full scan rather than
# reporting "not found" for a product that is actually there.
page = 1
while True:
d = spree_get(f"/products?per_page=100&page={page}")
for p in d.get("data", []):
if p.get("slug") == slug:
return p
if page >= d.get("meta", {}).get("pages", 1):
return None
page += 1
def photoroom(img_path: pathlib.Path, key: str, size: str, bg: str) -> bytes:
body, ctype = multipart(
{"background.color": bg, "outputSize": size, "padding": "0.05"},
"imageFile",
img_path.name,
img_path.read_bytes(),
mimetypes.guess_type(img_path.name)[0] or "image/jpeg",
)
req = urllib.request.Request(
PHOTOROOM_EDIT, data=body, headers={"x-api-key": key, "Content-Type": ctype}
)
try:
with urllib.request.urlopen(req, timeout=180) as r:
data = r.read()
if not data:
fatal("Photoroom returned 200 with an empty body")
if not r.headers.get("content-type", "").startswith("image/"):
fatal(f"Photoroom returned non-image: {r.headers.get('content-type')}")
return data
except urllib.error.HTTPError as e:
fatal(f"Photoroom HTTP {e.code}: {e.read().decode()[:300]}")
def upload_to_spree(product_id: str, blob: bytes, filename: str, position):
# position is always sent. Spree defaults a new asset to position 1, which
# silently makes it the product thumbnail — a watermarked sandbox image went
# live on the storefront that way. Callers pass the append position unless
# they explicitly want the front.
fields = {"type": "Spree::Image", "position": str(position)}
body, ctype = multipart(fields, "attachment", filename, blob, "image/png")
req = urllib.request.Request(
f"{SPREE_API}/products/{product_id}/media",
data=body,
headers={"X-Spree-Api-Key": SPREE_KEY, "Content-Type": ctype},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=180) as r:
return r.status, json.loads(r.read() or b"{}")
except urllib.error.HTTPError as e:
fatal(f"Spree HTTP {e.code}: {e.read().decode()[:400]}")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--slug", required=True)
ap.add_argument("--image", help="source file (default: products/<slug>/image-1.jpg)")
ap.add_argument("--live", action="store_true", help="use the paid key (10/month, no watermark)")
ap.add_argument("--position", type=int,
help="1 makes it the product thumbnail. Default: appended last.")
ap.add_argument("--size", default="1000x1000")
ap.add_argument("--bg", default="FFFFFF")
ap.add_argument("--dry-run", action="store_true")
args = ap.parse_args()
src = pathlib.Path(args.image) if args.image else SOURCE_ROOT / args.slug / "image-1.jpg"
if not src.exists():
fatal(f"source image not found: {src}")
product = find_product(args.slug)
if not product:
fatal(f"no Spree product with slug {args.slug}")
pid = product["id"]
name = product.get("name", "?")
before = spree_get(f"/products/{pid}/media").get("data", [])
print(f"product : {name} ({pid})")
print(f"source : {src} ({src.stat().st_size:,} bytes)")
print(f"media : {len(before)} existing")
print(f"key : {'LIVE (consumes paid quota)' if args.live else 'sandbox (watermarked)'}")
if args.dry_run:
print("\n--dry-run: nothing sent.")
return
key = load_key(args.live)
position = args.position if args.position is not None else len(before) + 1
print(f"\nprocessing... (will land at position {position}"
f"{' — THUMBNAIL' if position == 1 else ''})")
out = photoroom(src, key, args.size, args.bg)
print(f" photoroom ok: {len(out):,} bytes png")
status, resp = upload_to_spree(pid, out, f"{args.slug}-photoroom.png", position)
asset_id = resp.get("data", resp).get("id")
print(f" spree upload: HTTP {status} asset={asset_id} position={position}")
# Re-read rather than trusting the response. A 201 is not proof the asset is
# attached and serving.
after = spree_get(f"/products/{pid}/media").get("data", [])
ids = [m.get("id") for m in after]
if asset_id and asset_id in ids:
url = next((m.get("original_url") or m.get("url") for m in after if m.get("id") == asset_id), None)
print(f"\ncheck ok: media {len(before)} -> {len(after)}, asset present")
if url:
print(f"url: {url}")
else:
print(f"\nCHECK FAILED: asset {asset_id} not in product media after upload", file=sys.stderr)
print(f" media ids: {ids}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()