Back in Stock Module — Admin
Admin surface for back-in-stock ("notify me") requests — who is waiting on which sold-out variant, and withdrawing a request on a shopper's behalf. The demand signal behind a sold-out…
Admin surface for back-in-stock ("notify me") requests — who is waiting on which sold-out variant, and withdrawing a request on a shopper's behalf. The demand signal behind a sold-out SKU: how many people, and since when.
Source:
api-modules/back-in-stock/src/controllers/admin-stock-notification.controller.ts.This is an optional plugin — removing
BackInStockModule.forRoot()fromapp.module.tsremoves these routes along with the storefront surface. The customer-facing side is documented in Back in Stock — Storefront.
Conventions
Authentication
All endpoints: BetterAuthGuard + PermissionsGuard.
| Endpoint | Permission |
|---|---|
GET /admin/stock-notifications | stockNotification: read |
GET /admin/stock-notifications/demand | stockNotification: read |
GET /admin/stock-notifications/summary | stockNotification: read |
GET /admin/stock-notifications/demand/:variantId | stockNotification: read |
POST /admin/stock-notifications/demand/:variantId/notify-vendor | stockNotification: notify-vendor |
DELETE /admin/stock-notifications/:id | stockNotification: delete |
Configuration
The feature's behaviour is settings-driven, not endpoint-driven — see the back_in_stock group under Store Settings (enabled, guest_enabled, max_active_per_email, expiry_days). The message wording lives under Templates: back_in_stock.available|customer for email, and the matching in-app / push templates.
Admin UI
Storefront → Back in Stock (@sc/admin-stock-notifications), gated on stockNotification: read; the withdraw row-action is gated on stockNotification: delete. Removing the module's line from apps/admin/modules/registry.ts drops the nav entry and route.
The index page leads with four headline tiles (waiting, variants awaited,
unique subscribers, notified all-time), a daily requests-raised trend, then
the ranked demand table. Selecting a row navigates to
/stock-notifications/:variantId — a full page with that variant's own
tiles, its own interest-over-time chart, and the list of who is waiting.
That list drops the product column, since the page already names it.
Every demand row carries a Notify vendor action (and the variant page a
prominent button), gated on stockNotification: notify-vendor.
Endpoints
GET /admin/stock-notifications — List requests
Standard QueryDto pagination (limit / offset), newest first, plus:
| Query | Type | Notes |
|---|---|---|
status | "waiting" | "notified" | "cancelled" | "expired" | Filter by lifecycle state |
variantId | string | Everyone waiting on one variant |
productId | string | Everyone waiting on any variant of one product |
email | string | Partial, case-insensitive match |
Response 200 — paginated rows:
type AdminStockSubscription = {
id: string;
variantId: string;
productId: string;
productTitle: string | null;
productSlug: string | null;
variantSku: string | null;
status: "waiting" | "notified" | "cancelled" | "expired";
notifiedAt: string | null; // ISO
createdAt: string; // ISO
email: string; // admin-only
userId: string | null; // admin-only; null for a guest subscriber
};Rows are never hard-deleted, so notified rows stay as a record of demand that converted, and expired rows show requests the daily sweep dropped after expiry_days.
GET /admin/stock-notifications/demand — Awaited variants, ranked
The restock-decision view: one row per variant with at least one shopper
waiting, joined to that variant's live stock so both halves arrive in one call.
Paginated with the standard limit / offset.
| Query | Type | Notes |
|---|---|---|
rank | "waiting" | "oldest" | "recent" | "guests" | Ordering. Default waiting (most people waiting); oldest surfaces who has been queued longest; guests finds demand coming from shoppers with no account |
unorderableOnly | boolean? | Keep only variants a restock would actually release — excludes anything already orderable (in stock, backorder, untracked) |
type StockDemandRow = {
variantId: string;
productId: string;
productTitle: string | null;
productSlug: string | null;
variantSku: string | null;
waitingCount: number; // shoppers still waiting
notifiedCount: number; // already told, all-time — the demand that converted
guestCount: number; // of the waiting, how many have no account
oldestRequestedAt: string | null; // ISO; the longest-waiting request
newestRequestedAt: string | null; // ISO
availableQuantity: number | null; // null when untracked
stockStatus: "in_stock" | "low_stock" | "out_of_stock" | "backorder" | "untracked";
};Rows are grouped per variant, not per product — two sizes of the same
product are two decisions. availableQuantity here is the operator-facing
number (on-hand − reserved − safety stock); it is not subject to the
storefront's show_stock_quantity toggle.
GET /admin/stock-notifications/summary — Headline totals + trend
| Query | Type | Notes |
|---|---|---|
days | int | Trend window, 7–180, default 30 |
type StockDemandSummary = {
totalWaiting: number;
variantsAwaited: number; // distinct variants with someone waiting
uniqueSubscribers: number; // distinct email addresses waiting
guestWaiting: number;
totalNotified: number;
requestedInWindow: number;
notifiedInWindow: number;
windowDays: number;
trend: Array<{ date: string; requested: number; notified: number }>;
};trend is dense — days with no activity come back as zeroes, so it can be
plotted without gap-filling on the client.
GET /admin/stock-notifications/demand/:variantId — One variant
The same StockDemandRow for a single variant, plus that variant's own daily
trend — what the variant page renders. Accepts the same days window as
/summary (7–180, default 30).
type VariantDemandDetail = StockDemandRow & {
windowDays: number;
requestedInWindow: number;
notifiedInWindow: number;
trend: Array<{ date: string; requested: number; notified: number }>;
};404 when no request has ever been raised for that variant.
POST /admin/stock-notifications/demand/:variantId/notify-vendor — Nudge the vendor
Tells the variant's vendor how many shoppers are queued on it. Fans out to every member of that vendor through the notification orchestrator, so each one gets it on the channels they've kept switched on (email + push, with in-app riding the push intent).
The wording is not in the code path — it resolves through
back_in_stock.vendor_alert|vendor_member in the editable catalogs, so the
operator can rewrite or disable it under Templates. Variables available:
{{waiting_count}}, {{guest_count}}, {{waited_days}}, {{product_title}},
{{variant_sku}}, {{product_id}}, {{variant_id}}, {{inventory_url}}.
The call to action points at the vendor dashboard's inventory row for that
variant (/inventory/:productId/:variantId) — where they can actually add
stock — not the storefront product page. The in-app and push templates use
that relative path directly; the email needs an absolute URL, so it
prefixes admin.notifications.vendor_app_url. Leave that setting blank and
the email still sends, just without a working button.
Rate-limited per variant — one alert per variant per 12 hours, so repeated
clicks (or two admins working the same list) can't spam a vendor. A second
attempt inside the window is 409 VENDOR_ALERT_ON_COOLDOWN. The cooldown
starts only after a successful send.
Response 200
{
"data": {
"variantId": "var_123",
"vendorId": "ven_456",
"waitingCount": 12,
"cooldownHours": 12
}
}| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Nobody is waiting on that variant, or it doesn't exist |
| 409 | VENDOR_ALERT_ON_COOLDOWN | Already alerted within the last 12 hours |
DELETE /admin/stock-notifications/:id — Withdraw a request
Marks a waiting request cancelled (no row is removed). Returns 204. A request that is already notified, cancelled, or expired is left as it is.
Related modules
- Storefront surface — Back in Stock, including how the notification is delivered.
- Settings — the
back_in_stockstore-scope group. - Email templates / Notification templates — the editable message content.
Audit Module — Admin
Unified audit log capturing every domain event (attributed to the triggering actor via request-context propagation) plus eventless HTTP mutations, enriched (geo + device) and stored in ClickHouse via a dedicated event bus and a separately-deployable worker. Filterable, paginated admin read API.
Banner Module — Admin
HTTP surface for managing promotional banners attached to catalog entities (categories, brands, tags, ingredients). Banner targets a single entity via a polymorphic (entityType,…