AZ
Back to constellation
CommercePublished

Prechop

A Nigerian campus food pre-order marketplace: dated listings with cutoff times, Paystack split prepay, and atomic Redis slot reservations. Shipped on Next.js + Mongo (a Fastify/Prisma twin exists).

Next.js 16TypeScriptMongoDB / Mongoose 9Redis (ioredis)PaystackcronVitestPlaywrightFastify 5 + Prisma 7 + BullMQ (standalone API)
Read the write-up

Overview

What it is

Prechop is a Nigerian campus food pre-order marketplace: vendors publish dated daily listings with a cutoff time; buyers reserve and pay upfront via Paystack before the kitchen cooks. The tagline — "order before they cook" — is the whole model, and that preposition turns an ordinary catalog into a scheduling problem: inventory that expires.

Two backends, honestly

The repo carries two distinct backends, and the one actually shipped is not the Prisma one:

Dimensionprechop/ — the live appprechop-api/ — the standalone twin
FrameworkNext.js 16 App Router (frontend and API in one)Fastify 5 API + a separate worker process
StoreMongoDB via Mongoose 9PostgreSQL via Prisma 7 (cuid ids)
Background workin-process cron, no queueBullMQ delayed jobs + worker
Cutoff auto-closeper-minute cutoff-sweep cron under a Redis lockdelayed job keyed jobId = dailyOrderId, fires at cutoff
Slot oversell guardINCRBY/EXPIRE reservation counterSET NX per-(item,order) lock
Queue infranoneRedis-backed BullMQ

Both implement the same domain (dated listings, cutoffs, Paystack split prepay, oversell guards), so this case study draws the cleanest expression of the data model from prechop-api's Prisma schema and the shipped scheduling mechanics from the Next.js app — without pretending Postgres/Prisma is the live store. The Prisma schema is worth reading on its own: money is integer kobo everywhere, a DailyOrder snapshots item name/price/image/prep-time at listing-creation, and a Payment carries a unique idempotencyKey and a webhookVerified flag.

The live prechop/ backend layers under src/server/: constants/, databases/ (Mongo + Redis singletons), lib/ (a withApiHandler ∘ withAuth composition, response envelope, CSRF, rate limit), 17 Mongoose models/ with typed *DB functions, providers/ (Paystack, Sendchamp, Resend, S3, web-push), services/, and Zod validators/. It even carries a full IAM subsystem (policies/groups, services/iam/can.ts) the brief never mentioned.

Cutoff enforcement — the interesting part

Enforcement is layered. A read-time guard runs on every order attempt — a "coming soon" check for a not-yet-open listing plus if (cutoffTime <= now) throw CutoffPassed — so no scheduler race can let a late order slip through even if a sweep is delayed. The scheduled auto-close is where the two backends diverge. prechop-api enqueues a BullMQ delayed job keyed by listing id that fires exactly at the cutoff:

async function scheduleDailyOrderAutoClose(dailyOrderId, cutoffTime) {
  const existingJob = await cutoffEnforceQueue.getJob(dailyOrderId);
  if (existingJob) await existingJob.remove();
  const delay = Math.max(0, cutoffTime.getTime() - Date.now());
  await cutoffEnforceQueue.add("close-daily-order", { dailyOrderId },
    { jobId: dailyOrderId, delay, removeOnComplete: true, removeOnFail: true });
}

The live app instead runs a per-minute cron sweep where each of eight CronJobs is wrapped in runSingleInstance — a per-process token plus a Redis lock so that under horizontal scaling only one instance per tick does the work:

const key = `cron:lock:${DB_NAME}:${job}`;
const got = await acquireLock(key, INSTANCE_ID, ttlSeconds);
if (!got) return;
try { await fn(); } finally { await releaseLock(key, INSTANCE_ID); }

Several jobs pass PLATFORM_TIMEZONE (Lagos) as a load-bearing argument, not decoration: cron schedules in the server's local time, so on a UTC host the nightly sold-out reset would fire at 01:00 Lagos and leave every sold-out item dark through the first trading hour.

Closing a listing isn't the same as "stop new orders": a separate cutoff-enforce job (sweepStalePaidOrders) auto-cancels and Paystack-refunds every PAID-but-unconfirmed order the vendor took money for and never committed to cook — its 280s lock TTL deliberately outlives a slow batch of Paystack round trips so the next tick can't start an overlapping sweep. And the 30-minute pre-cutoff warning would fire 30 SMS from a per-minute sweep, so it's deduped with a per-listing SET NX whose TTL outlives the window; the key is never released because expiry is the reset, and a lost lock (Redis down) yields no warning rather than a duplicate — the safe direction for a message that costs money to send.

Money and oversell safety

Order placement is server-authoritative: the client sends only ids, and the server resolves and prices items and add-ons (rejecting an add-on that doesn't belong to the exact daily-order item), computes totals, and initialises Paystack before any DB write — on failure it releases the Redis slot locks and persists nothing. Paystack runs on split subaccounts with a per-transaction charge so the platform absorbs the processing fee, not the vendor. The webhook verifies an HMAC-SHA512 signature on the raw body with crypto.timingSafeEqual before doing anything, then handles only charge.success, checks idempotency, verifies the paid amount equals the record, and transitions the order. Finite maxQuantity slots are guarded separately with atomic Redis reservations — availability = capacity − committed − reserved — that roll back cleanly under contention:

const reservedAfter = await Redis.incrby(key, item.quantity);
await Redis.expire(key, ttlSeconds);
acquired.push({ id: item.dailyOrderItemId, qty: item.quantity });
if (item.committed + reservedAfter > item.maxQuantity) {
  for (const a of acquired) await decrReserved(a.id, a.qty); // roll back all
  return { ok: false, failedItemId: item.dailyOrderItemId };
}

Testing & security posture

Vitest runs against a per-worker throwaway DB (prechop-vitest-<pid>-<pool>) whose globalSetup mints the id and guarantees the scratch database is dropped even when a worker crashes — never the dev DB — across ~35 test files (models, services incl. iam/dailyOrderFlow/cronSweeps, providers). The Playwright config is instructive in its own right: it defaults to an obscure port (3187) and refuses to reuse a running server after a real incident where the suite silently ran against adverta-web-1 on a shared port, uses a dedicated throwaway Mongo + Redis logical db dropped after the run, and runs next start in production mode so the prod boot guard (assertRuntimeConfig) is exercised by e2e. Security: server-authoritative pricing, idempotency keys, AES-256-GCM encryption for vendor bank details, dual-secret HS256 JWTs (access + refresh cookies), Redis-backed rate limiting, and a GET /api/health that returns 200 only when both Mongo and Redis answer.

Related write-ups