diff --git a/WOWPLATTER_MIGRATION_AUDIT.md b/WOWPLATTER_MIGRATION_AUDIT.md new file mode 100644 index 0000000..77eeabd --- /dev/null +++ b/WOWPLATTER_MIGRATION_AUDIT.md @@ -0,0 +1,192 @@ +# WowPlatter β†’ RecordGod β€” feature migration audit + +Cycle through every WowPlatter wp-admin page, assess **cream vs bloat**, and spec what to migrate. +Legend: **🟒 CREAM** (migrate) Β· **πŸ”΄ BLOAT** (drop) Β· **🟑 UNSURE** (regroup with John) Β· **βœ… DONE** (already in RecordGod). +Menu = 2 roots (WowPlatter / SOUND) + 28 submenu pages. Source: `admin/partials/*`, `admin/class-*-ajax-handler.php`. + +--- + +## STOCK group + +### 1. Dashboard β€” `dashboard.php` Β· βœ… DONE (parity to extend) +Purpose: at-a-glance β€” Top Performers (last 30 days), stock value, counts, quick actions. +RecordGod: `/admin` dashboard already has counts/stock-value/sales/quick-actions. **Migrate the "Top Performers (30d)" ++ best-sellers widget** onto it (from `sale_items` we already have). Verdict: 🟒 (small add to the existing dash). + +### 2. Search β€” `search.php` Β· βœ… DONE / in progress +Scanner / Returns / Reorganize / Collections / Stock Finder + Storeβ†’Rackβ†’Crateβ†’Item navigator. +RecordGod: navigator (storeβ†’rackβ†’crateβ†’locate), Collections (CRUD + editor + fallback locator), Reorganize **backend** +(`/nav/reorg/scan,apply,compress`) all built. **Pending UIs: Scanner (scan-to-slot), Returns, Reorganize walkthrough.** +Verdict: 🟒 core β€” finish the 3 pending UIs. + +### 3. Inventory β€” `inventory/{main,list,form,bulk,meta-catalog,discogs-marketplace,ebay-marketplace}.php` Β· 🟒 CREAM (vital, not yet built) +The actual stock manager. **list.php** = Current Stock table (search, edit, delete, bulk actions). **form.php** = add/edit +one item. **bulk.php** = 4 tabs: IMPORT / DISCOGS / SYNC / BULK ACTIONS (import missing release data+images, push to Woo). +ajax: `add_inventory_item`, `delete_inventory_item`, `sync_inventory_to_woo`, `create_woo_product_from_inventory`, +`import_missing_data`, `inventory_changes_log`. RecordGod has the `inventory` TABLE + intake API but **no list/edit/bulk UI**. +β†’ **Migrate: the inventory list (search/filter/edit/delete/bulk) + single-item add/edit form.** Skip eBay-marketplace tab +(πŸ”΄, not used). Woo-sync stays (commerce). Verdict: 🟒 β€” THE core back-office screen still missing. + +### 4. Import β€” `import/{main,other,reference-conversion}.php` Β· 🟒 CREAM +Bulk-get-stock-in: paste release IDs / catnos / barcodes β†’ match to Discogs β†’ create inventory. **reference-conversion** = +turn catalog numbers / identifiers into release_ids (`convert_references`, `convert_discogs_references`). ajax: +`execute_discogs_import`, `execute_discogs_selected_import`, `check_import_progress`, `cancel_import`, `convert_*_references`. +RecordGod: has intake API + the disc_release mirror (so matching is local now). β†’ **Migrate: the bulk import UI +(paste list β†’ resolve against disc_release/identifier β†’ review β†’ create inventory).** Verdict: 🟒. + +### 5. Print β€” `print.php` + `label-designer.php` Β· 🟒 CREAM +Tabs: Preview / Settings / Label Designer. Thermal/price-label printing with a template designer. ajax: +`ajax_{save,get,delete}_label_template`, `get_label_templates_by_type`. RecordGod: PriceGod does label printing via the +rfid-daemon; RecordGod itself has no label UI. β†’ **Migrate: label templates + print (artist/title/price/QR/SKU), reuse the +PriceGod/daemon print path.** Designer is nice-to-have. Verdict: 🟒 (templates + print core; 🟑 the full visual designer). + +### 6. Reports β€” `reports/audio.php` (+ sales/stock reports) Β· 🟑 UNSURE (depth) +The Reports menu surfaces sales/stock/audio analytics. RecordGod has a basic `/admin/reports` (revenue, by-month, top items). +β†’ **Question for John: how deep do reports need to go?** Basic sales+stock reports = 🟒; the audio/Beatport/MusicBrainz +report tabs lean enrichment-niche. Verdict: 🟑 β€” confirm which report tabs are cream. + +### 7. Analyse β€” `analyse.php` Β· 🟑 UNSURE +"Analyse Inventory" β€” analysis + data-cleaning tools (find dupes, missing data, anomalies). Could be genuinely useful +(stock hygiene) or overlap with Import's "missing data". β†’ **Question: is this a distinct tool you use, or covered by +Import + Inventory bulk?** Verdict: 🟑. + +### 8. Wiki β€” `wiki.php` Β· 🟑 UNSURE +"Wiki Lookup & Editor" β€” per-release wiki/notes lookup + editing (wiki_extract enrichment). Niche enrichment; DealGod/ +discogs already carry notes. β†’ **Question: do you actually use the wiki editor, or is the release `notes`/`description` +field enough?** Verdict: 🟑 (lean πŸ”΄). + +### 9. Other β€” (misc tools) Β· 🟑 UNSURE +Catch-all "Other" menu β€” need to confirm what's parked here (likely odds-and-ends tools). Verdict: 🟑 β€” confirm contents. + +### 10. Virtual Store β€” `virtual/*` Β· βœ… DONE +3D store ported to RecordGod (raw Three.js, coordinate convention fixed, 3 rooms render). Editing tools (rack/crate +placement) still WP-side β€” 🟑 do we need an in-RecordGod scene editor, or keep placing via the navigator? Verdict: βœ… core done. + +### 11. Gig Guide β€” `gig-guide.php` Β· βœ… DONE (lives in DealGod) +GigGod is already in DealGod (`live_gig` + `/api/gigs/structured` + `/touring`); WowPlatter/RecordGod consume via API. +Verdict: βœ… β€” no migration, consume the DealGod API. + +--- + +## SALES group + +### 12. Sales β€” `sales/*` Β· βœ… DONE +POS (ring-up/discounts/payment-plans/locate/past-sales/promotions/import/settings) built in RecordGod `/pos`. +Pending: Email-receipt + Square tabs. Verdict: βœ… core done. + +### 13. Customers β€” `customers.php` Β· 🟒 CREAM +Full CRM: tabs Overview / Sales / Loyalty / Wantlist / Mailing / Messages / Info per customer. RecordGod has a thin +`customer` table + POS picker only. β†’ **Migrate: the customer record page (history, wantlist, contact, mailing prefs).** +Verdict: 🟒 (Loyalty/Messages tabs depend on those features below). + +### 14. Wantlist β€” `wantlist.php` Β· 🟑 UNSURE (lean 🟒) +Customer wantlists β€” "who wants what" β†’ buy signals + notify-on-arrival. Genuinely useful for a record store (match +incoming stock to wants). β†’ **Question: priority? It pairs with Customers + intake.** Verdict: πŸŸ‘β†’πŸŸ’. + +### 15. Inbox β€” (customer messages) Β· 🟑 UNSURE +Customer message inbox (ties to Messaging/Beeper). β†’ **Question: do you run customer messaging through here, or external +(email/socials)?** Verdict: 🟑. + +### 16. Loyalty β€” `loyalty-{users,reports,settings}.php` Β· 🟑 UNSURE +Points/tiers/rewards program (`loyalty_ledger`, `loyalty_tiers`, `loyalty_settings`). Real feature but is a loyalty program +cream for the shop right now? β†’ **Question: are you running loyalty, or is it aspirational?** Verdict: 🟑. + +--- + +## FRONTEND group + +### 17. Profile Editor β€” `profile-editor.php` Β· 🟑 UNSURE +Customer-facing profile editing (frontend account). Storefront concern; RecordGod's storefront is nascent. Verdict: 🟑. + +### 18. Pages β€” `pages.php` Β· 🟒 CREAM (partly done) +Storefront page builder: tabs General / Sales / Header / Welcome / FAQ / SEO / URL-structure / Placeholder. RecordGod has +the `/builder` (theme + brand import). β†’ **Migrate the content bits (welcome/FAQ/SEO/header copy) into the builder.** +Verdict: 🟒 (fold into the existing builder). + +### 19. Audio β€” `audio.php` Β· 🟑 UNSURE (split) +Tabs: MusicBrainz / Streaming & Harvester / Beatport BPM-Key / Reports. = audio enrichment + track previews + Apple Music +harvester. **Streaming/preview audio on the storefront = potentially 🟒 cream** (sell records by letting people hear them); +**the MusicBrainz/Beatport enrichment = DealGod/discogs territory (🟑).** β†’ **Question: do you want audio previews on the +RecordGod storefront?** Verdict: 🟑 β€” split preview (cream) from enrichment (DealGod). + +### 20. Audio Reports β€” `reports/audio.php` Β· πŸ”΄ BLOAT (lean) +Reporting on the audio-harvest process. Internal tooling for the enrichment pipeline β€” not a shop feature. Verdict: πŸ”΄/🟑. + +### 21. Messaging β€” `messaging-settings.php` + `channels/` Β· 🟑 UNSURE +Customer messaging config + channels (Beeper bridge = WhatsApp/etc). Powerful but heavy. β†’ **Question: channel messaging = +keep or external?** Verdict: 🟑 (lean πŸ”΄ for v1). + +--- + +## INTEGRATIONS & SYSTEM + +### 22. Shipping β€” `shipping/{main,rates,labels}.php` Β· 🟒 CREAM (if you ship) +AusPost rates + label creation (`auspost_create_labels`), zones/flat-rates. Fulfillment for mail-order. β†’ **Migrate if +RecordGod handles fulfillment; else Woo handles it.** Verdict: πŸŸ‘β†’πŸŸ’ β€” confirm RecordGod owns shipping vs Woo. + +### 23. AI β€” `ai-tabs/{main,agent,chatbot,connect,prompts}.php` Β· πŸ”΄ BLOAT (defer) +AI chatbot/agent + prompt config. Side quest, not the seller loop (PriceGod plan already cut AI). Verdict: πŸ”΄ defer. + +### 24. Beatport β€” `beatport-match.php` Β· 🟑 UNSURE +BPM/Key matcher (Search / Work Queue) via browser extension. DJ-store enrichment β€” useful for YOUR dance catalog, niche +generally. Overlaps DealGod enrichment. β†’ **Question: BPM/key valuable enough to carry?** Verdict: 🟑. + +### 25. Blagginate β€” `blagginate.php` Β· πŸ”΄ BLOAT +YouTube-cookie tracklist/metadata processor. PriceGod plan explicitly cut it. Verdict: πŸ”΄ drop. + +### 26. Ads β€” `ads-admin-display.php` + `ads/` Β· πŸ”΄ BLOAT (lean) +Advertising management. Niche, not core to stock/sell. β†’ **Question: is this used?** Verdict: πŸ”΄/🟑. + +### 27. Connect β€” `connect/{main,auspost,dealgod,discogs,ebay,facebook,google,listenbrainz,mail,recaptcha,serp,square}.php` Β· 🟒 CREAM +THE integration hub β€” store all third-party credentials + test connections. RecordGod already has `/settings/secrets` +(Fernet vault) + connection tests for **discogs/woo/dealgod**. β†’ **Migrate the rest: square, mail(SMTP), auspost, +facebook, google** as needed. eBay/listenbrainz/serp/recaptcha = 🟑 (only if used). Verdict: 🟒 β€” extend the vault/settings. + +### 28. System β€” `system.php` + `tools/{debug,logging,shortcodes,wpcli}.php` + `settings/{endpoints,general,performance}.php` Β· 🟑 UNSURE +Admin plumbing: debug, logs, shortcodes, WP-CLI, endpoint registry, perf. Most is WP-specific (πŸ”΄ β€” RecordGod has its own +infra). **General settings (currency/tax/store info) = 🟒** (some already in POS settings). Verdict: 🟑 β€” cherry-pick +general settings; drop WP plumbing. + +--- + +## SUMMARY + +**🟒 CREAM β€” migrate (priority order):** +1. **Inventory list/edit/bulk** (the missing core stock screen) +2. **Import** (bulk stock-in, now matchable against the local disc_release mirror) +3. **Print / labels** (price labels + templates) +4. **Customers** CRM record page +5. **Connect** β€” extend the vault with square/mail/auspost/etc. +6. **Pages** content β†’ fold into `/builder` +7. **Dashboard** Top-Performers widget +8. Finish **Search** (Scanner/Returns/Reorganize UIs), **Sales** (email/Square) + +**πŸ”΄ BLOAT β€” drop:** Blagginate, AI chatbot, Ads (likely), Audio Reports, WP system/tools plumbing. + +**βœ… DONE:** Search (mostly), Sales, Virtual Store, Gig Guide (DealGod). + +**RESOLVED β€” regroup with John (2026-06-22):** +- **Reports / Analyse** β†’ 🟒 FULL reports + a standalone **Analyse** tool (dupes/missing-data/anomalies). +- **Wantlist** β†’ 🟒 migrate **with intake-match + notify-on-arrival** (match incoming stock to customer wants). +- **Loyalty** β†’ 🟒 migrate (data + UI) **but keep INACTIVE** β€” "needs a lot of work", don't activate the program yet. +- **Wiki** β†’ πŸ”΄ DROP β€” superseded by local enwiki/Discogs/MusicBrainz lookups (built before the local data centre existed). +- **Audio** β†’ 🟒 migrate, but **INTERNALIZE**: replace API-key enrichment with local **MusicBrainz** (full MB on Ultra) + + enwiki + Discogs lookups; KEEP the **Apple Music ID** feature (saves id as JSON to **preserve country**) + storefront + **previews** + **Beatport**. ⭐ **Needs its own careful page-by-page review** β€” what each piece does, what's API vs + internalisable. (ajax surface: batch_musicbrainz_*, discogs_to_musicbrainz_lookup, force_process_apple_id, apple_repair_*, + bp_search_beatport, batch_harvest_*, audio_player.) +- **Beatport** BPM/Key β†’ 🟒 migrate (valuable for the dance catalog). +- **Messaging / Inbox** β†’ πŸ”΄ DROP in-app (Beeper/channels) β€” keep customer messaging external (email/socials). +- **Profile Editor** β†’ 🟑 LATER β€” storefront not far enough along. +- **Shipping** β†’ 🟒 migrate **own AusPost rates (table-based, NO API, no paid WP plugin)** β€” rates imported from the AusPost + guide PDF. ⚠️ **Rates +5% on 2026-07-01** (~9 days). Need a low-effort **rate-update path** (download new PDF β†’ Gemini + extracts the tables β†’ load), run ~yearly. +- **Ads** β†’ 🟒 migrate β€” **post-ads-to-Meta API** + **Discogs marketplace management**. Not used yet but wanted in record-store software. +- **"Other"** menu (`wowplatter-other` / `display_other_page`) β†’ confirm contents during execution (catch-all). +- **Virtual scene editor** β†’ 🟒 BUILD the full **3D rack/crate placement editor inside RecordGod**. + +**FINAL EXECUTION ORDER (cream):** 1) Inventory list/edit/bulk Β· 2) Import Β· 3) Print/labels Β· 4) Customers CRM Β· +5) Connect (extend vault) + Shipping (AusPost rates, before Jul 1) Β· 6) Wantlist Β· 7) Reports+Analyse Β· 8) finish Search +(Scanner/Returns/Reorganize) + Sales (email/Square) Β· 9) Audio internalization review+build Β· 10) Beatport Β· 11) Ads +(Meta + Discogs marketplace) Β· 12) Loyalty (inactive) Β· 13) 3D scene editor Β· 14) Pagesβ†’builder Β· 15) Dashboard widget. +DROP: Wiki, Messaging/Inbox, Blagginate, AI chatbot, Audio-Reports, WP system plumbing. LATER: Profile Editor. diff --git a/app/admin_routes.py b/app/admin_routes.py index eb3b20f..9e9d71f 100644 --- a/app/admin_routes.py +++ b/app/admin_routes.py @@ -1,5 +1,8 @@ +import secrets + import httpx -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel from sqlalchemy import text from . import vault @@ -63,6 +66,109 @@ async def inventory(q: str = Query(""), kind: str = Query(""), crate_id: int | N return {"ok": True, "items": items, "page": page, "per": per, "total": total} +# ── Inventory CRUD (the WowPlatter Inventory screen, RecordGod-side) ─────────────────────────── +class InvItemIn(BaseModel): + sku: str | None = None + kind: str = "vinyl" + release_id: int | None = None + identifier: str | None = None + title: str | None = None + price: float | None = None + qty: int = 1 + condition: str | None = None + sleeve_cond: str | None = None + brand: str | None = None + crate_id: int | None = None + slot_number: int | None = None + notes: str | None = None + in_stock: bool = True + + +class InvEditIn(BaseModel): + price: float | None = None + condition: str | None = None + sleeve_cond: str | None = None + title: str | None = None + crate_id: int | None = None + slot_number: int | None = None + notes: str | None = None + in_stock: bool | None = None + target_price: float | None = None + kind: str | None = None + + +class BulkIn(BaseModel): + skus: list[str] + action: str # set_price | adjust_pct | set_crate | mark_sold | restock | delete + value: float | int | str | None = None + + +@router.post("/inventory/add") +async def inv_add(body: InvItemIn, ident=Depends(require_token), db=Depends(get_db)): + sku = (body.sku or "").strip() or ("RG" + secrets.token_hex(4).upper()) + title = body.title + if body.release_id and not title: # pull the cached title if intaking by release + r = (await db.execute(text("SELECT title FROM disc_cache WHERE release_id=:r"), + {"r": body.release_id})).first() + title = r[0] if r else None + await db.execute(text(""" + INSERT INTO inventory (sku, store_id, kind, release_id, identifier, title, price, qty, in_stock, + condition, sleeve_cond, brand, crate_id, slot_number, notes, status, + created_at, updated_at) + VALUES (:sku, :s, :kind, :rid, :ident, :title, :price, :qty, :in_stock, :cond, :sleeve, :brand, + :crate, :slot, :notes, :status, now(), now()) + ON CONFLICT (sku) DO NOTHING + """), {"sku": sku, "s": ident["store_id"], "kind": body.kind, "rid": body.release_id, + "ident": body.identifier, "title": title, "price": body.price, "qty": body.qty, + "in_stock": body.in_stock, "cond": body.condition, "sleeve": body.sleeve_cond, + "brand": body.brand, "crate": body.crate_id, "slot": body.slot_number, "notes": body.notes, + "status": "publish" if body.in_stock else "sold"}) + await db.commit() + return {"ok": True, "sku": sku} + + +@router.post("/inventory/{sku}/edit") +async def inv_edit(sku: str, body: InvEditIn, ident=Depends(require_token), db=Depends(get_db)): + fields = {k: v for k, v in body.model_dump().items() if v is not None} + if not fields: + return {"ok": True, "unchanged": True} + sets = ", ".join(f"{k} = :{k}" for k in fields) + ", updated_at = now()" + r = await db.execute(text(f"UPDATE inventory SET {sets} WHERE sku = :sku AND store_id = :s"), + {**fields, "sku": sku, "s": ident["store_id"]}) + await db.commit() + if not r.rowcount: + raise HTTPException(404, "sku not found") + return {"ok": True, "updated": r.rowcount} + + +@router.post("/inventory/{sku}/delete") +async def inv_delete(sku: str, ident=Depends(require_token), db=Depends(get_db)): + r = await db.execute(text("DELETE FROM inventory WHERE sku = :sku AND store_id = :s"), + {"sku": sku, "s": ident["store_id"]}) + await db.commit() + return {"ok": True, "deleted": r.rowcount} + + +@router.post("/inventory/bulk") +async def inv_bulk(body: BulkIn, ident=Depends(require_token), db=Depends(get_db)): + if not body.skus: + raise HTTPException(400, "no skus") + p = {"skus": body.skus, "s": ident["store_id"], "v": body.value} + sql = { + "set_price": "UPDATE inventory SET price=:v, updated_at=now() WHERE sku=ANY(:skus) AND store_id=:s", + "adjust_pct": "UPDATE inventory SET price=round(price*(1+:v/100.0),2), updated_at=now() WHERE sku=ANY(:skus) AND store_id=:s", + "set_crate": "UPDATE inventory SET crate_id=:v, updated_at=now() WHERE sku=ANY(:skus) AND store_id=:s", + "mark_sold": "UPDATE inventory SET in_stock=false, status='sold', sold_date=now(), updated_at=now() WHERE sku=ANY(:skus) AND store_id=:s", + "restock": "UPDATE inventory SET in_stock=true, status='publish', sold_date=NULL, updated_at=now() WHERE sku=ANY(:skus) AND store_id=:s", + "delete": "DELETE FROM inventory WHERE sku=ANY(:skus) AND store_id=:s", + }.get(body.action) + if not sql: + raise HTTPException(400, f"unknown action {body.action}") + r = await db.execute(text(sql), p) + await db.commit() + return {"ok": True, "affected": r.rowcount} + + @router.get("/crates") async def crates(ident=Depends(require_token), db=Depends(get_db)): rows = await db.execute(text(""" diff --git a/disc_images_backfill.py b/disc_images_backfill.py new file mode 100644 index 0000000..be940b1 --- /dev/null +++ b/disc_images_backfill.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Copy WowPlatter's already-downloaded release-cover WebPs into RecordGod's hashed image tree. + +WowPlatter downloaded 60k+ covers to wp-content/uploads/wowplatter/release//release-.webp +(all WebP, ~150px, tiny). RecordGod serves /img/r/ from DISC_IMAGE_DIR//.webp +where h = sha256(release_id) β€” so we stage them under that scheme, then rsync to the VPS image dir. + + python disc_images_backfill.py # build /tmp/rg_disc_images + rsync -a /tmp/rg_disc_images/ root@:/opt/recordgod/disc_images/ + +Server-side i.discogs fetch is Cloudflare-403'd, so these local copies are how RecordGod self-hosts. +""" +import hashlib +import pathlib +import re +import shutil +import sys + +import pymysql + +WP = "/opt/homebrew/var/www/monsterrobot.localsite" +UPLOADS = WP + "/wp-content/uploads/" +STAGE = pathlib.Path("/tmp/rg_disc_images") + + +def creds(): + t = pathlib.Path(WP + "/wp-config.php").read_text() + g = lambda k: re.search(rf"'{k}',\s*'([^']*)'", t).group(1) + return g("DB_NAME"), g("DB_USER"), g("DB_PASSWORD") + + +def main(): + name, user, pw = creds() + c = pymysql.connect(unix_socket="/opt/homebrew/var/mysql/mysql.sock", user=user, password=pw, + database=name).cursor() + # one primary cover per release (fall back to any image if no primary) + c.execute("""SELECT release_id, local_url FROM wp_rmp_disc_release_image + WHERE local_url IS NOT NULL ORDER BY release_id, (type='primary') DESC""") + seen, copied, missing = set(), 0, 0 + for release_id, local_url in c.fetchall(): + if release_id in seen: + continue + seen.add(release_id) + m = re.search(r"/wp-content/uploads/(.+)$", local_url) + if not m: + continue + src = pathlib.Path(UPLOADS + m.group(1)) + if not src.exists(): + missing += 1 + continue + h = hashlib.sha256(str(release_id).encode()).hexdigest() + dst = STAGE / h[:2] / f"{h}.webp" + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(src, dst) + copied += 1 + if copied % 5000 == 0: + print(f" {copied}…", file=sys.stderr) + print(f"staged {copied} covers to {STAGE} ({missing} files missing on disk)", file=sys.stderr) + + +if __name__ == "__main__": + main()