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:
| Rule | NEW | Used (UK / US / NG) |
|---|---|---|
cosmeticGrade | must be null | required (A / B / C) |
stockType | RESTOCKABLE | UNIQUE_UNIT |
status | IN_STOCK / OUT_OF_STOCK | AVAILABLE / SOLD |
quantity | integer ≥ 0 | null |
| sold-out expressed as | quantity 0 → auto-hidden | SOLD |
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.