A smart package locker system, built for the Everest Engineering coding challenge. Three people use it:
- an agent stores a parcel — the system picks the smallest locker it fits in and prints a one-time code;
- a customer walks up to the kiosk, types the locker label and that code, pays, and the locker opens;
- an admin manages stations, lockers and pricing, and watches the dashboard.
Go backend · React SPA · PostgreSQL 17 · docker compose.
▶ Live demo — https://elms-demo.sp3ck.com
Seeded with the data below; the logins are admin / admin123 and
agent / agent123. It scales to zero, so the first request after an idle
spell takes a second or two. Anyone can reach it and it is shared state — treat
it as a sandbox, and run it locally if you want a database to yourself.
Start here → Run it · Seed data · Scenarios · Level 4 concurrency proof
Design docs live in docs/: the PRD for what is
being built and why, the technical design for how.
docker compose up --build # or: make up| SPA | http://localhost:8000 |
| API | http://localhost:8080/api/v1 |
| Health | http://localhost:8080/api/v1/healthz |
The API migrates on boot and seeds the demo data on a fresh volume, so the stack
is usable the moment it is up. make down stops it and drops the volume —
that is how you get back to a clean database.
make dev # Postgres in Docker + Go API on :8080 + Vite on :5173
make seed # run once — `make dev` does not seed, only compose doesThen use http://localhost:5173 and nothing else: Vite proxies /api through to
the Go server, and same-origin is what makes the session cookie work.
Copy
.env.exampleto.envto change ports or credentials. Every value in it is already the built-in default, so.envis optional.
| Admin login | admin / admin123 |
| Agent login | agent / agent123 |
| Station | Central Station, 1 Market Street |
| Lockers | 18 total — S-01…S-06 Small 300 × 300 × 450 · M-01…M-06 Medium 450 × 400 × 600 · L-01…L-04 Large 600 × 600 × 900 · T-01…T-02 Tower XL 400 × 400 × 1500 (interior, mm) |
| Pricing | X = 10.00 USD per day |
Seeding is idempotent: it never overwrites an edited rate or a locker you took
out of service, so it is safe on every boot. Override the demo passwords with
SEED_ADMIN_PASSWORD / SEED_AGENT_PASSWORD.
| Route | Who can open it | What it does |
|---|---|---|
/ |
anyone — public | Kiosk. Pick station → label + code → pay → locker opens |
/agent |
agent or admin | Store. Measure-first form with a live fit-pass verdict, one-time code |
/admin |
admin only | Administration. Dashboard, stations & lockers, pricing |
The kiosk is deliberately unauthenticated — the locker label plus the 6-digit
code is the credential, backed by a 5-attempt / 15-minute block. /agent and
/admin redirect to /login; an agent calling an admin endpoint gets a 403.
Six things worth trying, in the order that makes the system explain itself.
- Log in at
/loginasagent/agent123→ you land on/agent. - Pick Central Station. It is remembered, so this is asked once.
- Under New package, pick a box template card — or Custom and type three millimetre measurements. You never choose a locker: a fit pass appears underneath and stamps the station's verdict — Perfect fit, Oversize cell, Station full or Too big — naming the cell the package would land in, its tightest clearance, and how many such cells are free.
- Customer name and contact are both required — a package with nobody attached cannot be found again → Store package.
- The docket freezes and is read back to you under Confirm before storing. Nothing is claimed yet: Cancel costs nothing, and smallest-fit only runs when you press Confirm & store — so a cell taken while the pass was being read lands the package one size up rather than failing.
- The dialog then shows the locker label and the 6-digit code once.
⚠️ There is no second chance — only a bcrypt hash is kept. Write both down. (In production the code goes to the customer directly by SMS or email; the agent sees it here only because the demo has no delivery channel.) - Open
/in a new tab — this is the kiosk. Choose Central Station, type the label and the code. - The quote shows the tier breakdown and the total. Pay and open the locker → receipt, the locker is free again.
Now re-enter the same code. It returns the same generic "could not match" as a wrong code: the package is retrieved, the code is dead.
The seeded estate is 6 Small (300 × 300 × 450), 6 Medium (450 × 400 × 600), 4 Large (600 × 600 × 900) and 2 Tower XL (400 × 400 × 1500), interior. A package needs 20 mm of clearance on every side, and may be turned any way up — the fit test sorts both triples and compares them, so which number you typed first cannot change the answer.
| Do this | You get |
|---|---|
Store a Shoebox with S-01…S-06 free |
the lowest free S- locker |
| Fill all six Small cells, store another Shoebox | M-01 — the smallest cell it fits in |
| Fill the six Medium cells, store a Medium parcel | T-01 — a Tower XL is 240 L, a Large is 324 L. Smallest-fit is a claim about litres, not about names |
Custom 1400 × 100 × 100 — a curtain pole |
only the towers take it. A three-word size enum would have called this "large" and handed it a 900 mm locker it cannot enter |
Custom 2000 × 200 × 200 — a ski box |
409, and the form says what the largest free cell is, so the answer is "bring something this size" rather than "no" |
| Fill every locker, store anything | 409 · No free locker at this station fits that package |
Note the last one: a full station is a clean, immediate refusal. Never a queue, never a wait.
Charging runs per started 24-hour period from stored_at — so backdating that
one column is the entire demo:
docker exec elms-postgres-1 psql -U elms -d elms -c \
"UPDATE packages SET stored_at = now() - interval '11 days 12 hours'
WHERE status='stored' AND locker_id=(SELECT id FROM lockers WHERE label='M-03');"Order matters: store → backdate → quote. A re-quote returns the payment that is already pending, at its original amount.
The kiosk now shows twelve billable days:
| Days | Rate/day | Amount |
|---|---|---|
| 1–5 | 10.00 | 50.00 |
| 6–10 | 20.00 | 100.00 |
| 11–12 | 30.00 | 60.00 |
| Total | 210.00 USD |
Exactly 24 hours is one day; a second more is two. Money is int64 minor units
end to end — no floats anywhere.
At the kiosk, enter a valid label with a wrong code:
| Attempt | Response |
|---|---|
| 1–4 | 404 — the same generic failure whether the label or the code was wrong |
| 5 | 429 with Retry-After: 900 — blocked for 15 minutes |
The API never says which half you got wrong, so a locker bank cannot be enumerated. A successful pickup resets the counter.
A quote stands for 15 minutes. Leave the kiosk sitting on one past that and paying fails with quote expired; re-quoting prices it at the current elapsed time and the current rate.
Log in as admin / admin123 → /admin.
- Dashboard — locker counts per station (available / occupied / out of service), every package in residence with its dwell time, and revenue accrued but not yet collected.
- Stations & lockers — create stations and lockers (pick a cell type; the
label proposes itself as
T-03and stays editable); send a locker out for maintenance (refused while it holds a package); delete only what is empty. - Catalogs — the two reference lists everything else is measured against: cell types and box templates, both ordered by volume. Deleting one anything refers to is refused; a template with history is retired instead, which takes it out of the agent's form and leaves old receipts readable.
- Pricing — change X. New quotes use it immediately; payments already quoted keep the price they were given.
Copy-pasteable end to end — it stores a package and collects it again, so it
leaves the estate exactly as it found it. Point API at the live demo to run it
without cloning anything.
API=http://localhost:8080/api/v1 # or https://elms-demo.sp3ck.com/api/v1
# 1. agent session
curl -sc jar -X POST $API/auth/login \
-H 'content-type: application/json' \
-d '{"username":"agent","password":"agent123"}'
# 2. stations are public — the kiosk needs them with no account
STATION=$(curl -s $API/stations | jq -r '.[0].id')
# 3. store → returns locker_label and pickup_code, once.
# Both customer fields are required: a package with nobody attached
# cannot be found again.
STORED=$(curl -sb jar -X POST $API/packages \
-H 'content-type: application/json' \
-d "{\"station_id\":\"$STATION\",\"dimensions\":{\"length_mm\":330,\"width_mm\":250,\"height_mm\":180},\"customer_name\":\"Ada\",\"customer_contact\":\"ada@example.com\"}")
LABEL=$(jq -r .locker_label <<<"$STORED")
CODE=$(jq -r .pickup_code <<<"$STORED") # the only time it exists in readable form
# 4. quote → payment_id + tier breakdown (no session: this is the kiosk)
PAYMENT=$(curl -s -X POST $API/pickup/quotes \
-H 'content-type: application/json' \
-d "{\"station_id\":\"$STATION\",\"locker_label\":\"$LABEL\",\"pickup_code\":\"$CODE\"}" \
| jq -r .payment_id)
# 5. pay → locker_opened: true, and the locker is free again
curl -s -X POST $API/pickup/payments/$PAYMENT/confirm | jq
# 6. the code is dead now — this is the same generic 404 a wrong code gets
curl -s -o /dev/null -w '%{http_code}\n' -X POST $API/pickup/quotes \
-H 'content-type: application/json' \
-d "{\"station_id\":\"$STATION\",\"locker_label\":\"$LABEL\",\"pickup_code\":\"$CODE\"}"Every failure is an RFC 7807 application/problem+json document with a stable
type URI and the request id.
The full contract is server/api/openapi.yaml — the
source of truth that generates both the Go server interface and the TypeScript
client, so the SPA and the API cannot drift apart.
Fifty agents press Store in the same millisecond. Ten lockers fit the package. Here is what has to happen:
Ten get a locker, forty are told the station is full. No one waits in line, and no two are handed the same locker.
The last clause is the hard one. The obvious code — find a free locker, then take it — leaves a gap between the finding and the taking, and in that gap another request finds the same locker. Two packages, one door, and the loser finds out when a stranger opens it.
Two agents, one station, the same instant. Read across:
| Agent A | Agent B | |
|---|---|---|
t₁ |
BEGIN, scan for the smallest free locker → M-01, lock that row |
|
t₂ |
BEGIN, scan → sees M-01 is taken, steps straight over it → M-02 |
|
t₃ |
insert package, M-01 → occupied |
insert package, M-02 → occupied |
t₄ |
COMMIT |
COMMIT |
t₂ is the whole trick, and it is two words of SQL:
- without
FOR UPDATE, B reads M-01 as free and takes it too → two packages, one locker; - without
SKIP LOCKED, B stops dead until A commits → correct, but the fiftieth agent waits behind forty-nine others.
Both words together: B never sees a row someone else is mid-claim on, and never waits for one.
1. The claim — ClaimSmallestFittingLocker,
server/db/queries/lockers.sql:
SELECT ... FROM lockers l
JOIN locker_types t ON t.id = l.locker_type_id
WHERE l.station_id = $1 AND l.status = 'available'
AND t.dim_max_mm >= $2 AND t.dim_mid_mm >= $3 AND t.dim_min_mm >= $4
ORDER BY t.volume_mm3, l.label
FOR UPDATE OF l SKIP LOCKED
LIMIT 1OF l is load-bearing rather than stylistic: a plain FOR UPDATE on this join
would lock the matched locker_types row too, and every locker of a type shares
one — so under SKIP LOCKED a second transaction would skip every locker of
that type because one of them is mid-claim.
That claim, the package insert and the flip to occupied are one
transaction — all of it becomes visible at once or none of it does. No row
back means no locker fits: ErrNoLockerAvailable → HTTP 409, immediately.
Pickup is the mirror image, and a locker being freed is invisible to the scan of
that instant and available to the next one.
2. The backstop — a partial unique index, packages(locker_id) WHERE status = 'stored' (docs/erd.md). The database itself refuses a
second live package in one locker, so even a future code path that forgets to
claim properly fails loudly instead of corrupting quietly.
| Test | What it pins |
|---|---|
TestStorePackageUnderConcurrency |
The headline: 50 goroutines, 10 suitable lockers → 10 winners in 10 distinct lockers, 40 clean rejections |
TestStoreAndPickupInterleaved |
Sustained churn — 20 workers × 3 store→quote→pickup cycles over 10 lockers, so stores and pickups are always in flight against the same rows |
TestFreedLockerIsInvisibleUntilThePickupCommits |
A half-freed locker is nobody's: hold a pickup open at the instant before commit, and a store is told full rather than handed the locker early |
TestClaimSkipsRowsLockedByAnotherTransaction |
t₂ itself — the second claimer takes a different locker instead of blocking, and fails fast when nothing is left |
TestOneStoredPackagePerLocker |
The backstop: a second stored package in one locker is a unique violation |
Two details these tests are careful about, because they are where a fake proof
would pass. The final state is read straight from the database, not from what
the service returned — a double-assignment is exactly the bug a service reports
as two happy answers. And every wait is bounded, because under SKIP LOCKED a
request that blocks at all has already lost the property being proved.
make test-int # needs Docker; ~10 minutesThe whole server/ suite under the race detector against a real PostgreSQL 17
(testcontainers-go). CI runs the same command on every push and pull request
(.github/workflows/ci.yml), so this is a gate, not
a one-off screenshot.
A test that cannot fail proves nothing, so the mechanism was deleted to watch it break:
| Ablation | Result |
|---|---|
Remove FOR UPDATE SKIP LOCKED entirely |
Both concurrency tests die on packages_active_locker_uniq within seconds — the race is real, and the backstop index earns its place |
Remove only SKIP LOCKED, keep FOR UPDATE |
Still passes, and should: under READ COMMITTED the blocked scan re-reads the row after the lock lifts and moves on. Correct — merely serialised |
That second row is why SKIP LOCKED has a test of its own. Correctness is what
FOR UPDATE buys; SKIP LOCKED buys the absence of the queue, and only a test
that watches for a wait can tell those apart.
| Command | Purpose |
|---|---|
make dev |
Postgres + Go API + Vite, hot reload |
make seed |
Load the demo data (idempotent) |
make gen |
Regenerate sqlc queries, Go server and TS client |
make test |
Go unit tests |
make test-web |
Frontend unit tests (Vitest) |
make test-int |
Whole server suite under -race, integration included (needs Docker) |
make e2e |
Playwright suite — not yet built (T-70/T-71); the target says so and exits clean |
make lint |
go vet, golangci-lint, tsc --noEmit, eslint |
make up / make down |
Compose stack up / down with volumes dropped |
Two rules worth knowing before editing:
- Generated code is committed and never hand-edited. Change
server/api/openapi.yamlorserver/db/queries/*.sql, then runmake gen. - Layering is hexagonal, enforced by import direction:
domain(pure rules, no I/O) ←app(services, transaction boundaries) ←adapters(pgx, chi). Never the reverse.
Progress is tracked task by task in docs/tasks.md.