commit d75f27a691bf47423638a266830de1dc9382c629 Author: type-two Date: Fri Jun 19 17:18:50 2026 +1000 feat: RecordGod founding — service scaffold + MariaDB→Postgres migration - FastAPI twin of DealGod (async SQLAlchemy/asyncpg). wowplatter/v1: ping, inventory/lookup, inventory/intake (enriches from discogs_full by release_id). - schema v2: unified inventory (vinyl+general), slim crate, sales/sale_items. Discogs catalog NOT copied — joined from discogs_full at read time. - migrate.py: 34,543 shop rows moved (vs ~3.2M in the WowPlatter clone). - Smoke test green; plan/readme carry the architecture + UI boundary. Co-Authored-By: Claude Opus 4.8 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..876107d --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +.env +.venv/ +venv/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..bf5d7a3 --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# RecordGod + +**The records back-office engine — the best of WowPlatter, reborn on Postgres, outside WordPress.** + +RecordGod ingests WowPlatter's *job* (inventory, import, enrichment, labels, copy, queries) +but not WowPlatter's *code*. It runs as its own service on the VPS, calls DealGod for market +value, and feeds WordPress the tidy data it likes for the storefront. + +WowPlatter is the **ancestral founder** — still running the live shop floor, and the reference +spec RecordGod matches before it retires anything. Honour the founder: don't redesign what +already works, copy it. + +## Start here +- **[RECORDGOD_PLAN.md](RECORDGOD_PLAN.md)** — the plan, the map, the keep/drop triage, build + order, and the open decisions. Read this first. + +## The family (sibling repos on this machine) +| Repo | What it is | Talks to RecordGod via | +|---|---|---| +| `../dealgod` | The **market** brain — cross-store value, arbitrage. Its own product. | RecordGod *calls* it (`X-API-Key`) for value | +| `../PRICEGOD` | The seller **extension** (Discogs cockpit). Successor to pliceclogs. | Calls RecordGod's `wowplatter/v1` contract | +| `../wowplatter` | The founder — WP/WooCommerce plugin running the live store. | RecordGod publishes tidy data to it | + +Briefs that define the contracts already live in `../PRICEGOD/DEALGOD_BRIEF.md` and +`../PRICEGOD/WOWPLATTER_BRIEF.md`. All three still hold; RecordGod is who fulfils the +WowPlatter side of them. + +## Run it (dev) +```bash +python3 -m venv .venv && ./.venv/bin/pip install -r requirements.txt +RECORDGOD_TOKEN=dev-tok ./.venv/bin/uvicorn app.main:app --reload --port 8010 +./.venv/bin/python test_smoke.py # self-check — no DB/infra needed +``` +Postgres schema: `psql recordgod < schema.sql`. + +Live now: `GET /wowplatter/v1/ping`, `GET /wowplatter/v1/inventory/lookup?release_id=`. +Routes 3–5 (price / labels / intake) are intentionally **404** — PRICEGOD reads that as +"pending" and degrades gracefully, so an undefined route is the correct "not built yet" signal. + +## Layout +``` +app/ + main.py FastAPI app + /healthz + db.py async SQLAlchemy over asyncpg (twin of DealGod's db.py) + auth.py require_token — Bearer; the one place the auth scheme lives + wowplatter_v1.py the contract router (ping + lookup live) +schema.sql minimal inventory table, release_id-keyed, store_id seam +test_smoke.py infra-free self-check +``` + +_Status: founding — scaffold runs, smoke test green. 2026-06-19._ diff --git a/RECORDGOD_PLAN.md b/RECORDGOD_PLAN.md new file mode 100644 index 0000000..76d819a --- /dev/null +++ b/RECORDGOD_PLAN.md @@ -0,0 +1,227 @@ +# RecordGod — The Plan + +_Founding document. The map all four codebases build to. 2026-06-19._ + +> **For the DealGod / PRICEGOD / WowPlatter Claudes reading this:** this is RecordGod's +> charter. It does not change your briefs — it names who fulfils the store-API side of them +> and how the back-office moves out of WordPress without breaking the live shop. + +--- + +## 0. TL;DR + +- **RecordGod** = the records back-office engine. Postgres-native, runs **outside** WordPress, + on the same VPS pattern as `api.dealgod.pro`. +- It **ingests WowPlatter's job, not its code.** ~5% of WowPlatter's surface is the prize; + the rest stays WP-native or dies with the founder. +- It serves the **`wowplatter/v1`** contract (the five routes from the WowPlatter brief). That + contract is the **seam** — PRICEGOD codes against it; whether PHP-in-WP or RecordGod answers + is an implementation detail that moves over time. +- It **calls DealGod** for market value. DealGod stays a separate product. RecordGod is its + customer, same as PRICEGOD. +- **Source of truth by concern:** RecordGod/local owns intake & catalog; WordPress/Woo owns + sale-state & checkout. Non-destructive UPSERT on `release_id` + SKU. Never overwrite a sold + or hand-edited row. + +--- + +## 1. The mandate + +**One line:** RecordGod is the records back-office engine — inventory truth, import, +enrichment, labels, copy, queries — Postgres-native, outside WordPress, calling DealGod for +market value and feeding tidy data out to the storefront. + +**Honour the founder.** WowPlatter keeps running the live floor and is the *reference spec*. +RecordGod matches what WowPlatter already does right — its schema, the exact label layout, the +sale-state rules — before it retires anything. Copying the founder's hard-won correctness is +both the respectful move and the lazy one: don't redesign what works. + +**`release_id` (Discogs release id) is the universal join key.** Lead with it everywhere. + +--- + +## 2. The cast & the map + +```mermaid +flowchart TB + PG["PRICEGOD
seller extension (Discogs cockpit)"] + DG[("DealGod
market brain · api.dealgod.pro")] + RG[["RecordGod
records engine · Postgres, outside WP"]] + MIR[("Local mirrors @ ultra
discogs_full · MusicBrainz · enwiki")] + WP["WowPlatter
WP plugin · live storefront"] + COM["WooCommerce · Square · Aus Post · Mail
checkout · sale-state"] + + PG -->|"X-API-Key · price-suggest, /api/me"| DG + PG -->|"wowplatter/v1 contract"| RG + RG -->|"asks market value"| DG + RG -->|"enrich: instant, unlimited"| MIR + RG -->|"publish: UPSERT on release_id+SKU"| WP + WP --> COM + COM -.->|"sale-state is authoritative"| RG +``` + +- **PRICEGOD** — the brains on top. Talks to exactly **two** backends (DealGod + the store + API). Treats any `404` as "pending" and degrades gracefully, so routes can ship one at a time. +- **DealGod** — the market brain. Untouched except one add (`GET /api/me`). Its own product. +- **RecordGod** — the muscle. Serves `wowplatter/v1`, enriches from the local mirrors, calls + DealGod for value, publishes tidy data to WordPress. +- **WowPlatter** — the founder. Live storefront, 3D store, and the commerce stack (Woo, Square, + Aus Post rates, transactional mail). Keeps these forever. +- **Local mirrors @ ultra** — full Discogs + MusicBrainz + enwiki dumps in Postgres. Already + proven (the apple-id WP-CLI command `pg_connect`s to `discogs_full` on `127.0.0.1:5432`). + +--- + +## 3. The contract seam — `wowplatter/v1` + +The five routes from `../PRICEGOD/WOWPLATTER_BRIEF.md` are RecordGod's front door: + +| Route | Purpose | First home | Final home | +|---|---|---|---| +| `GET /ping` | connection test + store identity chip | PHP-in-WP | RecordGod | +| `GET /inventory/lookup?release_id=` | "do WE have this?" (the daily driver) | PHP-in-WP | RecordGod | +| `POST /inventory/price` | accept-target write → push Woo + Square | **stays WP** (commerce) | **stays WP** | +| `GET /labels/templates` | so PRICEGOD prints OUR label design | PHP-in-WP | RecordGod | +| `POST /inventory/intake` | new-stock intake → local staging | PHP-in-WP | RecordGod | + +**The reframe that matters:** the WowPlatter brief describes this as a PHP facade *inside* +WordPress. That is correct — as **step one of a strangler, not the permanent home.** The +*contract* is the seam; the *implementation* starts in PHP over today's MariaDB (fast, unblocks +PRICEGOD now) and migrates into RecordGod behind the frozen contract, route by route. PRICEGOD +never notices. + +The one route that **never leaves WordPress** is `inventory/price`'s write side — pushing the +accepted price to WooCommerce + Square is commerce, and commerce is WP-native. RecordGod stages +the price; the thin WP plugin does the push. + +--- + +## 4. The triage — ingest the value, let the bloat die with the founder + +WowPlatter is ~7,700 first-party PHP files. "Best of WowPlatter" is a fraction of it. + +### Ingest into RecordGod (the prize) +- `inventory-db` / change-logger / items-handler — inventory truth +- `import-*` (artist / label / master / release / style) + batch-processor — **rebuilt on the + local mirrors**, not live `api.discogs.com`. The worst MariaDB pain and the biggest win. +- `discogs-adapter` / `musicbrainz` / `wikidata` enrichment — repointed at `discogs_full` / MB + / wiki on ultra +- `label-designer` — templates as JSON first, designer UI later +- reports / queries — the stuff MariaDB-in-WP chokes on + +### Stays WordPress / Woo-native (never moves — the money & shopfront) +- checkout, orders, Square sync, Aus Post rates, the storefront + 3D store, transactional mail + *sending* + +### Let it die with the founder (do NOT carry across) +- ai-chatbot, matrix-appservice, beeper-bridge, gig-guide, ticketmaster, facebook-catalog / + ebay channels (maybe later), css/js optimizers, image-scanner, alt-text, the PHP + backup/cron/retry plumbing — RecordGod gets its own Python/Postgres-native infra, not a port. + +> Every line you *don't* ingest is the win. This triage is the difference between a 3-week +> build and a 9-month one. + +--- + +## 5. The "+more" — only what Postgres-outside-WP actually unlocks + +Not a feature parade. The handful that are *hard in MariaDB/WP and easy in RecordGod*: + +1. **Import enrichment from local mirrors** — instant / unlimited vs rate-limited Discogs API. +2. **pgvector copy** — newsletter blurbs, "records like this", semantic dedupe of the dirty + catalog. The LLM writes; pgvector retrieves. Default to Claude when wired. *(Add pgvector + only when keyword/trigram search measurably falls short — not day one.)* +3. **Queries / reporting** the WP admin can't do at speed. +4. **DealGod arbitrage native** — buy-signals on your *own* shelves, not just suggested prices. +5. **Staging → publish pipeline** with the non-destructive UPSERT (§6). + +--- + +## 6. Source of truth — the 3am gotcha, already solved + +Split by **concern**, not by table (the WowPlatter brief got this right — lock it in): + +- **Live (WordPress/Woo) is authoritative for sale-state.** Never overwrite sold / ordered / + hand-edited rows. +- **Local (RecordGod) is authoritative for intake & catalog.** +- **Publish = non-destructive UPSERT keyed on `release_id` + SKU.** Pull live's current + SKUs/sold-status into local *first*, then publish. +- Keep the Google-Sheet import as a fallback until the local pipeline earns trust. + +This is the one decision that's expensive to get wrong. Everything else is reversible. + +--- + +## 7. Build order (the lazy sequence) + +1. **Unblock PRICEGOD this week.** Ship `wowplatter/v1` routes `ping` + `lookup` + `price` as a + thin PHP facade over today's MariaDB. A few hundred lines over data that already exists. + Not throwaway — it's the contract. +2. **Add DealGod's `GET /api/me`** (two lines off `require_key`) — the only thing blocking + PRICEGOD's green light. +3. **Stand up RecordGod** (Python + Postgres + nginx, same pattern as `api.dealgod.pro`). + First migrate **import-on-local-mirrors** — that's the pain you actually named. +4. **Migrate routes' guts** into RecordGod behind the frozen contract, then flip the base URL + (or per-route proxy) once enough have moved. PRICEGOD codes against the contract, not the + location. +5. Then labels (JSON templates → port the pliceclogs layout), then intake/staging, then the + pgvector copy + reporting wins. + +**Migrate the schema, don't redesign it.** Day one: dump the relevant MariaDB tables → load +into Postgres as-is, *then* improve indexes / add pgvector. Reshaping before moving is how you +strand the live store. + +--- + +## 8. Open decisions + +- **Auth scheme.** DealGod uses `X-API-Key` (UUID); the WowPlatter brief says `Authorization: + Bearer` (WP app-password / api-tokens). Two schemes is fine **if deliberate** (one for the + market brain, one for the store) — but PRICEGOD has to hold both. Confirm that's intended, or + unify, before the facade ships. An auth scheme baked into a shipped extension is annoying to + change later. + +--- + +## 9. Stack + +Reuse what's already operated daily — a new framework is a tax paid forever. + +- **Python + Postgres**, fronted by **nginx**, on the **same VPS**, same shape as + `api.dealgod.pro`. No WordPress process involved. +- Enrichment reads the **local mirrors on ultra** over Tailscale (`discogs_full` / MB / wiki). +- pgvector when retrieval/similarity is the real need, not before. + +--- + +## 10. UI integration boundary — shared shell, separate engine + +The web UI is **shared** with DealGod; the engine and data are **not**. RecordGod renders as an +**enterprise-gated section** of the DealGod web app, but its pages are served by RecordGod and +call RecordGod's API. Share the glass, not the muscle. + +- RecordGod serves its own UI from its own service/repo, styled with DealGod's shared theme. +- DealGod's shell mounts it via **three things only**: + 1. **nginx path route** — `app.dealgod.pro/store/*` → RecordGod; everything else → DealGod. + 2. **shared auth** — one login session; RecordGod trusts DealGod's auth and reads the plan; + `plan=enterprise` lights the `/store` nav entry. + 3. **shared theme** — same `_theme.html` / `themeify`, so it looks like one site. +- **No code overlap.** The two repos meet at nginx + a cookie + a CSS file + the API contract. +- **Dependency direction:** DealGod knows RecordGod's mount URL and gates on plan; RecordGod + trusts DealGod's auth. RecordGod never imports DealGod internals. + +**Why this matters for staffing:** the DealGod-side IA reorg (role-based nav: `admin` / +`shopper: basic, pro` / `store: enterprise → RecordGod`) and the RecordGod build run **in +parallel** against this seam without colliding — different repos, one small contract. The +DealGod-repo change is just the mount point (one nav link + the nginx route + trust-the-cookie), +owned by whoever holds DealGod. + +--- + +## 11. Pointers + +- `../PRICEGOD/DEALGOD_BRIEF.md` — DealGod's one add (`/api/me`) + two optional niceties. +- `../PRICEGOD/WOWPLATTER_BRIEF.md` — the five-route contract + the local-clone pipeline. +- `../PRICEGOD/src/lib/wowplatter.js` — the exact shapes the extension already calls. +- `../dealgod` `media_routes.py:1043` — the live `POST /api/price-suggest` RecordGod will call. +- `../wowplatter` — the founder; reference spec for schema, labels, sale-state rules. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..3dc1f76 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..cc4c45f --- /dev/null +++ b/app/auth.py @@ -0,0 +1,22 @@ +import os +from fastapi import Header, HTTPException + +# Single-tenant v1: one store, one token from the environment. +STORE = os.getenv("RECORDGOD_STORE", "Monster Robot Party") +PLAN = os.getenv("RECORDGOD_PLAN", "enterprise") +TOKEN = os.getenv("RECORDGOD_TOKEN") # no default — unset means "deny everything", not "open" + + +async def require_token(authorization: str | None = Header(None)): + # ponytail: Bearer per WOWPLATTER_BRIEF. The auth scheme is still an OPEN decision + # (DealGod's X-API-Key vs this Bearer) — keep the resolver in this one function so + # flipping it, or swapping the env token for a tokens table / shared DealGod session, + # is a single-file change. + if not TOKEN: + raise HTTPException(503, "RECORDGOD_TOKEN not configured") + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException(401, "missing bearer token") + if authorization[7:].strip() != TOKEN: + raise HTTPException(401, "invalid token") + # ponytail: store_id is always 1 for now — the seam for multi-shop, not the room. + return {"store": STORE, "plan": PLAN, "store_id": 1} diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..3604952 --- /dev/null +++ b/app/db.py @@ -0,0 +1,22 @@ +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +import os + +# Same shape as DealGod's db.py — async SQLAlchemy over asyncpg. RecordGod owns its OWN db. +DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://localhost/recordgod") + +engine = create_async_engine( + DATABASE_URL, pool_size=5, max_overflow=5, + pool_timeout=30, pool_recycle=900, pool_pre_ping=True) +SessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +async def get_db(): + async with SessionLocal() as session: + yield session + + +# Read-only enrichment mirror — the full Discogs dump (same local Postgres, different db). +# We DON'T copy the catalog into RecordGod; intake joins it on release_id at write time. +MIRROR_URL = os.getenv("DISCOGS_MIRROR_URL", "postgresql+asyncpg://localhost/discogs_full") +mirror_engine = create_async_engine(MIRROR_URL, pool_size=2, max_overflow=2, pool_pre_ping=True) +MirrorSession = async_sessionmaker(mirror_engine, class_=AsyncSession, expire_on_commit=False) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..75e41e0 --- /dev/null +++ b/app/main.py @@ -0,0 +1,12 @@ +from fastapi import FastAPI + +from . import __version__ +from .wowplatter_v1 import router as wowplatter_v1_router + +app = FastAPI(title="RECORDGOD", version=__version__) +app.include_router(wowplatter_v1_router) + + +@app.get("/healthz") +async def healthz(): + return {"ok": True, "service": "recordgod", "version": __version__} diff --git a/app/wowplatter_v1.py b/app/wowplatter_v1.py new file mode 100644 index 0000000..0864fc2 --- /dev/null +++ b/app/wowplatter_v1.py @@ -0,0 +1,106 @@ +import base64 +import secrets + +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel +from sqlalchemy import text + +from . import __version__ +from .auth import require_token +from .db import get_db, MirrorSession + +# The wowplatter/v1 contract — RecordGod's front door (see RECORDGOD_PLAN.md §3). +# Routes 1, 2, 5 live here. Routes 3-4 (price/labels) are deliberately NOT defined yet: +# PRICEGOD treats 404 as "pending" and degrades gracefully. ponytail: 404 already says it. +router = APIRouter(prefix="/wowplatter/v1", tags=["wowplatter/v1"]) + + +@router.get("/ping") +async def ping(ident=Depends(require_token)): + """Connection test + identity — lights PRICEGOD's green dot and store chip.""" + return {"ok": True, "store": ident["store"], "plan": ident["plan"], "version": __version__} + + +@router.get("/inventory/lookup") +async def inventory_lookup( + release_id: int | None = Query(None), + barcode: str | None = Query(None), + ident=Depends(require_token), + db=Depends(get_db), +): + """"Do WE have this?" — the most-used call. release_id first, identifier (barcode) fallback.""" + if release_id is None and barcode is None: + return {"ok": False, "reason": "need release_id or barcode"} + + where, params = ( + ("release_id = :rid", {"rid": release_id}) + if release_id is not None + else ("identifier = :bc", {"bc": barcode}) + ) + params["sid"] = ident["store_id"] + + row = (await db.execute(text( + f"SELECT sku, price, qty, condition, status, product_url, release_id " + f"FROM inventory WHERE {where} AND store_id = :sid AND in_stock LIMIT 1" + ), params)).mappings().first() + + if not row: + return {"ok": True, "in_stock": False, "release_id": release_id} + return { + "ok": True, "in_stock": True, **dict(row), + "price": float(row["price"]) if row["price"] is not None else None, + } + + +class IntakeIn(BaseModel): + release_id: int | None = None + identifier: str | None = None + condition: str | None = None + sleeve: str | None = None + notes: str | None = None + price: float | None = None + sku: str | None = None + kind: str = "vinyl" + + +def _new_sku() -> str: + # ponytail: MRP- + 8 base32 chars ≈ 1e12 space — collisions negligible at shop scale; the + # sku PK + ON CONFLICT upsert means a clash just updates that row. Tighten if it ever bites. + return "MRP-" + base64.b32encode(secrets.token_bytes(5)).decode().rstrip("=")[:8] + + +@router.post("/inventory/intake") +async def inventory_intake(body: IntakeIn, ident=Depends(require_token), db=Depends(get_db)): + """New-stock intake. Enriches from the discogs_full mirror by release_id, stages locally + (staged=true). Publishing to live is a separate, non-destructive step (PLAN §6).""" + title = artist = None + weight = None + if body.release_id: + try: # ponytail: mirror down → stage un-enriched rather than fail the intake + async with MirrorSession() as mirror: + m = (await mirror.execute(text( + "SELECT title, artists_sort, estimated_weight FROM release WHERE id = :r" + ), {"r": body.release_id})).mappings().first() + if m: + title, artist, weight = m["title"], m["artists_sort"], m["estimated_weight"] + except Exception: + pass + + sku = body.sku or _new_sku() + await db.execute(text(""" + INSERT INTO inventory (sku, store_id, kind, release_id, identifier, title, price, + condition, sleeve_cond, weight_g, notes, staged, status) + VALUES (:sku, :sid, :kind, :rid, :ident, :title, :price, + :cond, :sleeve, :wt, :notes, true, 'staged') + ON CONFLICT (sku) DO UPDATE SET + release_id = EXCLUDED.release_id, title = EXCLUDED.title, price = EXCLUDED.price, + condition = EXCLUDED.condition, sleeve_cond = EXCLUDED.sleeve_cond, + notes = EXCLUDED.notes, updated_at = now() + """), { + "sku": sku, "sid": ident["store_id"], "kind": body.kind, "rid": body.release_id, + "ident": body.identifier, "title": title, "price": body.price, + "cond": body.condition, "sleeve": body.sleeve, "wt": weight, "notes": body.notes, + }) + await db.commit() + return {"ok": True, "sku": sku, "staged": True, + "title": title, "artist": artist, "enriched": title is not None} diff --git a/migrate.py b/migrate.py new file mode 100644 index 0000000..021fa3a --- /dev/null +++ b/migrate.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""One-shot MariaDB (WowPlatter) -> Postgres (RecordGod) migration. + +Migrates the shop's OWN data only — inventory (two tables unified into one), sales history, +and a slim crate table. The ~3.2M-row Discogs CATALOG is deliberately NOT copied: it lives in +the discogs_full mirror and is joined on release_id at read time. See RECORDGOD_PLAN.md. + + python migrate.py # idempotent: create schema, upsert (ON CONFLICT DO NOTHING) + python migrate.py --truncate # wipe the 4 tables first, then reload +""" +import json +import os +import pathlib +import re +import sys +from datetime import datetime, timezone + +import pymysql +import psycopg +from psycopg.types.json import Jsonb + +WP_CONFIG = os.getenv("WP_CONFIG", "/opt/homebrew/var/www/monsterrobot.localsite/wp-config.php") +PG_DSN = os.getenv("RECORDGOD_DSN", "postgresql://localhost/recordgod") +SOCK = os.getenv("MARIA_SOCK", "/opt/homebrew/var/mysql/mysql.sock") +HERE = pathlib.Path(__file__).parent + + +def wp_creds(): + t = pathlib.Path(WP_CONFIG).read_text() + g = lambda k: re.search(rf"'{k}',\s*'([^']*)'", t).group(1) + return g("DB_NAME"), g("DB_USER"), g("DB_PASSWORD") + + +def yn(v): + return v == "y" + + +def js(v): + if not v: + return None + try: + return json.loads(v) + except Exception: + return None # ponytail: skip a malformed json blob rather than abort the whole migration + + +def jb(v): + return Jsonb(v) if v is not None else None + + +def insert_many(pg, table, cols, dicts): + rows = [tuple(d.get(c) for c in cols) for d in dicts] + if not rows: + return 0 + ph = ",".join(["%s"] * len(cols)) + sql = f"INSERT INTO {table} ({','.join(cols)}) VALUES ({ph}) ON CONFLICT DO NOTHING" + with pg.cursor() as cur: + cur.executemany(sql, rows) + return len(rows) + + +def main(): + truncate = "--truncate" in sys.argv + now = datetime.now(timezone.utc) + name, user, pw = wp_creds() + my = pymysql.connect(unix_socket=SOCK, user=user, password=pw, database=name, + cursorclass=pymysql.cursors.DictCursor) + pg = psycopg.connect(PG_DSN, autocommit=False) + pg.execute(open(HERE / "schema.sql").read()) + pg.commit() + if truncate: + pg.execute("TRUNCATE inventory, sale_items, sales, crate") + + c = my.cursor() + + # --- inventory: disc_inventory (vinyl) ----------------------------------------------------- + c.execute("SELECT * FROM wp_rmp_disc_inventory") + vinyl = [dict( + sku=r["sku"], store_id=1, kind="vinyl", release_id=r["release_id"], + price=r["price"], qty=1, in_stock=yn(r["instock"]), + condition=r["media_condition"], sleeve_cond=r["sleeve_condition"], + location=r["location"], crate_id=r["crate_id"], slot_number=r["slot_number"], + status="publish" if yn(r["instock"]) else "sold", notes=r["comment"], + square_item_id=r["square_item_id"], square_catalog_object_id=r["square_catalog_object_id"], + square_sku=r["square_sku"], square_synced_at=r["square_synced_at"], + staged=False, sold_date=r["sold_date"], created_at=now, updated_at=now, + ) for r in c.fetchall()] + + # --- inventory: disc_general_inventory (books/mags/etc.) ----------------------------------- + c.execute("SELECT * FROM wp_rmp_disc_general_inventory") + general = [] + for g in c.fetchall(): + attrs = js(g["attributes"]) or {} + dims = js(g["dimensions"]) + if dims: + attrs["dimensions"] = dims + general.append(dict( + sku=g["sku"], store_id=1, kind=(g["type"] or g["category"] or "other").lower(), + identifier=g["identifier"] or g["upc"], title=g["title"], price=g["price"], + qty=g["quantity"] or 1, in_stock=yn(g["instock"]), condition=g["condition_desc"], + brand=g["brand"], model=g["model"], weight_g=g["weight"], location=g["location"], + crate_id=g["crate_id"], slot_number=g["slot_number"], + status="publish" if yn(g["instock"]) else "sold", + square_item_id=g["square_item_id"], square_catalog_object_id=g["square_catalog_object_id"], + square_sku=g["square_sku"], square_synced_at=g["square_synced_at"], + est_market_value=g["est_market_value"], lowest_competitor=g["lowest_competitor"], + target_price=g["target_price"], attributes=jb(attrs or None), + images=jb(js(g["images"])), raw_json=jb(js(g["raw_json"])), staged=False, + created_at=g["created_at"] or now, updated_at=g["updated_at"] or g["created_at"] or now, + )) + + inv_cols = ["sku", "store_id", "kind", "release_id", "identifier", "title", "price", "qty", + "in_stock", "condition", "sleeve_cond", "brand", "model", "weight_g", "location", + "crate_id", "slot_number", "status", "notes", "est_market_value", + "lowest_competitor", "target_price", "square_item_id", "square_catalog_object_id", + "square_sku", "square_synced_at", "staged", "attributes", "images", "raw_json", + "sold_date", "created_at", "updated_at"] + + # --- crate (slim) -------------------------------------------------------------------------- + c.execute("SELECT id, name, label_text, crate_purpose, notes FROM wp_rmp_disc_virtual_crate") + crates = [dict(id=v["id"], name=v["name"], label_text=v["label_text"], + purpose=v["crate_purpose"], notes=v["notes"]) for v in c.fetchall()] + + # --- sales + sale_items (reporting copy; WP owns live sale-state) --------------------------- + c.execute("SELECT * FROM wp_rmp_disc_sales") + sales = [dict(id=s["id"], sale_number=s["sale_number"], customer_id=s["customer_id"], + total=s["total_amount"] or s["total"], tax_amount=s["tax_amount"], + status=s["payment_status"] or s["status"], payment_method=s["payment_method"], + sale_date=s["sale_date"], created_at=s["created_at"]) for s in c.fetchall()] + c.execute("SELECT * FROM wp_rmp_disc_sales_items") + items = [dict(id=i["id"], sale_id=i["sale_id"], sku=(i["sku"] or None), + release_id=i["release_id"], item_name=i["item_name"], qty=i["quantity"], + unit_price=i["unit_price"], line_total=i["line_total"]) for i in c.fetchall()] + # drop orphan line-items that point at a sale that no longer exists (real junk in the source) + sale_ids = {s["id"] for s in sales} + n_orphan = sum(1 for i in items if i["sale_id"] not in sale_ids) + items = [i for i in items if i["sale_id"] in sale_ids] + + # order matters: crate + sales before things that could reference them + n = {} + n["crate"] = insert_many(pg, "crate", ["id", "name", "label_text", "purpose", "notes"], crates) + n["inventory(vinyl)"] = insert_many(pg, "inventory", inv_cols, vinyl) + n["inventory(general)"] = insert_many(pg, "inventory", inv_cols, general) + n["sales"] = insert_many(pg, "sales", + ["id", "sale_number", "customer_id", "total", "tax_amount", "status", + "payment_method", "sale_date", "created_at"], sales) + n["sale_items"] = insert_many(pg, "sale_items", + ["id", "sale_id", "sku", "release_id", "item_name", "qty", + "unit_price", "line_total"], items) + pg.commit() + for k, v in n.items(): + print(f" {k:22} {v:>6}") + if n_orphan: + print(f" (skipped {n_orphan} orphan sale_items pointing at deleted sales)") + print("done.") + + +if __name__ == "__main__": + main() diff --git a/requirements-migrate.txt b/requirements-migrate.txt new file mode 100644 index 0000000..fde3cd0 --- /dev/null +++ b/requirements-migrate.txt @@ -0,0 +1,3 @@ +# Migration-only deps (not needed by the running service). Run: pip install -r requirements-migrate.txt +pymysql +psycopg[binary] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7d2f8a9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +fastapi +uvicorn[standard] +sqlalchemy[asyncio] +asyncpg +httpx diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..0181364 --- /dev/null +++ b/schema.sql @@ -0,0 +1,90 @@ +-- RecordGod schema v2. +-- ONE inventory table (vinyl + general goods unified), sales history, slim crates. +-- The Discogs CATALOG is deliberately NOT stored here — it lives in the discogs_full Postgres +-- mirror and is joined on release_id at read time. We keep the shop's OWN data only: +-- WowPlatter crammed a ~3.2M-row Discogs clone into the shop DB; RecordGod keeps ~40k real rows. + +CREATE TABLE IF NOT EXISTS inventory ( + sku text PRIMARY KEY, + store_id int NOT NULL DEFAULT 1, -- always 1 for now; the multi-shop seam + kind text NOT NULL DEFAULT 'vinyl', -- vinyl|book|magazine|comic|card|game|other + release_id bigint, -- Discogs id (vinyl) → join discogs_full.release + identifier text, -- ISBN/UPC/barcode (general goods lookup key) + title text, -- denormalised cache; vinyl filled from mirror at intake + price numeric(10,2), + qty int NOT NULL DEFAULT 1, + in_stock boolean NOT NULL DEFAULT true, -- was enum('y','n') + condition text, -- media grade (vinyl) / condition desc (general) + sleeve_cond text, -- vinyl sleeve grade + brand text, + model text, + weight_g numeric(10,3), + location text, + crate_id int, + slot_number int, + status text DEFAULT 'publish', + notes text, + product_url text, + -- DealGod price intel (the engine writes these) + est_market_value numeric(10,2), + lowest_competitor numeric(10,2), + target_price numeric(10,2), + priced_at timestamptz, + -- Square sync (Square/WP authoritative) + square_item_id text, + square_catalog_object_id text, + square_sku text, + square_synced_at timestamptz, + -- intake / staging (live owns sale-state; local owns intake — see RECORDGOD_PLAN.md §6) + staged boolean NOT NULL DEFAULT false, -- true = intaken locally, not yet published live + attributes jsonb, -- per-kind extras (dimensions, etc.) — was longtext+CHECK + images jsonb, + raw_json jsonb, -- enrichment cache + sold_date timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS inventory_release_idx ON inventory (store_id, release_id) WHERE release_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS inventory_identifier_idx ON inventory (store_id, identifier) WHERE identifier IS NOT NULL; +CREATE INDEX IF NOT EXISTS inventory_instock_idx ON inventory (store_id, in_stock) WHERE in_stock; +CREATE INDEX IF NOT EXISTS inventory_crate_idx ON inventory (crate_id, slot_number); +CREATE INDEX IF NOT EXISTS inventory_staged_idx ON inventory (staged) WHERE staged; + +CREATE TABLE IF NOT EXISTS crate ( + id int PRIMARY KEY, + name text NOT NULL, + label_text text, + purpose text DEFAULT 'stock', -- stock|spacer|display|returns|hold|sale + notes text + -- 3D geometry (pos/rotation/mesh/genre-lists) intentionally stays in WowPlatter — front-end concern. +); + +CREATE TABLE IF NOT EXISTS sales ( + id bigint PRIMARY KEY, + sale_number text UNIQUE, + customer_id bigint, + total numeric(10,2) NOT NULL DEFAULT 0, -- consolidated (WP had total + total_amount) + tax_amount numeric(10,2) NOT NULL DEFAULT 0, + status text DEFAULT 'pending', -- consolidated (WP had status + payment_status) + payment_method text, + sale_date timestamptz, + created_at timestamptz + -- WooCommerce/WP stays authoritative for live sale-state; this is the reporting/analytics copy. +); +CREATE INDEX IF NOT EXISTS sales_date_idx ON sales (sale_date); + +CREATE TABLE IF NOT EXISTS sale_items ( + id bigint PRIMARY KEY, + sale_id bigint REFERENCES sales(id) ON DELETE CASCADE, + sku text, + release_id bigint, + item_name text, + qty int NOT NULL DEFAULT 1, + unit_price numeric(10,2) NOT NULL DEFAULT 0, + line_total numeric(10,2) NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS sale_items_sale_idx ON sale_items (sale_id); +CREATE INDEX IF NOT EXISTS sale_items_release_idx ON sale_items (release_id); + +-- pgvector (semantic search / "records like this" / newsletter copy): add when keyword search +-- measurably falls short, not before. diff --git a/test_smoke.py b/test_smoke.py new file mode 100644 index 0000000..50b2b52 --- /dev/null +++ b/test_smoke.py @@ -0,0 +1,73 @@ +import os +os.environ["RECORDGOD_TOKEN"] = "test-tok" + +from fastapi.testclient import TestClient +from app.main import app +from app.db import get_db + +AUTH = {"Authorization": "Bearer test-tok"} + + +class _FakeResult: + def __init__(self, row): self._row = row + def mappings(self): return self + def first(self): return self._row + + +def _override(row): + async def _dep(): + class _DB: + async def execute(self, *a, **k): return _FakeResult(row) + async def commit(self): pass + yield _DB() + return _dep + + +def test_ping_ok(): + r = TestClient(app).get("/wowplatter/v1/ping", headers=AUTH) + assert r.status_code == 200 + b = r.json() + assert b["ok"] and b["store"] and b["plan"] and b["version"] + + +def test_auth_required(): + c = TestClient(app) + assert c.get("/wowplatter/v1/ping").status_code == 401 + assert c.get("/wowplatter/v1/ping", headers={"Authorization": "Bearer wrong"}).status_code == 401 + + +def test_lookup_in_stock(): + app.dependency_overrides[get_db] = _override({ + "sku": "MRP-AB12CD", "price": 39.95, "qty": 1, "condition": "VG+", + "status": "publish", "product_url": "https://store/x", "release_id": 12345}) + r = TestClient(app).get("/wowplatter/v1/inventory/lookup?release_id=12345", headers=AUTH) + app.dependency_overrides.clear() + assert r.status_code == 200 + b = r.json() + assert b["in_stock"] is True and b["sku"] == "MRP-AB12CD" and b["price"] == 39.95 + + +def test_lookup_not_stocked(): + app.dependency_overrides[get_db] = _override(None) + r = TestClient(app).get("/wowplatter/v1/inventory/lookup?release_id=999", headers=AUTH) + app.dependency_overrides.clear() + assert r.json() == {"ok": True, "in_stock": False, "release_id": 999} + + +def test_intake_stages_without_mirror(): + # no release_id → no mirror call; just generate a sku and stage. + app.dependency_overrides[get_db] = _override(None) + r = TestClient(app).post("/wowplatter/v1/inventory/intake", + headers=AUTH, json={"kind": "vinyl", "price": 25.0, "condition": "VG+"}) + app.dependency_overrides.clear() + b = r.json() + assert b["ok"] and b["staged"] is True and b["sku"].startswith("MRP-") + + +if __name__ == "__main__": + test_ping_ok() + test_auth_required() + test_lookup_in_stock() + test_lookup_not_stocked() + test_intake_stages_without_mirror() + print("ok — ping, auth, lookup(in/out), intake all pass")