A secure REST API and real-time backend for Bread Bar, a food business with a customer ordering app and a staff management app. It handles accounts, menu, stock, orders, delivery areas, promotions, payments, reviews, loyalty points, reports and live order updates.
Built as part of a Work-Integrated Learning (WIL) project (INSY7315) at The IIE's Varsity College, where student teams build software for a real client.
Our team Alpha Devs (Wavhudi, Kim, Karabo, Tshego and me) split Task 2 by area. I owned the back end and the database:
- Designed the MongoDB data model (11 Mongoose models) and the REST API (10 route groups)
- Implemented authentication and security:
- two isolated JWT systems for customers and staff
- refresh-token rotation with reuse detection
- bcrypt hashing, account lockout and rate limiting
- RBAC with a permission map, Zod validation and audit logging
- Built the order workflow:
- a state machine with legal transitions only
- server-side pricing in cents and promotion maths
- all-or-nothing stock deduction per recipe, plus loyalty points
- Added real-time updates with Socket.IO rooms per role (kitchen, customer, driver, managers)
- Wrote 39 automated tests (unit, HTTP security tests with Supertest, and integration tests on an in-memory MongoDB)
The front-end apps and the CI/CD pipeline were built by teammates. The pages in public/ are a small sign-in demo for testing the API.
- Security mapped to requirements: each non-functional security requirement from the design phase maps to its implementation (see the table in section 4).
- The client can't tamper with prices: totals are always recalculated on the server from the menu, promotions and delivery fees.
- Customer data isolation: a customer requesting another customer's order gets a
404, and the customer ID always comes from the token. - Clean layering: routes → controllers → services → models, with a central error handler that never leaks stack traces.
| Customer Mobile App | Business Management App (owner & staff) | |
|---|---|---|
| Base URL | /api/customer/auth |
/api/staff/auth |
| Database collection | customers |
staffs (completely separate) |
| Register with | name, email, password (phone optional) | invite code + name, email, password, 6-digit PIN |
| Log in with | email + password | email + password + staff PIN |
| JWT signed with | JWT_CUSTOMER_SECRET, audience breadbar-customer-app |
JWT_STAFF_SECRET, audience breadbar-business-app |
| Session length | 15-min access token, 30-day refresh token | 15-min access token, 12-hour refresh token (one shift) |
Because the two token types use different secrets and audiences, a customer token is rejected by every staff route, even if someone edits the role inside it.
Three secrets that only Bread Bar has:
- Business Setup Key (owner only). The owner account is created once with
POST /api/staff/auth/setup-owner, which needsBUSINESS_SETUP_KEYfrom the server's environment variables. A unique database index guarantees only one owner can ever exist. Rotate or remove the key after setup. - One-time invite code (every employee). The owner/manager generates a code like
BB-7KQ2-M9XD-4PWTfor a specific email and role, then hands it to the employee in person. The code works once, only with that email, expires in 48 hours, and only its SHA-256 hash is stored. The employee's role comes from the invite, never from the request. - Personal staff PIN (every login). Each staff member chooses a 6-digit PIN
(weak PINs like
123456or000000are refused). Login needs email + password + PIN, so a leaked password alone does not open the business app. The error never says which of the three was wrong.
Who may invite or deactivate whom:
| Actor | Can invite / deactivate |
|---|---|
| Owner | kitchen, delivery, manager, admin |
| System Admin | kitchen, delivery, manager |
| Manager | kitchen, delivery |
| Kitchen / Delivery | nobody |
Nobody can create or deactivate the owner. Deactivating staff logs them out of every device immediately.
Owner (once): POST /api/staff/auth/setup-owner { setupKey, name, email, password, pin }
Owner: POST /api/staff/invites { email: "thabo@...", role: "kitchen" }
→ returns inviteCode (shown ONCE) → give to Thabo in person
Thabo: POST /api/staff/auth/register { inviteCode, name, email, password, pin }
Thabo (daily): POST /api/staff/auth/login { email, password, pin }
Customer auth – /api/customer/auth
| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | /register |
public, rate-limited | Create customer account |
| POST | /login |
public, rate-limited | Log in |
| POST | /refresh |
refresh token | Get new tokens (rotation) |
| POST | /logout |
refresh token | End this session |
| POST | /logout-all |
customer JWT | End all sessions |
| GET | /me |
customer JWT | Profile + loyalty points |
Staff auth – /api/staff/auth: /setup-owner, /register, /login, /refresh, /logout, /logout-all, /me (same pattern).
Staff management – /api/staff (staff JWT required)
| Method | Path | Permission |
|---|---|---|
| POST | /invites |
staff:invite |
| GET | / |
staff:read |
| PATCH | /:id/deactivate |
staff:deactivate |
| GET | /audit-logs?page=&limit= |
audit:read |
Orders – /api/orders (examples of protected routes)
| Method | Path | Who |
|---|---|---|
| POST | / |
Customer – prices & totals calculated on the server |
| GET | /mine |
Customer – own orders only |
| GET | / |
Kitchen, manager, owner |
| GET | /deliveries/assigned |
Delivery staff – their deliveries only |
| GET | /:id |
Customer (own order only) or staff with access |
| PATCH | /:id/status |
Staff – each transition needs its own permission |
| POST | /:id/cancel |
Customer – only while the order is still Received. Stock is put back. |
Status changes follow the state diagram: Received → Preparing → Ready → OutForDelivery/Collected → Completed,
with Cancelled allowed from Received or Preparing. Illegal jumps return 409.
A customer cannot cancel once the kitchen has moved the order to Preparing.
Cancelling from Received returns the ingredients. Cancelling from Preparing does not.
POST / body is { orderType, items: [{ menuItemId, quantity }], paymentMethod, paymentReference?, deliveryAddress? }.
paymentMethod is cash, card, or eft. Do not send prices, totals, or card numbers.
Cash is recorded as pay_on_collection. Card and EFT are pending, with an optional reference.
The server prices each line from the menu, applies the single best active promotion, then adds the delivery fee.
Loyalty is 1 point per R10 of totalAmount, added once when the status becomes Completed.
Menu – /api/menu
| Method | Path | Who |
|---|---|---|
| GET | /?category=&q= |
Public – available items only |
| GET | /manage |
menu:manage – includes hidden items and recipes |
| POST | / |
menu:manage |
| PATCH | /:id |
menu:manage |
| PATCH | /:id/availability |
menu:manage – body { isAvailable } |
| DELETE | /:id |
menu:manage – deletes the item, or marks it unavailable if an order already uses it |
Stock – /api/stock (stock:manage)
| Method | Path | Purpose |
|---|---|---|
| GET | / |
List items. Each row includes isLow when quantity is at or below the reorder level. |
| POST | / |
Create { name, unit, quantityOnHand, reorderLevel } |
| PATCH | /:id |
Update a stock item |
Placing an order deducts each menu item's recipe. If any ingredient is short, nothing is deducted and the API returns 409 INSUFFICIENT_STOCK.
Delivery areas – /api/delivery-areas
| Method | Path | Who |
|---|---|---|
| GET | / |
Public – active suburbs and fees |
| GET | /manage |
delivery_areas:manage |
| POST | / |
delivery_areas:manage – { suburb, fee } |
| PATCH | /:id |
delivery_areas:manage |
| DELETE | /:id |
delivery_areas:manage – hides the suburb (isActive: false) |
Promotions – /api/promotions
| Method | Path | Who |
|---|---|---|
| GET | /active |
Public – promotions that are active right now |
| GET | / |
promotions:manage |
| POST | / |
promotions:manage – discountType is percent or fixed |
| PATCH | /:id |
promotions:manage |
| DELETE | /:id |
promotions:manage – sets isActive to false |
Reviews – /api/reviews
| Method | Path | Who |
|---|---|---|
| GET | / |
Public – latest 20 reviews (name, rating, comment) |
| POST | / |
Customer – { orderId, rating, comment? } and only for their own completed order |
Reports – /api/reports (reports:read)
| Method | Path | Purpose |
|---|---|---|
| GET | /dashboard |
Today's completed-order count, sales total, and best sellers |
| GET | /sales?period=daily|weekly|monthly |
Completed sales grouped by day (today, last 7 days, or last 30 days) |
Cancelled orders are excluded. Days use the Africa/Johannesburg timezone.
Live updates (Socket.IO)
Connect with the access token: io(url, { auth: { token } }).
Rooms are assigned from the role on the account: kitchen and managers join kitchen, each customer joins customer:<id>, each driver joins delivery:<id>, and managers join managers.
| Event | Who receives it |
|---|---|
order:created |
Kitchen |
order:updated |
Kitchen, that customer, and the assigned driver |
stock:low |
Managers |
menu:updated |
Every connected client (menu, promotion, or delivery-area change) |
Error format for every failure:
{ "error": { "code": "VALIDATION_ERROR", "message": "Please check the highlighted fields",
"details": [{ "field": "pin", "message": "PIN must be exactly 6 digits" }] } }| NFR | Implementation |
|---|---|
| NFR-S01 Authentication | bcrypt (12 rounds) for passwords and PINs; short-lived JWT access tokens; opaque refresh tokens stored as hashes with rotation and stolen-token (reuse) detection; account lockout after 5 failures for 15 min; equal-time responses for unknown emails |
| NFR-S02 RBAC | config/roles.js permission map; requirePermission() middleware; role read from the database on every request, not trusted from the token |
| NFR-S03 Data in transit | Helmet HSTS (1 year); TLS terminated at the AWS load balancer; Cache-Control: no-store |
| NFR-S04 Input validation | Zod schemas on every body/params/query; .strict() rejects unknown fields (blocks role: "owner" mass assignment); NoSQL-operator and prototype-pollution stripping; 10 kb body limit |
| NFR-S05 XSS | Strict input formats (names, IDs, codes); security headers via Helmet; React escaping on the frontend |
| NFR-S06 Customer data isolation | Ownership checks – another customer's order returns 404; customerId always comes from the token |
| NFR-S07 / NFR-DI01 Payment integrity | The client cannot send prices. Totals are calculated in cents from menu prices, the best active promotion, and the delivery-area fee. |
| NFR-R03 Error handling | Central handler; no stack traces or DB messages leave the server |
| NFR-AM01 Audit | Owner setup, staff registration, logins (incl. failures), invites and deactivations are logged with IP and user agent |
| Brute force / API abuse | Rate limits: 300 req/15 min general, 10 login attempts/15 min, 5 setup attempts/hour |
Sensitive fields (passwordHash, pinHash, tokenVersion, lockout counters) are
select: false and also stripped by toJSON, so they can't leak in responses.
- Customer app (React Native): store the refresh token in
expo-secure-store(Keychain / Keystore), never in AsyncStorage. Keep the access token in memory. - Business dashboard (React web): keep the access token in memory only. When any
request returns
TOKEN_EXPIRED, call/refreshonce and retry. - On
SESSION_REVOKEDorINVALID_REFRESH_TOKEN, send the user back to the login screen.
npm install
cp .env.example .env # fill in the values (see the comments in the file)
npm run dev # starts on http://localhost:5000
npm run seed # optional sample menu, stock, Eden Park delivery, and a promotion
npm test # security tests plus shop tests (in-memory MongoDB, no Atlas)src/
config/ env.js, db.js, roles.js (RBAC), delivery.js (fees from the database)
models/ Customer, Staff, StaffInvite, RefreshToken, AuditLog, MenuItem, Order, StockItem, DeliveryArea, Promotion, Review
middleware/ authenticate, authorize, validate, sanitize, rateLimiters, errorHandler
validators/ schemas.js (Zod)
payments/ cash, card, and EFT strategies
services/ customerAuth, staffAuth, staffManagement, session, order, orderWorkflow, menu, stock, promotion, review, report, orderEvents
controllers/ thin HTTP handlers
routes/ customerAuth, staffAuth, staffManagement, order, menu, stock, delivery areas, promotions, reviews, reports
realtime/ Socket.IO gateway
utils/ tokens (JWT), password (bcrypt), crypto, lockout, audit, money, loyalty, AppError
scripts/ seed.js
tests/ unit + HTTP security tests, plus shop integration tests