AMSAhub — Freelance Services Marketplace

React.jsNode.jsExpressMongoDBStripeREST API· 3 min read

A marketplace for freelance services where the same platform serves two opposite intentions: someone selling a skill and someone buying one. Both sides browse the same listings, exchange messages on the same threads, and read the same ratings — but almost never want to see them the same way.

The problem & constraints

A two-sided marketplace's hard part isn't the CRUD, it's that buyer and seller need different answers from identical data, and that money is involved. Two constraints followed from that: the role a request acts under can never come from the client, and neither can the amount charged. A third came from the listing pages — a grid of service cards showing star ratings can't afford to aggregate a reviews collection on every render.

Approach

  • One User model carrying an isSeller flag, embedded in the JWT payload at login. getOrders then filters on the role read from the token — one endpoint, two correct answers, and no client-supplied role parameter to forge.
  • The JWT lives in an httpOnly cookie rather than localStorage, so browser-side script can't read it.
  • Stripe PaymentIntent created server-side, with the amount read from the stored listing record — the browser receives only the clientSecret and never states a price.
  • Ratings denormalized onto the listing as a running totalStars + starNumber pair, so a card renders its average from two integers it already has.
  • Conversations and messages modeled as separate collections, keeping the thread list cheap to load independently of the messages inside it.
client (React)httpOnly JWT cookieExpress APIrole read from JWTamount from listingMongoDBlistings · orders ·reviews · messagesStripePaymentIntent

Described in full:

  1. client (React) — httpOnly JWT cookie → Express API — role read from JWT — amount from listing
  2. Express API — role read from JWT — amount from listing → MongoDB — listings · orders · — reviews · messages
  3. Express API — role read from JWT — amount from listing → Stripe — PaymentIntent
One boundary, two decisions the client never makes: the role a request acts under is read from the JWT, and the amount charged is read from the stored listing — the browser only ever receives the clientSecret.

Trade-offs made

Decision

A denormalized rating on the listing over aggregating reviews at read time: two integers on the document a card already fetches, instead of a join per card on a grid — accepting that the pair must be updated transactionally with each new review, and that a deleted review needs explicit correction rather than simply disappearing from a recount.

Decision

Role carried in the token rather than re-read from the database per request: removes a lookup from every authenticated call, at the cost of the role being fixed for the token's lifetime — a seller who changes status stays on the old view until the token is reissued.

The first of those two trade-offs carries its cost inside a subordinate clause: a deleted review has to be corrected for explicitly. Here is that pair, live — add reviews and it stays honest, delete one and watch it stop:

A service listing★ 4.08 (12)what a grid card renders
Stored on the listing documenttotalStars 49 · starNumber 124.08
Recounted from the reviews collection12 reviews4.08

The pair agrees with the collection — every insert updated both at once.

Add a review

Documents read to render that grid: 24 with the stored pair · 312 recounting per card

Demonstration of the data model, computed live in your browser — the same two-integer update the marketplace performs. Not real listings, not real reviews, and not measured query times.

Results

A working marketplace loop: publish a listing, message about it, pay through Stripe, and leave a review — with the authorization and pricing decisions made server-side rather than trusted to the client.

What I'd do differently

Move order creation to a Stripe webhook. As it stands the order row is written when the PaymentIntent is created, before payment confirms, so an abandoned checkout leaves a persisted order that only the isCompleted filter hides — the data is honest but the collection isn't clean, and the payment provider's own callback is what should be authoritative. I'd also lift the hardcoded usd currency into configuration.

AMINE EL MOUMEN · CV

Which field interests you?

Choose a focus and a language to get the most relevant version of my CV.

1. Choose a field
2. Choose a language

AI & Data Engineering · English · PDF