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 my 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, I 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 I 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 I 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
| Concern | Approach | Why |
|---|---|---|
| Service polymorphism | Shared Service + type-specific extension table | Avoids nullable-column sprawl, keeps queries typed |
| Recurrence | Rule row (ClassRepeat), not materialized rows | Small schema, occurrence math at query time |
| Booking vs. payment | Two models (Booking, Order) | Cancel/refund logic never touches schedule data |
| Session model | Unique Session per user, upserted | Revocation is one row update, no token blocklist |
| Data layer | Migrated Mongo → MySQL/Prisma mid-project | Relational 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 I bring to any backend engagement — model the domain honestly first, then let the schema and the module boundaries fall out of it.