Prasanna Wagh.

All work

01Live in Spain and Europe

Tour Booking Marketplace

Four role portals running on one backend, in Spain and Europe

Lead full-stack engineer · Octogle TechnologiesJul 2025 to nowClient codebase, not public

Request pathswipe
Customersearch, book, payGuideassigned scheduleTour Operatorlistings, availabilityAdminoversight, correctionREST API · Node and ExpressJWT verified and role checked at the boundary, not in the UIPostgreSQLbookings, listings, availabilityRedisread-through cache, evicted on writeBullMQconfirmation and notification jobsWorkersretry on failure, off the request path

The problem

Tour operators across Spain and Europe were running bookings through a mix of spreadsheets, email and phone calls. The business needed one marketplace where a customer books an experience, a guide sees their assigned schedule, an operator manages their listings and availability, and an internal admin can see and correct all of it.

The catch is that those four groups do not want the same product. They share a booking, a calendar and a payment, and they agree on almost nothing else.

My part

I owned the application architecture and led the build from an empty repository through to production, with a three-person team working to me and reporting to the CEO.

The parts I was responsible for: architecture, REST API, auth and RBAC, caching, background jobs, database schema, frontend, CI/CD, team guidance.

Built with

  • React
  • TypeScript
  • Node.js
  • Express
  • PostgreSQL
  • TypeORM
  • Redis
  • BullMQ
  • Docker
  • Railway

The surfaces

The four surfaces, Customer, Guide, Tour Operator and Admin, all run against one backend, and each one keeps its own permissions, its own workflow and its own idea of what a booking means.

  • Customer

    Search, availability, booking and confirmation. The only surface tuned for people who have never seen the product before.

  • Guide

    Assigned tours, schedule and headcount. Read-heavy, opened on a phone, often on bad signal.

  • Tour Operator

    Listings, pricing, availability windows and the bookings against them. The surface that writes the most.

  • Admin

    The full picture, plus the ability to correct any of it. Every action here is a privileged write.

Decisions

A few decisions worth explaining.

  1. 01

    Custom JWT and RBAC instead of the third-party auth provider

    Problem
    The hosted provider modelled users, not roles-within-a-tenant. Encoding four portals with different permissions on top of it meant a growing pile of workarounds, and auth confusion was the single largest source of support tickets.
    Decision
    Replace it with a JWT layer owned in the codebase, with role-based access control enforced at the API boundary rather than in the UI.
    Trade-off
    Taking on token rotation, revocation and session handling that the provider had been doing. That is real work and real risk, accepted in exchange for permissions the product could actually express.
    Result
    Permission rules live in one place and are testable. Auth-related support issues fell by roughly 60%.

  2. 02

    Redis read-through cache in front of the booking reads

    Problem
    Availability, listing and pricing data were being read on nearly every request across four portals, and almost never changed between reads. The database was absorbing the same queries repeatedly.
    Decision
    Cache the high-read, low-volatility data in Redis, and invalidate on write from the operator surface rather than expiring on a timer alone.
    Trade-off
    Invalidation complexity. A stale availability record is worse than a slow one, so the write paths had to be disciplined about eviction.
    Result
    Redundant database queries fell about 40%, and average page load about 30%.

  3. 03

    BullMQ queues instead of doing the work inside the request

    Problem
    Booking confirmations and notifications were being sent inside the request that created the booking. A slow or failing mail provider became a slow or failing booking.
    Decision
    Move notification and confirmation work onto BullMQ queues with retries, so the request commits the booking and returns.
    Trade-off
    Introduces a worker process to run and monitor, and makes delivery eventual rather than immediate. The booking is the thing that must be synchronous. The email is not.
    Result
    A failing downstream provider retries instead of taking the booking path down with it.

  4. 04

    Role-specific frontends rather than one app with conditionals

    Problem
    A single app branching on role would have meant every screen carrying the logic of four products, and shipping a change to one audience risked all four.
    Decision
    Separate frontends per role against a shared, versioned API, with shared primitives extracted rather than shared screens.
    Trade-off
    Some duplication across surfaces, and four things to deploy instead of one. Worth it for blast radius: a guide-portal change cannot break checkout.
    Result
    Each surface can be tuned for how it is actually used, and released without a four-way regression pass.

  5. 05

    Docker and Railway for releases

    Problem
    Releases were manual and took hours, which meant they happened rarely, which meant each one carried more change and more risk.
    Decision
    Containerise the services and put them behind a CI/CD pipeline anyone on the team can trigger.
    Trade-off
    Managed infrastructure over self-hosted control, chosen deliberately for a three-person team with no platform engineer.
    Result
    Merge to deployed in under ten minutes, by any engineer on the team.

Outcome

  • Running in production across Spain and Europe with four role portals against one backend.
  • Redundant database queries down about 40%, average page load down about 30%.
  • Auth-related support issues down roughly 60%.
  • Releases down from hours of manual work to under ten minutes.

Not built

Things I considered and left out on purpose.

  • No microservices. Three engineers and one product do not need a distributed system, and the operational cost would have landed on the same three people.
  • No custom design system. Shared primitives only, extracted when a second surface actually needed them.
  • No self-hosted infrastructure. Managed platform, so the team's time goes to the product.