Case study 03
FreightSwipe
A two-sided load-matching platform for truckers and shippers, deployed and running against a live database. This is the project I could only have designed because I did the job it replaces.
The landing page has Explore as a shipper and Explore as a trucker buttons that sign you straight into a seeded demo account. Open two browsers, take one side in each, and you can run a load end to end: post it, swipe on it, accept, both confirm pickup, deliver, review.
Where this came from
In the summer of 2022, partway through my degree, I did dispatch data entry at Fortel Express, a trucking company. A load would arrive as a phone call, get written on paper, get keyed into one system, then get keyed again into billing by someone else. The two systems had no shared identifier for the same load. When they disagreed — and they disagreed constantly — a human had to work out which one was lying.
Every freight board I saw treated matching as the interesting problem and reconciliation as somebody else's. From the inside, it's the opposite: finding a load takes minutes, and arguing about what happened to it takes weeks.
The load is the entity, not the listing. One record, one identifier, one lifecycle, visible to both sides of the match from posting through delivery — so there is never a second version of the truth to reconcile against.
Architecture
The part worth reading the code for
A load moves PENDING → MATCHED → IN_TRANSIT → COMPLETED, with
CANCELLED off to the side. Two of those transitions are the whole design
argument.
A shipper accepting a match closes the load. When the shipper accepts one
trucker's swipe, that same request rejects every other pending swipe on the load and flips the
load itself to MATCHED, so the deck can't leave two truckers both believing
they have it. (Those three writes aren't wrapped in a transaction, which they should be. The load
is updated last, so a failure part-way through leaves an accepted match sitting against a
load still marked PENDING.)
Pickup needs both signatures. The load carries
shipperInTransitConfirmed and truckerInTransitConfirmed as
separate columns. Either side can set their own flag, and the status does not move until
both are true — one side confirming alone gets their flag persisted and the load handed back
with its status untouched. This is the disagreement I used to resolve by hand, made structurally impossible:
"when did it get picked up" has one answer, and it exists only once both parties said so.
Delivery is deliberately asymmetric — only the shipper can mark a load
COMPLETED, and only from IN_TRANSIT. Reviews unlock only on a
completed load, and a duplicate from the same reviewer is rejected server-side rather than by
the UI hiding the button. What that handler does not check is that the reviewer
was a party to the load — see the limitations below.
Decisions and what they cost
| Decision | Why | What it costs |
|---|---|---|
| One shared load record, not per-side copies | Removes the reconciliation work entirely. If there is only one row, the two sides cannot disagree about it. | Permissions get harder — both parties read the same row and need different write rights on different fields at different lifecycle stages. |
| Two-party confirmation on pickup | "In transit" has to mean the same thing to a trucker and a shipper, or the platform is worthless as a record of what happened. | A load can sit half-confirmed indefinitely — no timeout, no nudge, no way to force it through. The shipper's only exit is to cancel and eat the fee. The trucker has no exit at all, which is the wrong way round: they're the party with a truck committed to it. |
| Swipe deck instead of a filterable board | Freight boards are dense tables that assume you already know what you want. A trucker scanning for a return leg is making a fast yes/no call, not running a query. | The deck has no relevance logic — it hands back every open load the trucker hasn't already acted on, newest first. No lane, weight, date or rate scoring of any kind. That is fine at demo volume and useless at real volume, and it's the first thing I would build next. |
| Frontend and API on one origin, auth in an httpOnly cookie | Browsers won't send cookies on cross-site XHR unless they're marked SameSite=None; Secure. Serving the API from /api on the same domain keeps the cookie first-party — no SameSite=None, no CORS preflight, no second service to cold-start. The token is unreachable from JavaScript. |
The API is coupled to the frontend's deployment — they ship together whether or not both changed. And the JWT is stateless with a seven-day life: logout clears the cookie, but a token already captured stays valid until it expires. Revocation needs refresh-token rotation, which isn't built. |
| Prisma over raw SQL | Migrations and type safety mattered more than query control for a schema I was still discovering. | Less control over query plans, and the swipe query — which loads every open load and every match the trucker has made — is the first place that will hurt at volume. |
| Deploy as one Vercel project on serverless | Free to run, and the same app.js serves both the container (via Docker Compose locally) and the serverless function, so there is one set of routes to maintain rather than two. |
Migrations can't run through a transaction pooler, so the schema needs a second direct connection alongside the pooled runtime one — two URLs to keep straight, and a deploy that runs migrations against production as a build step. Cold starts are real, and the shared app.js has to stay careful about anything that assumes a long-lived process. |
- It's deployed and it works, but there are no real users. The database holds seeded demo data, not real freight. Everything the demo lets you do — auth, posting, the swipe deck, both dashboards, the full status machine, cancellation and reviews — is genuinely running against Postgres. None of it has carried an actual load.
- There is no matching algorithm. The deck returns every open load a trucker hasn't already swiped on, newest first. That's the whole of it — no lane, weight, date or rate scoring, no relevance ordering, nothing that narrows a real freight market down to what you'd actually haul. Calling it "matching" oversells it: the app binds two parties to one record, it just doesn't help you choose which.
- No unit, integration or component tests. Not one
*.test.jsin the repository — everything has been verified by hand. There is a committed Playwright smoke script that signs in as each demo role and walks 16 of the app's 18 routes, asserting no error boundary and no console errors. It performs no writes and isn't wired into CI, so the status machine and the role checks — where the actual risk lives — have no coverage at all. I wrote the strategy to close that inTESTING.mdbefore writing the tests, and the tests are still the gap. The section below is what happens when you skip them. - Authorization is enforced unevenly, and I found that out writing this page. The routes the demo actually exercises scope their data correctly — a trucker's board is their board, a shipper's loads are their loads. But that came from writing each handler carefully rather than from a middleware everything passes through, and auditing the file against this write-up turned up handlers where the check I assumed was there isn't: a couple that never verify the caller's role, and a review endpoint that confirms the load is delivered without confirming the reviewer had anything to do with it. One read endpoint returns more of the user record than it has any business returning. I'm fixing those rather than leaving them behind a paragraph that says the permission model is solid. The lesson is the boring one: per-handler checks look fine right up until you enumerate them, which is exactly the job an integration suite does and mine doesn't exist.
- The money is fake. Balances are a float column and the $5 cancellation fee decrements it inside a transaction — but nothing anywhere credits a balance, so the fee simply disappears. There is no payment processor, no escrow, no invoicing, which, given that billing is where I watched the reconciliation pain actually land, is a conspicuous hole.
- Two and a half roles, and no route guards. The page above describes a two-sided system; there's also a seeded
ADMINrole with a platform-wide read of every match and a dashboard that is still an empty placeholder. The React router has no auth gate on any path either — every dashboard route renders for anyone who types it, and what protects the data is the API refusing to fill it in. - No outside integrations. No load boards, no ELD telemetry, no rate APIs. Addresses come from Google Places autocomplete; everything else is typed in.
- What it demonstrates is domain modelling — a load lifecycle that makes a specific, expensive disagreement structurally impossible — running on infrastructure that stays up. The data model is the part I'd defend. The enforcement around it is a first draft, and I'd rather scope that accurately than dress it up.
What I'd tell you in an interview
The swipe deck is what people notice and the least interesting part — it's a list with no query behind it. Ask me about the two-party confirmation instead: two roles, one row, different write rights per field per lifecycle stage, and a pickup event that only exists when both sides have said so. That's the problem I actually spent my time on, and I understood it from the wrong end first — I was the person cleaning up when it went wrong.
Then ask me why I shipped it with no tests, because the two things are connected. I was
optimising for something deployed and demonstrable, and the cost showed up immediately:
auditing my own authorization to write this page honestly turned up gaps I'd have caught
on day one with the integration tier TESTING.md describes. I'd rather show you
that trade and what it cost me than a case study claiming I got it all right.
Stack
- React
- Node.js
- Express
- PostgreSQL
- Prisma ORM
- JWT
- Docker
- Vercel