Client: International Salon Supplies
Role: Lead Full-Stack Engineer / Architect
Period: Dec 2023 – Present (launched Nov 2024)
Status: Production
Tech stack: Next.js, React, TypeScript, NestJS, TypeORM, PostgreSQL, RabbitMQ, isolated-vm, WordPress, WooCommerce, PHP 8.1, WPGraphQL, CoCart, Elasticsearch, ElasticPress, Redis, SQLite, Drizzle ORM, Puppeteer, Handlebars, Sharp, DigitalOcean Spaces, Caddy, Docker, ELK, Grafana, Retail Express REST & SOAP, Tyro, PayPal, Zip
A complete rebuild of International Salon Supplies' wholesale e-commerce platform: a NestJS sync engine that turns a polling-only ERP into a near real-time event stream, RabbitMQ with sandboxed routing filters, headless WooCommerce, a Next.js storefront with direct Elasticsearch search, a Puppeteer invoice service, and an ELK observability stack. Launched in November 2024 with 7,000+ SEO redirects and ~1,560 commits across the system.
Context
International Salon Supplies (ISS) supplies salons, barbers and beauty professionals across Western Australia. Stock, customers, pricing, loyalty and orders all live in Retail Express (REX). The previous website was kept in sync by an older Python integration I inherited. It worked, but it was a polling bridge straight into WooCommerce, with no event stream, no routing and little visibility when something went wrong.
ISSWA 2.0 replaced the whole stack. The goals were a fast, searchable storefront for trade customers; stock, price and customer data that stays near real time with the ERP; and web orders and payments written back to the ERP reliably.
Architecture
Retail Express ERP (REST + legacy SOAP)
│ delta polling ▲ SOAP: create order, add payment
▼ │
┌──────────────────────────────────────┐ │
│ Sync engine (NestJS + PostgreSQL) │ │
│ mirror tables → change log │ │
│ → per-binding JS filter (isolated-vm)│ │
│ → RabbitMQ publish → published ledger│ │
│ Admin console (Next.js, CodeMirror) │ │
└──────────────────┬───────────────────┘ │
▼ durable queues │
RabbitMQ │
▼ prefetch 1 │
Store-and-forward consumer (Node + SQLite inbox)
▼ batches of 10 │
┌──────────────────────────────────────────────┐
│ Headless WordPress + WooCommerce │
│ custom plugin: processors per entity, │
│ ERP write-back, rules, feeds, marketing sync │
│ Redis object cache · ElasticPress → ES │
└─────────┬───────────────────────────┬────────┘
▼ ▼
Next.js storefront (BFF, ISR) PDF invoice service
Elasticsearch search · Redis (Puppeteer + Handlebars)
sessions · Tyro/PayPal/Zip
Observability: winston / PHP → Logstash → Elasticsearch → Grafana + email on ERROR
Everything runs in Docker on DigitalOcean with managed PostgreSQL/MySQL, DigitalOcean Spaces as an S3-compatible CDN, and Caddy for TLS.
1. Making a polling ERP feel real-time
The ERP's API offers paged modified_since REST endpoints plus some legacy SOAP services. There are no webhooks and no event stream. The sync engine turns that into near real-time events:
- A single cron loop (default every 2 seconds, adjustable at runtime and stored in the database) with a persisted power switch. It never overlaps a running or manual sync.
- Watermarks commit only when a delta has been fully read (
total_records === recordsCounter). A partially read page set never moves the cursor. - Inventory rewinds 5 minutes on every pass to absorb the ERP's late-arriving stock writes. Other resources add one second to avoid re-reading the last row.
- Per-status backoff: 429 → 5 min, 503 → 10 min, 500/504 → 1 min, 401 → refresh the token and hold, anything else → 60 s.
- Order-driven stock refresh. When an order syncs, every product on it is queued for a priority inventory fetch. A PostgreSQL
CROSS JOIN LATERAL jsonb_array_elements_text(...)expands bundle products into their component SKUs and back, so stock allocation shows on the website within seconds of a sale. - Deletions the API never reports. The ERP's delta API doesn't tell you when a barcode is removed. After each barcode sync, the engine compares enabled-barcode counts. On a mismatch it runs a full diff, soft-disables the vanished rows and publishes
enabled=falseevents. - A SOAP payload inside a payload. Product packages come from a legacy SOAP 1.2 call that returns a Base64-encoded, gzipped XML document inside the SOAP body. The engine extracts, decodes, gunzips and parses it, then replaces packages in batches, throttled to once an hour.
The engine is ERP-agnostic by design: an ERP_MODE switch (REX | ODOO) and prefixed external IDs mean a future ERP swap doesn't mean rewriting downstream consumers.
2. Letting operations write routing logic, safely
Different downstream systems want different subsets of the change stream: some categories, some outlets, some brands. Hard-coding that meant a redeploy for every business rule change.
Instead, each RabbitMQ binding has a user-defined JavaScript filter that staff edit in the admin console (CodeMirror). A filter can't be saved until it passes a test against sample records. At runtime each filter executes inside isolated-vm: a fresh V8 isolate with a memory cap, disposed after every run. The publisher is a three-stage outbox:
base_changes: an upserted change log per entityfiltered_changes: the output of each binding's filter, published in batches of 100 as persistent messagesfiltered_changes_published: a ledger that only advances on newermodified_on, used for dedupe and to detect records that have moved out of a filter
Even the RabbitMQ topology (exchanges, queues and bindings) is data stored in PostgreSQL and asserted on startup. If topology setup fails, the process sends itself SIGTERM so Docker restarts it cleanly instead of running half-configured.
Product events are enriched before publishing with CMS fields, brand, inventory and barcodes merged into one payload, so consumers never need to call back.
3. A consumer that can't lose messages
WordPress is a poor AMQP consumer: PHP requests are short-lived, and deploys or slow admin screens cause gaps. A small Node/TypeScript consumer sits in between:
- It consumes with
prefetch(1), writes each message to a local SQLite inbox and only then acks. - A separate loop forwards the oldest 10 rows to a WordPress batch endpoint and deletes them only after a 2xx.
- It reconnects with exponential backoff and heartbeats, and shuts down gracefully.
Inside WordPress, a custom plugin dispatches each message by table_code to a processor: products, inventory, customers, orders, brands, barcodes, packages. PHP 8.1 enums define processing states (PROCESSED, FAILED, HOLD, SKIP, REQUEUE), and an audit row records every attempt. After a batch, affected parent products are bulk re-indexed in Elasticsearch and only the affected storefront category pages are revalidated.
4. Headless WooCommerce with real business rules
The custom WooCommerce plugin (~21k lines of PHP across ~20 modules) is where the business lives:
- Product modelling. ERP items are grouped into parent products and variations by manufacturer SKU, the category tree is built from ERP category strings, and disabled products are moved to a hidden category.
- Order write-back to the ERP over SOAP (create order and add payment) on status change, with a full request/response log viewer. It covers gift vouchers, loyalty points, coupons, overpayments, quotes and on-account orders, and avoids double payments when an order is edited in the ERP.
- Trade customer approval: sign-up with proof-of-trade upload, an approval queue, and approve/reject/block synced to the ERP.
- Rule engines built as custom post types: who can buy which brands, categories or products, and "available in store" rules.
- Frequently bought together and easy re-order, powered by a separate order-line database.
- Integrations: Omnisend (with E.164 phone normalisation and consent), Maropost, a Google Merchant feed, drop-shipping orders, and Tyro payment reconciliation.
- Two node types from one image: web nodes serve traffic, while a standalone node runs system cron (feeds, promo expiry, payment validation, clean-ups) and hosts the consumer.
5. Fast wholesale search, straight from the storefront
The Next.js storefront has ~80 backend-for-frontend API routes that keep all secrets server-side, with sessions in Redis. Search skips WordPress entirely and queries Elasticsearch directly, using a hand-tuned boost ladder built for how trade customers actually type product codes:
| Match type | Boost |
|---|---|
| Exact | 600 |
| Exact with spaces removed | 550 / 450 |
| Starts with | 350 / 300 |
| Contains | 250 / 200 |
| Fuzzy | 150 / 120 |
Salon staff can also scan a barcode with the phone camera (ZXing with torch control) to find or re-order a product.
Caching has three layers: ISR per page type, on-demand revalidation triggered by WordPress, and a filesystem JSON cache for menus, footers, filters, redirects and in-store rules.
6. Launching without losing Google
The November 2024 cut-over was planned around SEO:
- A redirect plan of ~7,155 legacy URLs became edge-middleware 308 permanent redirects.
X-Robots-Tag: noindexon private pages and faceted query-string URLs.- Dynamic sitemaps (~5,480 products, 286 brands, 261 categories), plus
robots.txtandllms.txt. - ~17,500 legacy images migrated to DigitalOcean Spaces, then attached to products by the sync engine's CDN module.
Supporting services
- PDF invoice service: Next.js, Puppeteer and Handlebars render branded GST tax invoices, single or ZIP-bundled. Duplicate requests reuse an existing job, files expire after 30 minutes, and the invoice template is edited in WordPress and pushed to the service on save, so the business changes invoices without a deploy.
- Image processing service: Sharp converts uploads to WebP (max 800×800, no upscaling), keeps animated GIFs animated, and falls back to PNG for unusual formats, before upload to the CDN and a cache purge.
- Observability: NestJS (winston) and WordPress (a must-use plugin) ship logs to Logstash. A pipeline normalises timestamps to Perth time, indexes daily, feeds Grafana dashboards and emails the team on every ERROR.
Security
- The storefront middleware blocks spoofed
x-middleware-subrequestheaders (the 2025 Next.js middleware-bypass class). - Forms use Cloudflare Turnstile. Admin and API routes use JWT, API keys or shared secrets.
- Server-side Meta Conversions API calls are gated by Google Consent Mode.
By the numbers
- ~1,560 commits across the platform (Dec 2023 → Sep 2026), launched Nov 2024
- Sync engine: ~6.4k lines of NestJS, 30 routes, 26 entities
- WordPress: ~21.5k lines of custom PHP, ~45 custom REST routes, ~10 WP-CLI commands
- Storefront: ~80 API routes, 748 commits
- 7,155 redirects, ~17.5k migrated images
Lessons
- Events are worth building even when the source can't emit them. A careful poller plus an outbox turned a request/response ERP into a stream that every later project (CMS, dropshipping API, mobile app, Tuel) could subscribe to.
- Let the business change rules without a deploy, but sandbox it.
isolated-vmmade user-authored filters safe enough to hand to operations. - Put a buffer in front of anything you don't control. The SQLite inbox is ~300 lines of code and one of the most valuable pieces of the system.