Amirali YaghoutiSenior Software Engineer

woocommerce Case study

Back-in-Stock Notifier

On a watch and jewellery retailer where roughly three quarters of the catalogue is out of stock at any time, the waiting list is not a nicety. It is the only way a sold-out page can turn a visitor into a sale. The plugin doing that job collected an email address and nothing else, on a shop that reaches its customers by SMS. I replaced it with a notifier that takes either channel, sends exactly once, and hands the person to the shop's CRM as a lead.

The business problem

The installed waitlist plugin collected an email address and nothing else. Every transactional message on this storefront, from one-time passwords to cart recovery to loyalty rewards, travels by SMS, so a waiting list that cannot send one is a list nobody acts on. Roughly three quarters of the catalogue is out of stock at any time, those pages still draw search traffic, and a visitor who landed on one had no way to express interest except to leave.

Auditing the old plugin's data before migration showed what that had cost. It held 250 subscriptions collected over two years: 217 distinct email addresses, and no phone number for most of them; 24 matched a customer account, and 21 of those had a phone on file; and 11 of the products people had waited for were already back in stock, some for over a year, with nobody told.

What I delivered

  • A subscription keyed on product plus identity, where the identity can be a phone number, an email address or a logged-in account. Two unique indexes cover product-plus-phone and product-plus-email; because a MySQL unique index accepts any number of NULLs, an email-only row and a phone-only row for the same product coexist without a sentinel value standing in for "none".
  • Restock detection that survives stock changing by an admin save, an importer, a refund, or a plugin that never fires the expected hook. An hourly sweep is the safety net, not the mechanism: it catches a product that came back by a route nobody was watching.
  • Queued, once-only delivery. Sending is asynchronous because stock usually returns inside an administrator's save or an importer run, and fifty gateway round trips inside that request is a save that appears to hang. Each subscription is served once, enforced by the row's own status rather than a cache entry.
  • CRM binding. An anonymous subscriber is matched to an existing contact where one exists, and a lead is raised so an operator can act on demand the shop cannot currently meet. The existing opt-out and blocklist are respected before a row is written, not before a send.
  • Cache-safe interface state. The buttons carry no state in the markup; the page asks once, in a single request, after it is interactive. So the first visitor's "you are already on this list" is never baked into a full-page cache with a very long lifetime.
  • An idempotent migration off the old plugin that can be run twice without duplicating anything and deliberately notifies nobody, plus an operator view that lists today's subscriptions and the rows already notified so staff can ring them.

Technical approach

  • Six small modules loaded in order, each with one responsibility: a core for schema, identity normalisation, subscribe, cancel and the CRM binding; a three-endpoint API for join, leave and read state; the button and its dialog in the three places a sold-out product appears; inline assets gated to pages that can carry a button; dispatch; and a tab in the customer's own account listing what they are waiting for.
  • Reads match on all three identities at once. Somebody can subscribe as a guest with a phone number months before creating an account, and would not accept "that was not you" as an answer.
  • I kept email rather than drop it to keep the design tidy, because dropping it would have discarded 193 real people. And I migrated without sending, because a message about a watch that returned eighteen months ago is not a service.
  • Two things I refused to trust. Markup never goes inside price HTML, after I found a localisation plugin rewriting the digits inside it, including the digit in a CSS class name. And the existing phone normaliser's output is validated rather than believed, after it returned a five-digit string unchanged.
  • Served means served. Someone who asked in March and was told in September has been told, and the row records that permanently. Wanting to hear again means asking again, which is what the button already offers.

Result and evidence

The audit numbers above are measurements of the old plugin's data, taken before migration. They describe what I replaced, not what the new notifier has produced. What is verified live: the back-in-stock SMS pattern is wired and proven, with the product name and the link riding in separate template tokens, and the operator app's back-in-stock view returns rows with a notified status. Earlier non-delivery was traced to a zero-width non-joiner in product names and to the site's safe-mode HTTP short-circuit. The system is in production.

Commercial value

Three quarters of a catalogue out of stock is three quarters of the search traffic landing on pages that cannot sell. A waiting list that reaches people on the channel they actually read, once, and puts them in front of an operator as a lead, turns those pages into demand the shop can see and act on. In my judgement the same shape transfers to any retailer with stock-outs, and to clinics and salons as slot-available alerts.

implementation-brief.readme

Readable implementation brief

implementation_brief {
  project: "Back-in-Stock Notifier"
  status: "live; replaced a third-party waitlist plugin"
  identity: "product + phone | email | account; two unique
             indexes, NULL-tolerant, no sentinel value"
  detection: "stock hooks + hourly sweep for routes that
              never fire one"
  delivery: "queued, never inline; once per row, enforced
             by row status, not by cache"
  crm: "bind to existing contact, raise a lead;
        opt-out and blocklist checked before write"
  ui: "no state in markup; one state request per page,
       after interactive, so the full-page cache stays safe"
  migration: "idempotent, safe to run twice; notifies nobody"
}

What this project shows

The migration policy is the decision I would want a reviewer to look at. Eleven products had come back, some for over a year, with nobody told. Sending those messages late would have been worse than silence, so the migration moves the rows and sends nothing.

Most of the engineering here is refusing to assume: that a hook will fire, that a normaliser normalises, that price HTML is inert, that a cached page can carry per-visitor state. Two of those, the localisation plugin rewriting digits and the normaliser returning a five-digit string unchanged, I only learned by being bitten.