Flex Engineers
5 min read

Quadro: A Booking & Scheduling Platform for Service Businesses

TypeScriptPrismaExpressReactSaaS DevelopmentBooking Management System

The problem

Service businesses — salons, tutors, coaches, boutique studios — usually end up stitching together two or three tools to run their bookings: one for one-off appointments, another for recurring classes, and a separate payment processor bolted on top with no shared data model. Calendly-style tools handle time slots well but know nothing about payments or capacity. Studio-management platforms handle payments but are heavyweight and expensive for a single-location business.

Quadro is our team's answer to that gap: a single API and client that model a business's bookable offerings (appointments and recurring classes), the reservations clients make against them, and the commercial lifecycle of those reservations — paid, unpaid, canceled, refunded — as one coherent domain instead of three bolted-together systems.

Stack

  • API: Node.js + TypeScript on Express, structured as a modular monolith (auth, users, settings, service/appointment, service/classService, booking, orders)
  • Data: MySQL via Prisma, with a migration history that tells its own story (more on that below)
  • Auth: Passport with four strategies — local, JWT, Google OAuth2, and a query-string strategy for authenticating Socket.IO connections — backed by RS256-signed access/refresh tokens
  • Real-time: Socket.IO, used to push events like login notifications to connected clients
  • Client: Vite + React 18 + MUI + React Router v6
  • Hardening & DX: Helmet, HPP, compression, Argon2/bcrypt, Zod validation, Husky + lint-staged + Commitizen for conventional commits, and a Dockerfile for containerized deploys

Modeling a business's offerings without a real union type

A Service in Quadro can be either a one-off Appointment or a recurring Class. Prisma doesn't support tagged unions, so instead of one bloated table with half its columns nullable depending on type, our team split it into a Service row (name, price, location, payment type) with a serviceType enum, and a 1:1 relation out to either an Appointment or Class extension table:

model Service {
  id          String       @id @default(uuid())
  name        String
  price       Float
  paymentType PaymentType  @default(IN_PERSON)
  serviceType ServiceType

  appointment Appointment?
  class       Class?
  booking     Booking[]
}

model Class {
  id                      String   @id @default(uuid())
  maxNumberOfParticipants Int
  startDateAndTime        DateTime?
  service                 Service  @relation(fields: [serviceId], references: [id])
  serviceId               String   @unique
  repeat                  ClassRepeat @relation(fields: [repeatId], references: [id])
  repeatId                String   @unique
}

Recurrence for classes is a separate ClassRepeat row — a week count plus seven booleans (monday through sunday) — rather than materializing every future occurrence as its own row. It's a compact rule, not a denormalized calendar, which keeps the schema small and pushes occurrence expansion to query time where it belongs.

Separating the calendar slot from the money

The other deliberate split is Booking vs. Order. A Booking is purely a reservation against a Service at a bookingTime. An Order wraps that booking with the commercial side: paymentStatus (PAID / UNPAID / CANCELED / REFUNDED), which client made it, and which business it belongs to.

async function createOrder(payload: OrderServiceDto): Promise<OrderOutput> {
  const booking = await bookingService.createBooking(payload);
  const order = await orderDal.create(orderMapper.toOrderInput(payload.clientId, booking));
  return orderMapper.toOrderOutput(order, booking);
}

Keeping these as two models instead of one fat "booking with payment fields" record means canceling or refunding an order never touches the underlying schedule data, and it lets paymentType (in-person vs. online) vary per service without the booking logic caring which one it is. Capacity control for classes falls out of this cleanly too — checkAvailability just compares the current order count against maxNumberOfParticipants on the class, no separate seat-tracking table needed:

async function checkAvailability(serviceId: string): Promise<boolean> {
  const serviceDetails = await servicesService.getServiceByTypeId(serviceId);
  const orderCount = await orderDal.orderCount(serviceDetails.id);
  if (serviceDetails.serviceType === ServiceType.CLASS && serviceDetails.class) {
    if (orderCount >= serviceDetails.class?.maxNumberOfParticipants) return false;
  }
  return true;
}

Auth: sessions, not just tokens

Access and refresh tokens are signed with separate RS256 key pairs (base64-encoded in config, decoded at runtime), which keeps the private signing keys out of the tokens themselves and lets access/refresh tokens be verified independently. Refresh tokens are tied to a Session row with a @unique userId, so issuing a new one is an upsert:

public createSession = async (userId: number) => {
  return await prisma.session.upsert({
    create: { userId },
    update: { userId },
    where: { userId },
  });
};

That unique constraint is doing real work: it means a user has exactly one valid session at a time, so revoking access is a single row update, not a token blocklist to maintain. Google OAuth2 login merges into the same User table (googleId, method, optional password) rather than a separate identity table, since Quadro only ever needed to distinguish "how did this user sign up," not support multiple linked providers per account. Socket.IO connections authenticate through a dedicated Passport query-strategy, so the same JWT that authorizes REST calls also authorizes the real-time channel used to push logged_in events.

A schema decision the team reversed

The migration history has a blunt commit: refactor: :fire: Remove mongodb related files, deleting Mongoose models and a parallel auth.services.mongo.ts implementation. Quadro started on MongoDB and the team moved it to MySQL/Prisma once the domain model solidified. Bookings, orders, and subscriptions are inherently relational — an order always belongs to exactly one booking, one client, and one business, and those constraints are worth enforcing at the database layer rather than in application code. Prisma's migration history (30+ incremental migrations) is the paper trail of that decision holding up: settings, billing, and subscription tables were added and reshaped without ever touching the core booking/order relations.

What this demonstrates

ConcernApproachWhy
Service polymorphismShared Service + type-specific extension tableAvoids nullable-column sprawl, keeps queries typed
RecurrenceRule row (ClassRepeat), not materialized rowsSmall schema, occurrence math at query time
Booking vs. paymentTwo models (Booking, Order)Cancel/refund logic never touches schedule data
Session modelUnique Session per user, upsertedRevocation is one row update, no token blocklist
Data layerMigrated Mongo → MySQL/Prisma mid-projectRelational constraints matter once money is involved

Quadro is a modular-monolith case study in resisting the urge to over-normalize or under-normalize a booking domain: split what genuinely varies independently (calendar slot vs. payment state), and don't split what doesn't (one User table, one Session per user). That's the same judgment call our team brings to any backend engagement — model the domain honestly first, then let the schema and the module boundaries fall out of it.