Skip to content
CRTOsp3ckPublic

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

ELMS — Electronic Locker Management System

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.


Run it

With Docker (recommended)

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.

For development, with hot reload

make dev      # Postgres in Docker + Go API on :8080 + Vite on :5173
make seed     # run once — `make dev` does not seed, only compose does

Then 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.example to .env to change ports or credentials. Every value in it is already the built-in default, so .env is optional.


Seed data

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.

The three areas

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.


Scenarios

Six things worth trying, in the order that makes the system explain itself.

1 · Store a package, then collect it

  1. Log in at /login as agent / agent123 → you land on /agent.
  2. Pick Central Station. It is remembered, so this is asked once.
  3. 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.
  4. Customer name and contact are both required — a package with nobody attached cannot be found again → Store package.
  5. 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.
  6. 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.)
  7. Open / in a new tab — this is the kiosk. Choose Central Station, type the label and the code.
  8. 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.

2 · Smallest fit — in millimetres, not in words

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.

3 · Tiered pricing, without waiting twelve days

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.

4 · Wrong code → cool-off

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.

5 · Quote expiry

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.

6 · Admin

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-03 and 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.

The same run, over the API

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.


Level 4 — the concurrency proof

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.

The gap, and how it closes

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.

Enforced twice, independently

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 1

OF 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.

What the tests pin down

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.

Running it

make test-int      # needs Docker; ~10 minutes

The 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.

Proof that it can fail

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.


Development

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:

  1. Generated code is committed and never hand-edited. Change server/api/openapi.yaml or server/db/queries/*.sql, then run make gen.
  2. 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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages