02 / 02PROJECT

ServiceHub

A three-sided home-services marketplace — customer booking site, admin operations panel, and vendor mobile app — with geospatial vendor matching, dual-path payment verification, and background job processing.

ServiceHub is a home-services marketplace, in the shape of Urban Company — customers browse and book services like AC repair, cleaning, and salon visits; admins manage the catalog, assign bookings to vendors, and watch operations live; vendors (on a separate mobile app) complete a KYC gate before receiving job assignments. Three frontends — a React customer site, a React admin panel, and a React Native vendor app — share one Node.js/MongoDB backend.

NODE.JSMONGODBREACTREACT NATIVERAZORPAYBULLMQ
ServiceHub preview
ROLE
Solo Developer
TEAM
Solo Project
YEAR
2026
STATUS
IN DEVELOPMENT
01OVERVIEW
WHAT IS IT?

ServiceHub is a home-services marketplace, in the shape of Urban Company — customers browse and book services like AC repair, cleaning, and salon visits; admins manage the catalog, assign bookings to vendors, and watch operations live; vendors (on a separate mobile app) complete a KYC gate before receiving job assignments. Three frontends — a React customer site, a React admin panel, and a React Native vendor app — share one Node.js/MongoDB backend.

WHY DID I BUILD IT?

Built to go deep on the parts of marketplace engineering that don't show up in a typical CRUD project: a real catalog hierarchy with denormalization tradeoffs, a two-sided matching problem (customer demand, vendor supply), payment reliability under concurrent verification paths, and background-job architecture — the kind of system design that actually comes up in senior backend interviews.

WHAT MAKES IT INTERESTING?

The vendor-assignment engine is the most interesting piece: given a booking, it queries vendors within their own individually-configured service radius (not a single fixed search radius), filters by KYC and approval status, and produces a ranked, explainable recommendation — closest, least busy, highest rated — for the admin to act on today, structured so it can evolve into an Uber-style broadcast-and-first-to-accept model later without a schema rewrite.

02PROBLEM

Coordinating a home-service booking across three parties — a customer who wants a service done, an admin who needs live operational visibility, and a vendor who has to actually show up — breaks down fast without a system that assigns work well and stays consistent when things happen out of order or concurrently.

01

A naive admin panel that just lists every vendor gives no way to know who's actually near, available, and approved for that specific service

02

Payment confirmation can legitimately arrive twice (client callback and webhook racing) — naive handling double-fires side effects like emails

03

Slow downstream work (PDF invoices, emails, socket notifications) has no business blocking the response a customer is waiting on

04

A single flexible Order schema tries to represent two genuinely different fulfillment lifecycles (scheduled on-site service vs. shipped product) and ends up needing type-branching everywhere

03OBJECTIVE

Build a marketplace backend and three matching frontends where every state transition is safe under concurrency, every deactivation preserves historical data integrity, and admin operations feel like a real ops tool rather than a CRUD wrapper.

MODEL THE CATALOG SO CATEGORIES, SERVICES, AND ORDERS CAN CHANGE WITHOUT BREAKING PAST BOOKINGSMAKE PAYMENT CONFIRMATION SAFE EVEN WHEN TWO INDEPENDENT SIGNALS ARRIVE CONCURRENTLYGIVE ADMINS A RANKED, EXPLAINABLE VENDOR RECOMMENDATION INSTEAD OF A RAW LISTKEEP THE REQUEST PATH FAST BY PUSHING NON-CRITICAL WORK ONTO BACKGROUND QUEUES
04SOLUTION

A feature-folder Node.js backend serving three independent frontends, with a snapshot-on-write pattern protecting order history, an atomic status-guard pattern protecting payment state, and a queue layer separating what must happen before a response from what can happen after.

CATALOG

4-level hierarchy with server-derived denormalization, never client-trusted

TRANSACT

Cart splits into typed Order discriminators at checkout, snapshotting prices

CONFIRM

Atomic status guard reconciles whichever payment signal arrives first

FULFILL

Ranked vendor recommendation, real-time admin visibility, queued side effects

05USER FLOW
01DISCOVER

Browse via category navigation or search across categories and services

02CART

Add services from multiple categories to one cart, guest or logged in

03AUTH GATE

Login required at checkout, with the guest cart merged in on sign-in

04SCHEDULE & PAY

Pick an address and time slot, pay via Razorpay

05ADMIN ASSIGNS

Admin sees a ranked, eligible vendor list and confirms one

06VENDOR COMPLETES

Vendor (mobile app) moves the job through its real status stages

07TRACK

Customer sees order status and invoice from their account history

06FEATURES

Add feature screenshots — edit in project-data.ts

07ARCHITECTURE

One backend, three independent frontends. Feature-folder backend organization mirrors the catalog hierarchy; background jobs run as a separate worker process from the API so slow or unreliable work never blocks a request.

CUSTOMER
React (Vite)shadcn/uiTanStack QueryTanStack Router
ADMIN
ReactAnt Design + TailwindShared design-token theme (dark/light)
VENDOR
React Native (Expo)Expo RouterBackground geolocationSocket.IO client
SERVER
Node.js / ExpressFeature-folder architectureZod validationSocket.IO (admin namespace)
DATA
MongoDB / MongoosePolymorphic Address model (refPath)Discriminator-based Order schemas
JOBS & CACHE
BullMQRedisSeparate worker process from the API server
INTEGRATIONS
Razorpay (payments)Resend (email)Shiprocket (shipping, adapter-abstracted)
08CHALLENGES
CHALLENGE 01

A race condition between two valid payment confirmations

PROBLEM

Payment confirmation is deliberately verified two ways — an instant client-side callback for UX speed, and a server-to-server webhook as the source of truth. Both can independently try to mark a payment as paid, and a naive read-then-write status check let both pass the 'already handled?' check when they landed close together, risking duplicate side effects.

APPROACH
  • Replaced the read-then-write status check with a single atomic findOneAndUpdate guarded by status != 'paid'
  • Made only the call that actually wins the atomic update responsible for dispatching downstream effects
  • Kept a durable attempts log (with signature-validity per entry) so every delivery — valid or forged — stays auditable without ever being treated as a trusted duplicate
RESULT

Either confirmation path can now safely arrive first, arrive concurrently, or be redelivered by a retry, and the payment is guaranteed to transition exactly once.

CHALLENGE 02

Vendor matching with a per-vendor, not global, service radius

PROBLEM

MongoDB's geospatial queries take one global maxDistance, but each vendor has their own configured service radius — a straightforward $near query couldn't express 'find vendors close enough to THEIR OWN coverage area.'

APPROACH
  • Queried a generous outer radius via the existing 2dsphere index, sorted by distance
  • Filtered the results in application code against each candidate's own serviceRadius
  • Layered a simple, explainable weighted score (distance, current workload, rating) on top instead of a black-box ranking model
RESULT

Admins see a ranked candidate list with a clearly marked top pick, and the same eligibility data is structured to support an Uber-style broadcast/first-accept model later without touching the underlying schema.

CHALLENGE 03

Discovering two parallel, half-dead catalog implementations

PROBLEM

A full backend audit surfaced that the category system existed twice — an older self-referential single-collection model (with its own Joi validation and soft-delete pattern) and a newer FK-based, Zod-validated, denormalizing model. Only the newer one was actually mounted; the legacy one was still imported by other schemas by reference name.

APPROACH
  • Traced the live Express route tree to confirm exactly one system was reachable at runtime
  • Mapped every place the legacy model's name was still referenced before touching anything
  • Kept the active FK-based model as the system of record and treated the legacy code as flagged dead code rather than deleting it blind
RESULT

Confirmed a single source of truth for the catalog hierarchy, and avoided a break from removing code something else was silently still importing by name.

09ENGINEERING DECISIONS

Why keep both a client-side payment callback and a server webhook?

NEED — Fast confirmation UX for the customer, without trusting the customer's browser as the source of truth.

DECISION — Kept both paths, each independently signature-verified, reconciled by one atomic status guard.

WHY — The client callback gives instant feedback but could theoretically be spoofed if trusted alone; the webhook is authoritative but can lag. Verifying both cryptographically and letting either one complete the transition gets the speed without weakening the trust boundary.

Why Mongoose discriminators for ServiceOrder and ProductOrder instead of one flexible Order model?

NEED — Represent two fulfillment lifecycles — scheduled on-site service vs. shipped product — that share checkout mechanics but diverge completely in status flow and post-purchase logic.

DECISION — One base Order schema, two discriminated types extending it.

WHY — Forcing both into one schema meant every status check needed type-branching everywhere in the codebase; discriminators keep shared fields (payment, address snapshot) in one place while letting each type's status enum and fulfillment fields stay clean and independently valid.

Why BullMQ and a separate worker process instead of handling everything inline?

NEED — Keep the API responsive — especially the Razorpay webhook, which must return quickly or trigger Razorpay's own retry logic.

DECISION — Queued confirmation emails, invoice generation, and admin notifications; ran a dedicated worker process alongside the API server.

WHY — None of that downstream work should be able to block or fail a request the customer is actively waiting on, and separating the worker process means a spike in job volume can never degrade API response times.

Why a separate React Native app for vendors instead of folding it into the customer site?

NEED — A genuinely different user type — job queue, availability toggling, KYC, background location — with almost no UI overlap with the customer experience.

DECISION — Built service-hub-vendor as an independent Expo app with its own isolated auth.

WHY — Every major two-sided marketplace (Uber/Driver, DoorDash/Dasher) splits these for a reason — shared auth complexity, unrelated bundle weight, and deploy coupling all get worse the longer two unrelated user types share one codebase.

10TECH STACK
CUSTOMER FRONTEND
  • React (Vite)
  • TypeScript
  • shadcn/ui
  • Tailwind CSS
  • TanStack Query
  • TanStack Router
ADMIN FRONTEND
  • React
  • Ant Design
  • Tailwind CSS
  • Shared dark/light design tokens
VENDOR APP
  • React Native
  • Expo
  • Expo Router
  • Zustand
BACKEND
  • Node.js
  • Express
  • Zod
  • Feature-folder architecture
DATABASE
  • MongoDB
  • Mongoose
JOBS & REALTIME
  • BullMQ
  • Redis
  • Socket.IO
PAYMENTS & COMMS
  • Razorpay
  • Resend
  • Shiprocket (adapter-abstracted)
AUTH
  • JWT (access + refresh)
  • Isolated identity per app: customer, admin, vendor
11PERFORMANCE & SECURITY
PERFORMANCE

Add measured performance numbers — edit in project-data.ts

SECURITY
  • Every Order snapshots price, name, and address at time of purchase — later catalog or address changes never rewrite history
  • Deletes are blocked, not cascaded — a Category/Subcategory/ServiceGroup can't be removed while children still reference it
  • Payment webhook and client-callback paths are both signature-verified and reconciled through an atomic, idempotent status update
  • Polymorphic Address model uses Mongoose refPath rather than duplicating address logic per owner type
  • JWT identity is fully isolated per app — an admin token can't authenticate a customer or vendor route, and vice versa
  • Vendors are hard-gated behind a KYC approval state machine before reaching any job-assignment functionality
  • Auth endpoints (login, register, forgot-password) are rate-limited against brute-force and enumeration
12RESULTS
FUNCTIONAL END-TO-END FLOW FROM CATALOG BROWSING THROUGH PAYMENT TO ADMIN VENDOR ASSIGNMENTPAYMENT AND JOB-PROCESSING RELIABILITY PATTERNS (IDEMPOTENCY, ATOMIC GUARDS, QUEUE SEPARATION) BUILT IN FROM THE START, NOT RETROFITTED
13WHAT I LEARNED

Idempotency isn't one mechanism you add once — the same 'check a durable record before acting' principle had to be applied independently at the payment-webhook layer and again at the job-queue layer, since each has its own at-least-once delivery risk

Denormalization needs a clear rule for which direction data flows — snapshot-on-write (Orders) and live-reference (a future Membership) are both correct, but mixing up which pattern applies where causes real bugs

A two-sided marketplace's 'who can do this job' question is a data-modeling problem before it's a matching-algorithm problem — the geospatial and eligibility fields had to exist correctly before any ranking logic could be meaningful

Auditing an existing codebase before extending it (the legacy-vs-active catalog discovery) is worth doing explicitly — assuming the obviously-named model is the live one is a real way to silently build on dead code

14WHAT'S NEXT
  • Broadcast-and-first-to-accept vendor dispatch (Ola/Uber-style) once the vendor app supports live push
  • Server-rendered or prerendered customer pages for the category/service listings that currently ship as an unindexable client-rendered SPA
  • Multi-tenant support (tenantId scoping across all models) if the platform needs to serve more than one operator
  • A Membership/subscription tier, deliberately deferred as a live-referenced (not snapshotted) model distinct from Order's snapshot pattern
  • Real SMS provider integration alongside the existing email-adapter pattern for vendor and customer notifications
PROJECT COMPLETE

ServiceHub