Tour Booking Marketplace
Four role portals running on one backend, in Spain and Europe
Lead full-stack engineer · Octogle TechnologiesClient codebase, not public
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.
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%.
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%.
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.
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.
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.