diff --git a/backend/app.py b/backend/app.py
index 2862f53..155246a 100644
--- a/backend/app.py
+++ b/backend/app.py
@@ -268,5 +268,8 @@ app.include_router(pricing_router)
from .review import router as review_router
app.include_router(review_router)
+from .publish import router as publish_router
+app.include_router(publish_router)
+
# expose for tests
__all__ = ["app", "__version__"]
diff --git a/backend/publish.py b/backend/publish.py
new file mode 100644
index 0000000..6232f49
--- /dev/null
+++ b/backend/publish.py
@@ -0,0 +1,231 @@
+"""Publish sequence (S6). Cites PUBLISH-5/10/15/20/25/30/31/36/37/38/55/60.
+
+Sequence (PUBLISH-5): validate ready set -> look up SKU (stop if exists) ->
+refresh taxonomy if last sync >1h -> resolve cats/tags/attrs -> upload processed
+PNGs -> POST /products -> finalize. SLAB-UAT-* always draft (PUBLISH-10).
+Failure: mark failed, retain photos, write sync_log, plain message (PUBLISH-30).
+Partial (media ok, product POST failed): orphan media left, logged (PUBLISH-31).
+"""
+
+import json
+import re
+from datetime import datetime, timedelta, timezone
+from typing import Any
+
+from fastapi import APIRouter
+from fastapi.responses import JSONResponse
+
+from .db import get_connection
+from .woo import WooAuthError, WooClient, WooConnectionError, WooCredentialsMissing, WooError
+from .review import compute_ready, _slab_dict
+
+router = APIRouter(prefix="/api/v1/slabs")
+
+
+def _error(code: str, message: str, detail: Any = None) -> dict:
+ return {"error": {"code": code, "message": message, "detail": detail}}
+
+
+def _now() -> str:
+ return datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None).isoformat()
+
+
+def _is_uat(sku: str) -> bool:
+ return sku.startswith("SLAB-UAT-")
+
+
+def _slugify(name: str, sku: str) -> str:
+ base = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
+ return f"{base}-{sku.lower()}"
+
+
+def _taxonomy_stale(conn) -> bool:
+ row = conn.execute("SELECT value FROM settings WHERE key='taxonomy_last_sync_at'").fetchone()
+ if not row or not row[0]:
+ return True
+ try:
+ last = datetime.fromisoformat(row[0])
+ except ValueError:
+ return True
+ if last.tzinfo is None:
+ last = last.replace(tzinfo=timezone.utc)
+ return datetime.now(timezone.utc) - last > timedelta(hours=1)
+
+
+def _build_payload(slab: dict, conn, status: str) -> dict:
+ payload: dict[str, Any] = {
+ "type": "simple",
+ "status": status,
+ "sku": slab["sku"],
+ "name": slab["title"],
+ "slug": _slugify(slab["title"], slab["sku"]),
+ "regular_price": f"{slab['price']:.2f}",
+ "description": slab["description"],
+ "short_description": slab["short_description"],
+ "virtual": False,
+ "downloadable": False,
+ "catalog_visibility": "visible",
+ "sold_individually": True,
+ }
+ wood_cats = slab.get("wood_category_ids") or []
+ payload["categories"] = [{"id": slab["species_id"]}]+[{"id": c} for c in wood_cats]
+ tags = [t for t in (slab.get("fig_tag_ids") or [])]+[t for t in (slab.get("feat_tag_ids") or [])]
+ if tags:
+ payload["tags"] = [{"id": t} for t in tags]
+ # Five locked attributes by synced term name.
+ terms = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM woo_attribute_terms").fetchall()}
+ attrs = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM woo_attributes").fetchall()}
+ built_attrs = []
+ for field, attr_name, pos in (
+ ("edge_type_term_id", "Edge Type", 0),
+ ("figure_term_ids", "Figure", 1),
+ ("grade_term_id", "Grade", 2),
+ ("thickness_term_id", "Thickness", 3),
+ ("moisture_term_id", "Moisture", 4),
+ ):
+ raw = slab.get(field)
+ term_ids = raw if isinstance(raw, list) else ([raw] if raw else [])
+ options = [terms[t]["name"] for t in term_ids if t in terms]
+ attr_id = _attr_id_by_name(attrs, attr_name)
+ if attr_id and options:
+ built_attrs.append({"id": attr_id, "position": pos, "visible": True, "variation": False, "options": options})
+ if built_attrs:
+ payload["attributes"] = built_attrs
+ return payload
+
+
+def _attr_id_by_name(attrs: dict, name: str) -> int | None:
+ for a in attrs.values():
+ if a.get("name", "").lower() == name.lower():
+ return a["id"]
+ return None
+
+
+def _classify(e: Exception) -> tuple[str, str, bool]:
+ """Return (code, plain message, recoverable)."""
+ if isinstance(e, WooCredentialsMissing):
+ return "woo_unconfigured", "Woo is not configured. Set the store credentials in Settings.", False
+ if isinstance(e, WooAuthError):
+ return "woo_auth", "The store rejected the credentials. Check the app password.", False
+ if isinstance(e, WooConnectionError):
+ return "woo_unreachable", "The store is unreachable right now. You can retry.", True
+ return "woo_failed", "The store create failed. You can retry.", True
+
+
+@router.post("/{slab_id}/publish")
+async def publish(slab_id: str) -> dict:
+ conn = get_connection()
+ row = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (slab_id,)).fetchone()
+ if row is None:
+ return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
+ slab = _slab_dict(row)
+ # 1. Ready set (PUBLISH-5)
+ ready, missing = compute_ready(slab)
+ if not ready:
+ return JSONResponse(status_code=422, content=_error("unpublishable", "Slab is not ready", {"missing": missing}))
+ # 2. SKU lookup (PUBLISH-15) — stop if it exists in the store.
+ base, user, enc = _woo_creds(conn)
+ try:
+ client = WooClient.from_settings(base, user, enc)
+ except WooCredentialsMissing as e:
+ return JSONResponse(status_code=422, content=_error("woo_unconfigured", str(e)))
+ try:
+ existing = _sku_exists(client, slab["sku"])
+ except WooError as e:
+ code, msg, rec = _classify(e)
+ _log(conn, slab_id, None, "failed", msg)
+ conn.execute("UPDATE slabs SET status='failed', updated_at=? WHERE id=?", (_now(), slab_id))
+ conn.commit()
+ return JSONResponse(status_code=502, content=_error(code, msg, {"recoverable": rec}))
+ if existing:
+ _log(conn, slab_id, None, "failed", "SKU already exists in the store")
+ return JSONResponse(status_code=409, content=_error("duplicate_sku", "SKU already exists", {"sku": slab["sku"]}))
+ # 3. Refresh taxonomy if stale (SETTINGS-60).
+ if _taxonomy_stale(conn):
+ try:
+ from . import taxonomy as taxonomy_mod
+
+ taxonomy_mod.sync(client, conn)
+ except WooError as e:
+ code, msg, rec = _classify(e)
+ return JSONResponse(status_code=502, content=_error(code, msg, {"recoverable": rec}))
+ # 4/5. Upload processed PNGs, then POST product.
+ status = "draft" if _is_uat(slab["sku"]) else _create_status(conn)
+ photos = conn.execute(
+ "SELECT id, processed_blob FROM slab_photos WHERE slab_id=? AND processed_blob IS NOT NULL ORDER BY seq",
+ (slab_id,),
+ ).fetchall()
+ uploaded_ids: list[int] = []
+ try:
+ for p in photos:
+ uploaded_ids.append(client.upload_media(p["processed_blob"], f"{slab['sku']}-{p['id']}.png"))
+ payload = _build_payload(slab, conn, status)
+ if uploaded_ids:
+ payload["images"] = [{"id": i} for i in uploaded_ids]
+ woo_product_id = _create_product(client, payload)
+ except WooError as e:
+ code, msg, rec = _classify(e)
+ partial = bool(uploaded_ids)
+ if partial:
+ msg = f"{msg} The uploaded images were left in the store — remove them manually."
+ _log(conn, slab_id, None, "failed", msg)
+ conn.execute("UPDATE slabs SET status='failed', updated_at=? WHERE id=?", (_now(), slab_id))
+ conn.commit()
+ return JSONResponse(status_code=502, content=_error(code, msg, {"recoverable": rec, "partial_media": partial}))
+ # 6. Finalize (PUBLISH-25): set woo_product_id, purge photos, discard local row.
+ now = _now()
+ conn.execute("UPDATE slabs SET status='published', woo_product_id=?, published_at=?, updated_at=? WHERE id=?",
+ (woo_product_id, now, now, slab_id))
+ conn.execute("UPDATE slab_photos SET original_blob=NULL, processed_blob=NULL WHERE slab_id=?", (slab_id,))
+ _log(conn, slab_id, woo_product_id, "success", "published")
+ conn.commit()
+ return {
+ "ok": True,
+ "status": "published",
+ "woo_product_id": woo_product_id,
+ "draft": _is_uat(slab["sku"]),
+ }
+
+
+def _woo_creds(conn) -> tuple[str, str | None, str | None]:
+ import os
+
+ base = os.environ.get("WOO_BASE_URL", "")
+ u = conn.execute("SELECT value FROM settings WHERE key='woo_wp_username'").fetchone()
+ p = conn.execute("SELECT value_enc FROM settings WHERE key='woo_app_password'").fetchone()
+ return base, (u[0] if u else None), (p[0] if p else None)
+
+
+def _create_status(conn) -> str:
+ row = conn.execute("SELECT value FROM settings WHERE key='woo_create_status'").fetchone()
+ return (row[0] if row and row[0] else "draft")
+
+
+def _sku_exists(client: WooClient, sku: str) -> bool:
+ code, _h, body = client._request(
+ f"{client.base_url}/wp-json/wc/v3/products?sku={sku}",
+ "GET", None,
+ {"Authorization": f"Basic {client._auth}", "Accept": "application/json"},
+ )
+ if code != 200:
+ raise WooError(f"sku lookup returned {code}")
+ return len(json.loads(body)) > 0
+
+
+def _create_product(client: WooClient, payload: dict) -> int:
+ body = json.dumps(payload).encode("utf-8")
+ code, _h, resp = client._request(
+ f"{client.base_url}/wp-json/wc/v3/products",
+ "POST", body,
+ {"Authorization": f"Basic {client._auth}", "Content-Type": "application/json", "Accept": "application/json"},
+ )
+ if code not in (200, 201):
+ raise WooError(f"product create returned {code}")
+ return int(json.loads(resp)["id"])
+
+
+def _log(conn, slab_id: str, woo_product_id: int | None, status: str, message: str) -> None:
+ conn.execute(
+ "INSERT INTO sync_log(slab_id, woo_product_id, status, payload_hash, message, created_at) VALUES (?,?,?,?,?,?)",
+ (slab_id, woo_product_id, status, None, message, _now()),
+ )
diff --git a/backend/tests/test_publish.py b/backend/tests/test_publish.py
new file mode 100644
index 0000000..c9b7459
--- /dev/null
+++ b/backend/tests/test_publish.py
@@ -0,0 +1,184 @@
+"""S6 publish sequence tests (PUBLISH-5/10/15/25/30/31). Woo transport is mocked."""
+
+import base64
+import json
+import os
+import shutil
+import tempfile
+
+import pytest
+from fastapi.testclient import TestClient
+
+import backend.db as db_mod
+
+
+def _full_slab(sku="SLAB-01", **over):
+ base = {
+ "id": "s1", "sku": sku, "length_in": 114.0, "thickness_in": 1.75, "sqft": 30.0,
+ "bdft": 18.4, "width_min_in": 18.0, "width_max_in": 24.3, "width_avg_in": 24.3,
+ "species_id": 1, "wood_category_ids": json.dumps([2]), "edge_type_term_id": 1,
+ "figure_term_ids": json.dumps([1]), "grade_term_id": 1, "thickness_term_id": 1,
+ "moisture_term_id": 1, "fig_tag_ids": json.dumps([]), "feat_tag_ids": json.dumps([]),
+ "price": 184.0, "price_source": "recommendation",
+ "title": "Figured Black Walnut Live Edge Slab", "short_description": "slab, 9ft 6in",
+ "description": "A fine slab.", "content_source": "manual", "status": "ready",
+ "client_rev": 1, "server_rev": 1, "deleted_at": None, "created_at": "t", "updated_at": "t",
+ "woo_product_id": None,
+ }
+ base.update(over)
+ return base
+
+
+@pytest.fixture
+def env(monkeypatch):
+ tmp = tempfile.mkdtemp(prefix="pub_")
+ os.environ["SLAB_DB_PATH"] = os.path.join(tmp, "p.db")
+ os.environ["SLAB_IMAGES_DIR"] = tmp
+ os.environ["WOO_BASE_URL"] = "https://store.example.com"
+ os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode()
+ db_mod._reset_for_tests()
+ from backend.crypto import encrypt
+
+ conn = db_mod.init_db()
+ # taxonomy
+ conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (1,'Black Walnut','black-walnut',NULL,100)")
+ conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (2,'Live edge wood','live-edge-wood',NULL,101)")
+ conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (8,'Edge Type','edge-type',200)")
+ conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (1,8,'Flat','flat',300)")
+ # creds
+ conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('woo_wp_username','admin',NULL)")
+ conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('woo_app_password',NULL,?)", (encrypt("secret"),))
+ # fresh sync stamp so we skip re-sync
+ from datetime import datetime, timezone
+
+ conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('taxonomy_last_sync_at',?,NULL)",
+ (datetime.now(timezone.utc).replace(tzinfo=None).isoformat(),))
+ conn.commit()
+ from backend.app import app
+
+ yield TestClient(app), conn
+ db_mod._reset_for_tests()
+ shutil.rmtree(tmp, ignore_errors=True)
+
+
+def _insert_slab(conn, s):
+ conn.execute(
+ "INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,"
+ "species_id,wood_category_ids,edge_type_term_id,figure_term_ids,grade_term_id,thickness_term_id,"
+ "moisture_term_id,fig_tag_ids,feat_tag_ids,price,price_source,title,short_description,description,"
+ "content_source,status,server_rev,client_rev,created_at,updated_at) VALUES "
+ "(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
+ (s["id"], s["sku"], s["length_in"], s["thickness_in"], s["sqft"], s["bdft"], s["width_min_in"],
+ s["width_max_in"], s["width_avg_in"], s["species_id"], s["wood_category_ids"], s["edge_type_term_id"],
+ s["figure_term_ids"], s["grade_term_id"], s["thickness_term_id"], s["moisture_term_id"],
+ s["fig_tag_ids"], s["feat_tag_ids"], s["price"], s["price_source"], s["title"], s["short_description"],
+ s["description"], s["content_source"], s["status"], 1, 1, "t", "t"),
+ )
+
+
+def _install_mock(monkeypatch, sku_exists=0, product_id=999, media_ids=(42,)):
+ from backend.woo import WooClient
+
+ state = {"media_n": 0}
+
+ def fake_request(url, method, data, headers):
+ if "/products?sku=" in url:
+ body = json.dumps([{"id": 1}] * sku_exists).encode()
+ return 200, {}, body
+ if url.endswith("/wp-json/wp/v2/media") and method == "POST":
+ mid = media_ids[min(state["media_n"], len(media_ids) - 1)]
+ state["media_n"] += 1
+ return 201, {}, json.dumps({"id": mid}).encode()
+ if url.endswith("/wp-json/wc/v3/products") and method == "POST":
+ return 201, {}, json.dumps({"id": product_id}).encode()
+ return 200, {}, b"[]"
+
+ orig_init = WooClient.__init__
+
+ def patched_init(self, *a, **k):
+ orig_init(self, *a, **k)
+ self._request = fake_request
+
+ monkeypatch.setattr(WooClient, "__init__", patched_init)
+
+
+def test_publish_success(env, monkeypatch):
+ client, conn = env
+ s = _full_slab()
+ _insert_slab(conn, s)
+ conn.execute("INSERT INTO slab_photos(id,slab_id,seq,is_calibration,processed_blob,created_at) VALUES ('p1','s1',1,1,X'89504E47','t')")
+ conn.commit()
+ _install_mock(monkeypatch, sku_exists=0, product_id=999, media_ids=(42,))
+ r = client.post("/api/v1/slabs/s1/publish")
+ assert r.status_code == 200, r.text
+ body = r.json()
+ assert body["ok"] is True and body["woo_product_id"] == 999 and body["draft"] is False
+ row = conn.execute("SELECT status, woo_product_id FROM slabs WHERE id='s1'").fetchone()
+ assert row["status"] == "published" and row["woo_product_id"] == 999
+ # photos purged
+ assert conn.execute("SELECT processed_blob FROM slab_photos WHERE id='p1'").fetchone()["processed_blob"] is None
+
+
+def test_uat_draft(env, monkeypatch):
+ client, conn = env
+ s = _full_slab(sku="SLAB-UAT-1")
+ _insert_slab(conn, s)
+ conn.commit()
+ _install_mock(monkeypatch)
+ r = client.post("/api/v1/slabs/s1/publish")
+ assert r.status_code == 200
+ assert r.json()["draft"] is True
+
+
+def test_not_ready_422(env, monkeypatch):
+ client, conn = env
+ s = _full_slab(price=None, price_source=None)
+ _insert_slab(conn, s)
+ conn.commit()
+ _install_mock(monkeypatch)
+ r = client.post("/api/v1/slabs/s1/publish")
+ assert r.status_code == 422
+ assert "price" in r.json()["error"]["detail"]["missing"]
+
+
+def test_duplicate_sku_409(env, monkeypatch):
+ client, conn = env
+ s = _full_slab(sku="SLAB-DUP")
+ _insert_slab(conn, s)
+ conn.commit()
+ _install_mock(monkeypatch, sku_exists=1)
+ r = client.post("/api/v1/slabs/s1/publish")
+ assert r.status_code == 409
+ assert r.json()["error"]["code"] == "duplicate_sku"
+
+
+def test_partial_media_failure_502(env, monkeypatch):
+ client, conn = env
+ s = _full_slab()
+ _insert_slab(conn, s)
+ conn.execute("INSERT INTO slab_photos(id,slab_id,seq,is_calibration,processed_blob,created_at) VALUES ('p1','s1',1,1,X'89504E47','t')")
+ conn.commit()
+ from backend.woo import WooClient, WooError
+
+ def fake_request(url, method, data, headers):
+ if "/products?sku=" in url:
+ return 200, {}, b"[]"
+ if url.endswith("/wp-json/wp/v2/media"):
+ return 201, {}, json.dumps({"id": 42}).encode()
+ if url.endswith("/wp-json/wc/v3/products"):
+ raise WooError("product create returned 500")
+ return 200, {}, b"[]"
+
+ orig_init = WooClient.__init__
+
+ def patched_init(self, *a, **k):
+ orig_init(self, *a, **k)
+ self._request = fake_request
+
+ monkeypatch.setattr(WooClient, "__init__", patched_init)
+ r = client.post("/api/v1/slabs/s1/publish")
+ assert r.status_code == 502
+ assert r.json()["error"]["detail"]["partial_media"] is True
+ # sync_log written, status failed
+ assert conn.execute("SELECT status FROM slabs WHERE id='s1'").fetchone()["status"] == "failed"
+ assert conn.execute("SELECT COUNT(*) c FROM sync_log WHERE status='failed'").fetchone()["c"] >= 1
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index 9d8ddad..b19ba3f 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -13,6 +13,7 @@ import Settings from './create/settings/Settings.svelte';
import SlabList from './create/slabs/SlabList.svelte';
import PhotoAttach from './create/photos/PhotoAttach.svelte';
import Review from './create/review/Review.svelte';
+import Publish from './create/publish/Publish.svelte';
const gate = typeof window !== 'undefined' ? supported() : 'window unavailable';
const session: CaptureSession = createCaptureSession();
@@ -24,6 +25,7 @@ import Review from './create/review/Review.svelte';
let showSlabs = false;
let showPhotos = false;
let showReview = false;
+ let showPublish = false;
const unsubCap = session.capture.subscribe((c) => {
cap = c;
@@ -101,6 +103,7 @@ import Review from './create/review/Review.svelte';
+
{#if showSettings}
+ Published{result.draft ? ' (UAT draft)' : ''} → Woo product #{result.woo_product_id} +
+ {:else} ++ {result.error?.message} + {#if result.error?.detail?.recoverable === false} + unrecoverable + {:else if result.error?.detail?.recoverable === true} + recoverable — retry + {/if} + {#if result.error?.detail?.partial_media} + orphan media — clean up manually + {/if} +
+ {/if} + {/if} +