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
+346
View File
@@ -0,0 +1,346 @@
# Pak Jabit Store — Project Manifest
## 🏗️ Architecture
Restructured 2026-09-15: backend and frontend now live on separate VMs, both
under jux-lab.com. Vercel is no longer in the path.
```
shop.jux-lab.com api.jux-lab.com + admin.jux-lab.com
(celest-proto, 34.21.217.116) (pak-jabit-backend, 35.247.170.3)
├── Next.js 16.3.5 storefront ├── Caddy (SSL)
│ systemd: pak-jabit-store └── Docker Compose
│ :3200 behind Caddy ├── Spree 5.6.1 (Rails 8.1.3)
├── Tailwind 4 ├── PostgreSQL 18
└── calls api.jux-lab.com └── Mailpit
```
Also on celest-proto (unrelated): jux-lab.com landing, mmo.jux-lab.com,
vibein.jux-lab.com, purge relay.
## 🔑 Credentials & Connections
| What | Value |
|------|-------|
| **Backend VM** | `pak-jabit-backend` · asia-southeast1-a · e2-medium · 35.247.170.3 |
| **Frontend VM** | `celest-proto` · asia-southeast1-a · e2-custom-4-8192 · 34.21.217.116 |
| **SSH** | `ssh -i /root/.ssh/google_compute_engine ijat@<ip>` |
| **Storefront** | https://shop.jux-lab.com |
| **Spree Admin** | https://admin.jux-lab.com (bare host redirects to the panel) |
| **Admin Login** | juxtapo.opus@gmail.com / ayambasuhkaki |
| **Storefront API** | https://api.jux-lab.com/api/v3/store/ |
| **Admin API** | https://api.jux-lab.com/api/v3/admin/ |
| **Publishable key** | pk_RVVf1fTUW1T4nW9FTFACm92D (header: X-Spree-Api-Key) |
| **Secret key (admin)** | sk_ntDFxFE1KwiDwqGzpCBGTJ8N — all scopes |
| **API Media** | `?expand=media` on product endpoint returns full gallery |
| **Store Currency** | **ZAR** (switched 2026-09-15). Supported: ZAR, USD, MYR — one `spree_markets` row per currency; the `spree_stores.default_currency` COLUMN is ignored, `Spree::Stores::Markets` overrides the reader |
| **Channel (Online Store)** | ch_UkLWZg9DAJ |
| **GitHub Repo** | https://github.com/juxtapo9090/pak-jabit-store (private) |
| **GCloud Project** | project-19f6479b-f9db-4588-92a |
| **DNS** | Cloud DNS zone `jux-lab-com`, same project |
| **Firewall Rule** | `allow-spree` (tcp:80,443) — applies to all instances. :3000 closed 2026-09-15; Caddy reaches Spree on 127.0.0.1:3000 |
## 🐳 Docker on the backend VM
```bash
ssh -i /root/.ssh/google_compute_engine ijat@35.247.170.3
cd ~/pak-jabit
docker compose up -d # start
docker compose down # stop
docker logs pak-jabit-web-1 # spree logs
docker exec pak-jabit-web-1 bin/rails runner "..." # rails console (~30s boot)
```
⚠️ `docker compose restart` does NOT re-read `.env`. Use `docker compose up -d`
after any env change.
## 🖥️ Storefront on celest-proto
```bash
ssh ijat@34.21.217.116
cd ~/pak-jabit-store
npm run build && sudo systemctl restart pak-jabit-store
sudo journalctl -u pak-jabit-store -f
```
⚠️ Delete `.env.local` before building — Next.js gives it precedence over
`.env.production`, and the Vercel-era copy points at the old backend.
## 📦 Products
- **160 products** live (30 original + 130 imported 2026-09-15)
- Scraped from Zalora (source prices are **MYR**), 500+ images in ActiveStorage
- Priced from source at the live rate 2026-09-15: ZAR R99.26-R3185.09 · USD $6.11-$196.02 · MYR RM24.90-RM799.00
- Imported via the Spree Admin API v3 (`scraped-catalogue/bulk_import.py`)
- Brands: G-Shock, H&M, Lacoste, Tommy Hilfiger, Cotton On, GAP, Giordano,
Superdry, Under Armour, Calvin Klein, Camel Active, Nike, Urban Revivo,
Trendyol, Swiss Polo, and more
### 🔴 Five gates a product must pass to appear on the storefront
Learned the hard way twice — 130 products invisible on 2026-09-15 morning, and
again that evening when Image Studio created products that looked perfect and
showed nothing. The scope is `Spree::Product.available(Time.current, currency)`,
defined in `spree_core-5.6.1/app/models/concerns/spree/product_scopes.rb:315`.
Read it rather than guessing; every gate below is a clause in it.
1. **`status: 'active'`** and not discontinued.
2. **A price row in the store currency.** `POST /products` silently ignores a
scalar `"price"`. It needs `"prices": [{"amount": N, "currency": "ZAR"}]`.
The clause is `with_currency(currency)`, which joins
`variants_including_master: :prices` — no row in that exact currency, no
product.
3. **A `Spree::ProductPublication` row for the channel.** Visibility is
**per-channel**, not per-product: `for_channel` INNER JOINs
`spree_product_publications`, so a product with no publication row is dropped
whatever its status. Channels here are `1 Online Store` and `2 Wholesale`.
Creating a product in Rails does **not** create this row.
4. **Purchasable.** Either stock on hand, or `track_inventory: false` on the
master **plus a stock item per active location**. `track_inventory: false`
alone is not enough — with zero stock items `in_stock?` is false,
`purchasable?` is false, and the product is excluded.
5. **Timestamps at or before `cutoff.beginning_of_minute`.** The scope truncates
the cutoff DOWN to the minute, so `published_at = Time.current` reads as the
*future* and the product stays hidden until the next minute ticks over. Set
`Time.current.beginning_of_minute` or it looks broken for up to 59 seconds
and then mysteriously fixes itself.
`Spree::Admin::ImageStudioController#build_new_product` does all five and then
re-checks `available?` before reporting success — copy that shape.
## 🎨 Design Tokens (THANDO)
| Token | Value |
|-------|-------|
| ink | #0E0D0B |
| ochre | #C8873A |
| bone | #EDE6DA |
| bone-light | #F5F1EB |
| ochre-dark | #A86E2D |
| Font | Instrument Sans (Google Fonts) |
| Default theme | Dark |
## ☁️ NextCloudPi (Team File Sharing)
| What | Value |
|------|-------|
| **URL** | https://cloudmi/ |
| **WebDAV** | https://cloudmi/remote.php/dav/files/juxtapo/ |
| **User** | juxtapo |
| **App Password** | Bk7qS-Pe5zk-7gWoY-kpc6E-KWYky |
| **Box** | C-fu's mirah (192.168.1.22) |
## 🚚 EasyParcel (MY Shipping)
| What | Value |
|------|-------|
| **Developer Hub** | https://developer.easyparcel.com |
| **App Name** | Pak Jabit Store |
| **Client ID** | 5be15621-f711-485b-bc79-15256747ce65 |
| **Client Secret** | 9a1e611e-c4e2-4d1a-87c8-f695c1323e63 |
| **Account** | Muhammad Aizat (#10935549) |
| **Auth** | OAuth 2.0 |
| **API Docs** | https://developer.easyparcel.com (Open API Documentation) |
| **Sandbox** | Available (connect from app config page) |
| **Redirect URI** | `https://shop.jux-lab.com/api/easyparcel/callback` — must match the developer hub exactly |
| **Token store** | `~/pak-jabit-store/.easyparcel-token.json` (chmod 600) |
| **Sender address** | `STORE_POSTCODE=10150` / `STORE_STATE=MY-07` (Penang) in `.env.production` |
| **Coverage** | All 16 MY states verified live — 20 couriers peninsular, 14-15 Sabah/Sarawak |
| **Re-auth** | Open the callback URL in a browser; EasyParcel asks for login, then writes the token |
### EasyParcel token handling (fixed 2026-09-15)
The access token lives 10 hours and **cannot** be extended — that is EasyParcel's
server-side policy, and it does not need extending: `getToken()` renews it with the
refresh token automatically, and writes the rotated refresh token back.
🔴 **The original code kept the token in a module-level variable**, so every restart,
redeploy or serverless cold start dropped it and the next quote died with "run OAuth
flow first". On Vercel it would have looked random. Now persisted to disk — verified
by restarting the service mid-session and getting 20 live quotes straight after.
🔴 **`req.nextUrl.origin` is wrong behind Caddy** — it resolved to `localhost:3200`
and produced a redirect URI EasyParcel would reject. Use `APP_BASE_URL` instead.
## ⚠️ Known Issues / TODO
- [x] ~~Currency USD → ZAR~~ done 2026-09-15, all 160 products in ZAR/USD/MYR
- [ ] Payment gateway not wired (Payfast — see napkin-payment-courier.md)
- [ ] Courier: EasyParcel is in for MY; Bob Go still needed for SA
- [ ] No product variants (size/colour) — every product is master-only
- [ ] `track_inventory: false` on all products — fine for demo, not for real stock
- [ ] Admin panel has no lockout or rate limiting — Devise `lockable` is off and
there is no rack-attack, so login attempts are unlimited. Worth an IP
allowlist or basic auth on the admin.jux-lab.com Caddy block before this
goes anywhere real
- [ ] Old Spree containers still on celest-proto (stopped, volumes intact) —
delete once the new backend has proven itself for a few days
- [ ] No backups on the backend VM — daily snapshots exist for celest-proto only
## 🗺️ Planned — customer data & image pipeline
### Customer account data (UI only — the data layer already exists)
Spree ships this; we just haven't surfaced it in the storefront.
| Need | Where it already lives |
|---|---|
| Saved addresses | `spree_addresses` · `GET/POST/PATCH/DELETE /api/v3/store/customers/me/addresses` |
| Saved cards | `spree_credit_cards` · `GET /api/v3/store/customers/me/credit_cards` |
🔴 **Never store raw card data.** `spree_credit_cards` holds `last_digits`,
`cc_type`, `month`/`year`, and `gateway_customer_id` / `gateway_payment_profile_id`
— a **gateway token**, not the PAN, and no CVV column at all. That is the
PCI-correct design and it is already correct. Cards only populate once a gateway
(Payfast) is wired, so this sequences after payment.
### Photoroom batch pipeline
✅ **Built 2026-09-15 evening.** Two ways in, same pipe:
- **Image Studio** — custom Spree admin page at `/admin/image_studio`. Upload
photos from the device (multiple, `accept="image/*"` so a phone opens the
camera roll) → Photoroom → before/after review → attach to an existing product
or create a new listing. Nothing touches the catalogue until Apply; discarding
is just navigating away, since the result is held as an unattached blob.
- **CLI** — `scraped-catalogue/photoroom_to_spree.py --slug <slug> [--live]
[--position 1]`. Same pipe, scriptable, with a read-back check.
🔴 **Batch is NOT built.** One at a time, capped at `MAX_PHOTOS = 8` per request
because each image is a 3-5s synchronous round trip and more starts risking the
web server timeout. A real batch needs a background job.
**Keys** live at `/root/.photoroom/credentials` (chmod 600) on the house box, and
`PHOTOROOM_API_KEY` in the backend `.env`. Currently the **sandbox** key —
1000/month free, every output watermarked. The live key has **10 images/month**
on the free tier (9 left as of 2026-09-15); the 7-day trial is the *web app*, a
separate meter from API credits, and its Batch tool does 250 images at once
through the browser without touching API quota.
⚠️ Photoroom adds little to the scraped catalogue — those images are already
studio shots on white, and the only real inconsistency is framing (2 aspect
ratios, 4 sizes across 160 products). Its value is phone photos of new stock,
which is what Image Studio is for. A free Pillow square-and-pad would fix the
existing 160.
## 📜 Session Log — 2026-09-14 (midnight build)
1. Discovered old `spree-app` VM deleted, `celest-proto` available with jux-lab.com
2. Installed Docker, cloned spree_starter, `docker compose up`
3. Created admin user, loaded 30 products via Rails script
4. Uploaded 76 product images to Spree ActiveStorage
5. Published products to "Online Store" channel (`Spree::ProductPublication`)
6. Scaffolded Next.js storefront with THANDO design tokens
7. Wired storefront to Spree API v3 (`X-Spree-Api-Key`, `?expand=media`)
8. Deployed to Vercel (pak-jabit-store-juxtapo9090.vercel.app)
9. Pushed to GitHub (juxtapo9090/pak-jabit-store)
10. Luc found `?expand=media` param for full image gallery
11. Built Zalora-style vertical thumbnail gallery (ProductImages component)
12. Hardcoded dark theme as default
## 📜 Session Log — 2026-09-15 (restructure)
1. Found the "deleted" `spree-app` VM alive in the `jux-mmo` project — stopped,
then deleted it. Its admin key never matched because each Spree instance
HMACs tokens with its own `SECRET_KEY_BASE`.
2. Imported 130 products via the Spree Admin API v3 (proper API, not Rails
runner — the presentation story is "automated import pipeline").
3. Spent an hour on 130 invisible products. Three stacked causes, all silent:
no `published_at`, not purchasable, and no price row at all.
4. Built `pak-jabit-backend` VM, migrated Postgres + 207MB ActiveStorage.
Kept `SECRET_KEY_BASE` so API keys and image URLs stayed valid.
5. Split the domain: api + admin → backend VM, shop → celest-proto.
6. Moved the storefront off Vercel onto celest-proto (systemd + Caddy).
A leftover `.env.local` from Vercel silently built it against the old
backend twice before we caught it.
7. Deleted the Vercel project. One project, two VMs, one domain.
8. Added `admin.jux-lab.com` — panel split from the API by hostname, so it can be
locked down separately later. Tested in a real browser: no bounce back to api.
9. Security pass: admin email off `spree@example.com`, password off `spree123`,
and closed `:3000` at the firewall (it was open to the internet over plain
HTTP, bypassing Caddy entirely).
10. Cart proven end-to-end on the new stack: create → add 2x item → $5,154.24.
## 📜 Session Log — 2026-09-15 (evening: ZAR, currency switcher, Image Studio, fail2ban)
1. **Backups** — `Projects/PeakyBuildr/backup-pak-jabit.sh`, nightly 04:30 via
`pak-jabit-backup.timer` on the house box → `/root/Backups/pak-jabit`.
pg_dump + ActiveStorage rsync + `.env` + compose + **the bind-mounted custom
code** (none of which is in the database). Restore proven into a throwaway
PG18 cluster: 160 products, 3200 blobs, 0 blobs missing their file.
`RESTORE.md` sits beside the dumps.
2. **The 23x price bug** — `fix_prices.py:6` set `MYR_TO_USD = 4.8` and
*multiplied*. 4.8 is the USD→MYR rate; MYR→USD is a divide. Every product was
listed at 4.8² = **23.04x** its real price (a MYR 537 watch at $2,577).
Repriced all 160 from source at the live rate.
3. **USD → ZAR** — store now quotes rand. 160 rows each in ZAR/USD/MYR.
4. **Currency switcher** on the storefront (ZAR · USD · MYR), URL-param driven so
the server fetches real Spree price rows — nothing converted in the browser.
The cart deliberately ignores it and stays on the store default.
5. **Image Studio** — custom admin page, see the Photoroom section above.
6. **fail2ban** — three jails, see Security below.
## 🔒 Security (2026-09-15 evening)
**fail2ban**, enabled at boot on the backend VM, banning at iptables so offenders
never reach Caddy or Rails:
| Jail | Trigger | Ban |
|---|---|---|
| `caddy-admin-login` | 8 POSTs to `/admin_users/sign_in` in 10 min | 24h |
| `caddy-scan` | 25 × 4xx in 10 min | 1h |
| `sshd` | 5 failed SSH in 10 min | 24h |
`ignoreip = 127.0.0.1/8 ::1 34.21.217.116 27.125.248.0/24` — celest-proto must
never be banned (it pulls product images constantly; banning it takes the shop
down), and the /24 is abang's ISP block, which his public IP rotates inside.
An allports ban on that range would also kill SSH to the box.
Caddy has **no `rate_limit` module** in the standard build, which is why this is
fail2ban and not a Caddy directive.
🔴 **Caddy had no access log until tonight** — errors only, so successful
requests left no trace whatsoever. Now JSON to journald (a file under
`/var/log/caddy/` fails to open even with the directory writable, and Caddy
fails the *whole service* on start rather than skipping the log — it dropped the
site for about a minute finding that out).
⚠️ **Unresolved:** `42.188.122.106` (Windows, Brave 153, carrying a session
cookie) requested `/admin/api_keys`, `/admin/store/edit` and `/admin/admin_users`.
Abang's browser is Linux on `27.125.248.0/24` — different machine. Those specific
requests 502'd mid-restart, but with no access logs before tonight any earlier
success is invisible. Most likely a colleague, since abang expects colleagues to
have access. `sign_in_count` is 0 but Devise trackable looks disabled, so that
number proves nothing.
🔴 **There is exactly ONE admin account**, shared. Every session is
indistinguishable, which is precisely why the above cannot be attributed. Give
each person their own account (Settings → Users) before more people get access.
## 💣 Landmines hit today
Every one of these returned success while being wrong. Worth knowing before
touching this stack again.
| Landmine | What it looked like | The actual cause |
|---|---|---|
| **Scalar `price` is silently dropped** | `POST /products` returns 200, product exists, price is `null` | Spree wants `prices: [{amount, currency}]`. No price row in the store currency → excluded from `available(Time.current, currency)` → invisible on the storefront, no error anywhere |
| **`published_at` left null** | `add_products` returns `{product_count: 130}` | The publication row is created but not live unless you pass `published_at` |
| **Not purchasable** | Product active, available, published | No stock and `track_inventory: true`. Needs stock or `track_inventory: false` |
| **`.env.local` beats `.env.production`** | Build succeeds, only 30 of 160 pages | Next.js precedence. The Vercel-era file pointed at the old backend, which was still running and still serving 30 products — so the build read stale data and reported nothing wrong. Cost two full rebuilds |
| **`docker compose restart` ignores `.env`** | Env edited, behaviour unchanged | `restart` reuses the existing container config. Needs `up -d` |
| **Admin API key looked broken** | `find_by_secret_token` → no match, every time | The key was copied from a *different* Spree instance. Each one HMACs tokens with its own `SECRET_KEY_BASE` |
| **"Deleted" VM was still running** | Manifest said deleted; nothing in the project list | It was in a different GCP project (`jux-mmo`), still billing |
| **OAuth token in a module variable** | Shipping worked, then randomly didn't | Process memory dies on restart/redeploy/cold start. Persist the refresh token to disk or DB |
| **`req.nextUrl.origin` behind a proxy** | OAuth redirect_uri came out as `localhost:3200` | Next.js sees the internal listener, not the public host. Use an explicit `APP_BASE_URL` |
| **100KB API body cap** | Image upload → `request_too_large` on a 115KB jpg | `Spree::Api::Config[:max_request_body_size]` default. 🔴 **The fix recorded here was never live.** `config/initializers/api_body_limit.rb` existed on the host with the 10MB value but the container never loaded it — the compose file mounts no app code, so the file was dead. Fixed by bind-mounting it (2026-09-15 evening). A runtime assignment also does nothing, but not for the reason written before: it isn't read at boot, it just isn't *persisted*, so it only affects the process that ran it |
| **Prebuilt image mounts no app code** | Initializer written, never ran | `ghcr.io/spree/spree` ships its own `/rails`. Anything custom needs an explicit bind mount in `docker-compose.yml`. This silently killed the 10MB body-limit fix for a whole day |
| **`add_routes` from an initializer is a no-op** | Sidebar entry appears, path helper does not exist | Spree draws engine routes BEFORE `config/initializers` run. Routes must go in `config/routes.rb` |
| **New asset defaults to position 1** | Appended an image, it became the product thumbnail | Spree defaults `position` to 1. A watermarked sandbox image went live as a public thumbnail this way. Always send an explicit position |
| **`max(:position)` on `media` misses half the gallery** | Appended image landed mid-gallery at position 2 | A gallery is the UNION of product-level `media` and legacy assets on the master variant. Clear both |
| **`product.stores << store`** | `NoMethodError` on a Relation | Products `belongs_to :store` (singular) in 5.6. Use `store:` on create |
| **`published_at = Time.current` hides the product** | Created, active, priced, stocked — invisible for up to 59s | The scope compares against `cutoff.beginning_of_minute`, truncating DOWN. Use `Time.current.beginning_of_minute` |
| **`curl -o` loses binary bodies here** | HTTP 200, `content-length: 758983`, zero-byte file | The rtk proxy layer eats it. Use Python for binary downloads on the house box |
Full write-up: `/mnt/playground/Server_files/Debug-Logs/2026-09-15_celeste_pak-jabit-restructure-and-invisible-products.md`