Express + Postgres backend for online ordering at chinatwang.no, with an ESC/POS receipt printer in the restaurant.
Built to be read and extended by you — every file explains why it exists, not
just what it does. Start with docs/ARCHITECTURE.md, then src/routes.js.
npm install
cp .env.example .env # then fill in DATABASE_URL + the two tokens
npm run migrate # create the tables
npm run seed # a few PLACEHOLDER menu rows
npm run dev # http://localhost:3000Generate the tokens:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"Verify without a database:
node scripts/selftest.js # 31 checks: validation, money, receipt rendering
node scripts/routes.js # boots the app, lists all 22 routesroutes.js is the quickest smoke test — it imports every module, so a broken
import or typo'd export fails there instead of at request time.
src/
├── server.js start/stop the HTTP server
├── app.js middleware order (read this second)
├── routes.js THE API MAP — read this first
├── config.js every env var, read in one place
│
├── middleware/
│ ├── validate.js ★ the Zod middleware you asked for
│ ├── auth.js bearer tokens for printer + staff
│ ├── errorHandler.js one place that shapes every error response
│ ├── asyncHandler.js makes async routes throw correctly
│ └── requestId.js per-request id + access log
│
├── schemas/
│ ├── common.js ★ reusable cleaners: phone, email, text, ints
│ └── order.js the order request shapes
│
├── modules/ one folder per feature
│ ├── menu/ simplest module — read this to learn the pattern
│ ├── orders/ routes → service → repository → presenter
│ ├── printer/ queue/ack API + ticket text builder
│ └── bookings/ STUB, deliberately — rules are yours
│
├── db/pool.js connection pool + withTransaction()
└── lib/ AppError, logger, money
Each module is the same four files:
| File | Job | Rule |
|---|---|---|
*.routes.js |
HTTP in/out | no business logic, no SQL |
*.service.js |
business rules | no SQL, no req/res |
*.repository.js |
SQL | no business rules |
*.presenter.js |
DB row → JSON | the public API boundary |
- Schema — add to
src/schemas/, built fromcommon.jscleaners - Service — the actual rule, in
*.service.js - Repository — SQL, if the DB is involved
- Route — wire it up:
router.post('/thing',
validate({ body: myThingSchema }), // cleans + rejects bad input
asyncHandler(async (req, res) => {
const result = await service.doThing(req.valid.body);
res.status(201).json({ data: result });
})
);Always read req.valid.body, never req.body. The first is cleaned and
type-guaranteed; the second is raw internet input. Different names so you can't
mix them up by accident.
22 routes. node scripts/routes.js prints the live list any time.
Public (no token)
| Method | Path | Notes |
|---|---|---|
| GET | /health |
pings Postgres too |
| GET | /api/menu |
categories + available dishes |
| POST | /api/orders |
place an order |
| GET | /api/orders/:reference |
track by reference |
| POST | /api/orders/:reference/cancel |
only before the kitchen starts |
| POST | /api/bookings |
501 — not implemented |
Staff (Authorization: Bearer <ADMIN_TOKEN>)
| Method | Path | Notes |
|---|---|---|
| GET/POST | /api/menu/categories |
list / create |
| GET/PATCH/DELETE | /api/menu/categories/:id |
delete refuses if dishes remain |
| GET/POST | /api/menu/items |
list (filterable) / create |
| GET/PATCH/DELETE | /api/menu/items/:id |
delete refuses if ever ordered |
| PATCH | /api/menu/items/:id/availability |
one-tap "sold out tonight" |
| GET | /api/orders |
kitchen list, filter by status |
| PATCH | /api/orders/:reference/status |
enforces legal transitions |
Printer bridge (Authorization: Bearer <PRINTER_TOKEN>)
| Method | Path | Notes |
|---|---|---|
| GET | /api/printer/queue |
claim up to limit unprinted tickets (default 5, max 20) |
| GET | /api/printer/status |
queue health — watch oldestUnprintedAgeSeconds |
| POST | /api/printer/:ref/ack |
paper came out; stop re-sending |
| POST | /api/printer/:ref/nack |
printing failed; retry on next poll |
Claims expire after PRINT_CLAIM_TIMEOUT_SECONDS (120s). A bridge that dies
mid-print never acks or nacks, so without expiry its order would sit unprinted
forever while the queue looked empty. Retrying risks a duplicate ticket; not
retrying risks a customer who paid and got no food.
Responses are always { "data": ... } or { "error": { code, message, details } }.
DELETE on a dish that appears in past orders returns 409, because
order_items references it with ON DELETE RESTRICT. Same for a category that
still holds dishes. Use isAvailable: false to hide something — that is almost
always what was actually wanted, and it keeps your order history and accounting
intact. Cancelling an order likewise sets status = 'cancelled' rather than
deleting the row.
1. The client never sends prices. createOrderSchema has no price field. The
server looks every price up in the database and computes the total itself. Accept a
price from the browser and a customer can order dinner for 1 øre.
2. Money is integer øre, everywhere. 149,50 kr is 14950. Floats drift
(0.1 + 0.2 !== 0.3); over a 30-item order that becomes a real mismatch against
your accounting. See src/lib/money.js.
- Booking logic — opening hours, table count, sitting length and
double-booking policy are business decisions. Schema, validation and route are
ready; implement
booking.service.js. - Payment — no provider chosen. Vipps/Stripe slots in as its own module.
- The real menu —
chinatwang.no/menu.htmlhardcodes dishes in HTML with no machine-readable prices. The seed is clearly-labelled placeholders rather than guesses at your parents' prices. - The printer bridge — runs in the restaurant, not on the server. Starting
script in
docs/PRINTER_BRIDGE.md. - Accounting — see the note at the bottom of
src/routes.js. The data model already stores net/VAT/total and per-line snapshots, which is what Tripletex needs.
node --check passes on every source file. scripts/selftest.js: 31/31.
scripts/routes.js: all 30 routes register, every module imports cleanly.
Live HTTP checks against a running server: per-field 422 validation errors,
401 on missing/wrong tokens for all staff and printer routes, 403 on a
disallowed CORS origin, 404 unknown route, 501 bookings stub, helmet
security headers and X-Request-Id present, and /health correctly reporting
503 while Postgres is unreachable.
Not yet run against your Postgres. The SQL and migrations are unexercised —
no instance was reachable from this machine. Point DATABASE_URL at your Pi and
run npm run migrate; that is the first thing to try, and the one part of this
I cannot vouch for from testing.