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:
| Dimension | prechop/ — the live app | prechop-api/ — the standalone twin |
|---|---|---|
| Framework | Next.js 16 App Router (frontend and API in one) | Fastify 5 API + a separate worker process |
| Store | MongoDB via Mongoose 9 | PostgreSQL via Prisma 7 (cuid ids) |
| Background work | in-process cron, no queue | BullMQ delayed jobs + worker |
| Cutoff auto-close | per-minute cutoff-sweep cron under a Redis lock | delayed job keyed jobId = dailyOrderId, fires at cutoff |
| Slot oversell guard | INCRBY/EXPIRE reservation counter | SET NX per-(item,order) lock |
| Queue infra | none | Redis-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.