AZ
Back to constellation
CommercePublished

Mogadget

A single-owner Lagos gadget catalog with no cart — browse, then hand off to a prefilled WhatsApp/Instagram chat, with fire-before-navigate click analytics.

Next.js 16TypeScriptMongoDB / Mongoose 9RedisZodpinoVitestPlaywright
Read the write-up

Overview

What it is

Mogadget is a single-owner gadget catalog for a Lagos store. Visitors browse and filter a catalog and order by tapping "Chat on WhatsApp" or "DM on Instagram" — there is deliberately no cart, no checkout, no online payment, and no customer accounts. The product doc says it in the first four lines and again in §8: "no step in this flow touches a cart, checkout, or account system." It reuses the Managerenta triad but adds two layers of its own: a pure src/server/domain/ of business rules and a typed client src/lib/ API wrapper.

Architecture — triad plus a pure domain layer

The server folders are the familiar models/, services/ (one function per file), validators/, plus domain/ — pure, DB-free, unit-tested rules (whatsapp.ts, naira.ts, slug.ts, product.ts). Structural logging is pino, unique to this app. The products model carries a compound browse index { isVisible, category, condition, priceNaira } and a text index { name, brand, description }, and listProductsDB sinks unavailable items below available ones after the DB sort:

// SOLD / OUT_OF_STOCK always sink below available items (product doc §5.2).
const rank = (p: IProduct) => (p.status === "SOLD" || p.status === "OUT_OF_STOCK" ? 1 : 0);
result.sort((a, b) => rank(a) - rank(b));

The domain invariants — two product shapes, enforced

The interesting modelling lives in domain/product.ts: a NEW item and a pre-owned unit are structurally different records, and assertProductInvariants rejects any mixture on every write:

RuleNEWUsed (UK / US / NG)
cosmeticGrademust be nullrequired (A / B / C)
stockTypeRESTOCKABLEUNIQUE_UNIT
statusIN_STOCK / OUT_OF_STOCKAVAILABLE / SOLD
quantityinteger ≥ 0null
sold-out expressed asquantity 0 → auto-hiddenSOLD
if (isNew) {
  if (p.cosmeticGrade !== null) bad();
  if (p.stockType !== "RESTOCKABLE") bad();
  if (!restockStatuses.includes(p.status)) bad();
  if (p.quantity === null || p.quantity < 0) bad();
} else {
  if (p.cosmeticGrade === null) bad();
  if (p.stockType !== "UNIQUE_UNIT") bad();
  if (!uniqueStatuses.includes(p.status)) bad();
  if (p.quantity !== null) bad();
}

A companion rule, stockAwareVisibility, auto-hides a RESTOCKABLE listing the moment its quantity hits 0 — enforced on every write, not gated behind the admin visibility toggle — but restocking never auto-unhides it: re-listing stays a deliberate admin choice.

The "no cart" core: WhatsApp / Instagram hand-off

The order flow is the hand-off. domain/whatsapp.ts builds a deep link whose prefilled message identifies the exact product and price, so negotiation and payment happen entirely in-chat, off-platform:

export function buildWhatsAppLink(p: { name: string; priceNaira: number; url?: string }): string {
  const base = `Hi, I'm interested in the ${p.name} (${formatNaira(p.priceNaira)}) listed on MoGadget`;
  const msg = p.url ? `${base}${p.url}` : base;
  return `https://wa.me/${WHATSAPP_NUMBER}?text=${encodeURIComponent(msg)}`;
}

Click tracking must never block the sale, so the client fires before navigation with navigator.sendBeacon (keepalive fetch fallback), wrapped in try/catch — "analytics are best-effort — never interrupt the sale." The server does an atomic $inc on the product's channel counter, then a best-effort append to a time-series log whose failure is logged via pino but never fails the click:

const productId = await incrementClickDB({ slug, channel }); // atomic $inc, returns null if gone
if (!productId) return false;
try { await insertClickEventDB({ productId, slug, channel }); }
catch (err) { getLogger().warn({ err, slug, channel }, "click-event insert failed (click still recorded)"); }

Analytics without a cron and without PII

The clickEvents collection is an append-only event log with a MongoDB TTL index for automatic 180-day retention — no cron job — plus a secondary index that supports a day-by-channel $group trend query. It stores no PII:

const RETENTION_SECONDS = 180 * 24 * 60 * 60; // 180 days
// Automatic retention — no cron. TTL must be a single-field index.
ClickEventSchema.index({ createdAt: 1 }, { expireAfterSeconds: RETENTION_SECONDS });
ClickEventSchema.index({ createdAt: 1, channel: 1 });

An honest tension: the doc said "one admin," the build shipped IAM

The product doc argued for a single admin login and no permission framework (§13/§21) — but the shipped app carries a full IAM stack: users, groups and policies gated by a withPermission(handler, ...required) wrapper, plus WebAuthn passkeys and TOTP 2FA with recovery codes. The doc's line-22 "Historical note" flags the pivot honestly rather than pretending the plan never changed. The public click endpoint stays unauthenticated by design — but it carries no PII.

Testing & security posture

39 colocated Vitest specs sit beside their subjects — domain/ rules, services/products/*, the products model, and the client src/lib/ API wrappers all have adjacent tests — giving strong unit discipline over the invariants and the hand-off. Playwright covers the public catalog, admin, admin analytics, and settings. Tooling is pnpm + Biome + Vitest + Playwright, and the dev server runs on port 6060.

Related write-ups