Full‑stack app for limited‑edition product drops.
Users browse products, reserve for 60 seconds, then purchase.
Stock updates live across all browsers via WebSocket.
A technical assessment submission. Implements:
- Atomic reservation (no overselling)
- 60‑second stock lock with auto‑expiry
- Real‑time stock sync across tabs
- Purchase flow (only if reserved)
- Admin API to create drops (no UI)
Backend (Node + Express + Prisma + PostgreSQL)
POST /admin-drops/create-drop– admin creates a drop with total stock.POST /public-reservations/reserve-drop– user reserves one unit.- Checks existing active reservation for that user+drop.
- Atomically decreases
availableStockby 1 only if current stock > 0. - Creates a reservation row with
expiresAt = now + 60s.
- Background job runs every 10 seconds:
- Finds all
activereservations whereexpiresAtis past. - Locks rows with
FOR UPDATE SKIP LOCKED(safe for multiple server instances). - Marks them
expiredand restoresavailableStock+1.
- Finds all
POST /public-reservations/purchase-reserved-drop– completes purchase.- Checks reservation is still active and not expired.
- Creates purchase record, marks reservation
completed.
- WebSocket (
realtime-drop:inventory) broadcasts stock changes after any reserve, purchase, or expiry.
Frontend (React + TanStack Query + Socket.io)
- Fetches drops via
GET /public-drops/get-drops(includes top 3 purchasers). - Connects to WebSocket, listens to
realtime-drop:inventory. - Updates React Query cache directly → UI re‑renders only affected drop card.
- Floating badge shows active reservations count; dialog lists all reservations with countdown timer and purchase button.
Used a database transaction with Serializable isolation:
const updated = await tx.drop.updateMany({
where: { id: dropId, availableStock: { gt: 0 } },
data: { availableStock: { decrement: 1 } }
})
if (updated.count === 0) throw new Error("Out of stock")Only one update succeeds because PostgreSQL row‑level locking prevents concurrent decrements. The transaction also checks for existing active reservations to avoid double‑booking for the same user.
A background job (setInterval every 10 seconds) runs raw SQL:
SELECT id, "dropId" FROM "Reservation"
WHERE status = 'active' AND "expiresAt" < NOW()
FOR UPDATE SKIP LOCKED
LIMIT 100It processes expired reservations in batches, marks them expired, and increments availableStock for each affected drop. The SKIP LOCKED clause makes the job safe when multiple server instances run concurrently.
- Server emits
realtime-drop:inventoryafter any stock change (reserve, purchase, expiry). - Frontend socket listener calls
queryClient.setQueryDatato update the cached drop list. - All connected clients receive the event and update their UI instantly – no page refresh needed.
/
├── back-end/ # Node + Express + Prisma + Socket.io
├── front-end/ # React + TanStack + Socket.io client
└── README.md
Each folder contains its own README with detailed setup instructions.
cd back-end
npm install
cp .env.example .env # edit DATABASE_URL, FRONTEND_URL
npx prisma migrate dev --name init
npm run devBackend runs on http://localhost:4000.
cd front-end
npm install
echo "VITE_API_URL=http://localhost:4000" > .env
npm run devFrontend runs on http://localhost:5173.
Use static bearer token: ddecbc49-e121-48bc-829c-eef4f4eb186b
Example create drop:
curl -X POST http://localhost:4000/admin-drops/create-drop \
-H "Authorization: Bearer ddecbc49-e121-48bc-829c-eef4f4eb186b" \
-H "Content-Type: application/json" \
-d '{"name":"AJ1","price":17000,"stock":100}'See back-end/README.md for all admin endpoints.
- Expiration job runs inside the main server process – would need a separate worker for production scale.
- Admin token is hardcoded – should be environment‑managed.
- No database read replicas – all queries hit primary.
- Price stored as float – real apps should use integer cents.
- Backend: Node.js, Express, Prisma (PostgreSQL), Socket.io, Zod
- Frontend: React, TypeScript, TanStack Query, TanStack Router, Socket.io client, Tailwind CSS, shadcn/ui
- Deployment: Backend via Docker (see Dockerfile), frontend on Vercel
I don't know 🙂