gGymflow
Engineering notes

Gymflow

Mini gym class booking SaaS with two AI layers on top: an overbooking advisor on the class side, and a blood-panel driven weekly program on the member side. Built as a focused “show” project for the Virtuagym B2B domain (class booking + credits + check-in), then extended with features the base product does not have.

The idea

The core product is the boring part: members book classes, pay in credits, check in. The interesting part is the + layer, which is now two features.

+ Overbooking advisor

+ Bloodwork-driven weekly program

Architecture

apps/
  web/   Next.js 15 App Router + Tailwind — admin & member UI
  api/   NestJS 10 + Prisma + Postgres + Grok — domain, booking, AI
packages/
  shared/  Zod schemas + types — the single source of truth for web ↔ api contracts
docker/
  docker-compose.yml       dev (Postgres + Redis)
  docker-compose.prod.yml  full stack (compose-run api + web + db + redis)

API layout — feature-first, capped depth

apps/api/src/
  main.ts
  app.module.ts

  config/                 ← zod-validated env (boot fails fast on a bad secret)
    env.schema.ts
    env.service.ts
    config.module.ts

  core/                   ← infrastructure (DB, cache, HTTP clients)
    prisma/{prisma.module,prisma.service}.ts
    redis/{redis.module,redis.service}.ts

  common/                 ← cross-cutting request concerns, no business logic
    filters/http-exception.filter.ts     ← global RFC 7807-ish errors
    pipes/zod-validation.pipe.ts         ← ZodSchema → NestPipe
    guards/{jwt-auth,roles}.guard.ts
    decorators/{public,roles,current-user,zod-body,zod-query}.ts
    middleware/request-id.middleware.ts   ← x-request-id propagation + log line

  modules/                ← every feature, self-contained
    auth/       {module, controller, service, repository, strategies/jwt.strategy}
    members/    {module, controller, service, repository}
    classes/    {module, controller, service, repository}
    trainers/   {module, controller, service, repository}
    bookings/   {module, controller, service, repository}
    overbooking/{module, controller, no-show-advisor, overbook-decision.repository}
    bloodwork/  {module, controller, service, repository, classifier, analyzer, pdf-extractor}
    realtime/   {module, gateway}          ← Socket.IO gateway with JWT handshake
    health/     {module, controller}       ← liveness ping, not the bloodwork feature

The root never grows past six entries (main.ts, app.module.ts, config/, core/, common/, modules/). Adding a new feature means one new folder under modules/, nothing else moves.

Layering rules

This makes testing painless: services are unit-tested with mocked repositories (see auth.service.spec.ts, no-show-advisor.service.spec.ts) without any Prisma fakes.

Zod-driven contracts

packages/shared holds every request/response schema as a Zod object:

packages/shared/src/schemas/ai.schema.ts
export const NoShowAdvisorResponseSchema = z.object({
  expectedAttendance: z.number().min(0),
  expectedNoShows: z.number().min(0),
  overbookRecommendation: z.enum(["ALLOW", "DENY"]),
  riskBand: z.enum(["LOW", "MEDIUM", "HIGH"]),
  rationale: z.string().min(1).max(600),
  perBooking: z.array(...),
});
export type NoShowAdvisorResponse = z.infer<typeof NoShowAdvisorResponseSchema>;

The same schema is used three ways:

Data invariants live in the database

Booking / credit logic is safety-critical, so the authoritative checks live in Postgres:

See apps/api/prisma/migrations/20260423010000_credit_checks/migration.sql.

The AI advisor (xAI Grok)

bookings.service ─► no-show-advisor.service ─► grok-client.service ─► POST /chat/completions
                                  │
                                  ├─► prisma.class (read context)
                                  └─► ai-decision.repository (audit)

The bloodwork analyzer

POST /bloodwork/extract   (PDF → preview, nothing saved)
POST /bloodwork/reports   (confirm + persist + analyze)
GET  /bloodwork/reports/me           (list)
GET  /bloodwork/reports/me/latest    (latest)
GET  /bloodwork/reports/:id          (detail)
GET  /bloodwork/recommendations/me/latest

Pipeline (PDF path):

raw PDF ─► pdf-extractor ─► pdf-parse (text layer)
                          └► Grok (structure → marker rows)
                                │
                                ▼
                          editable preview (not persisted)
                                │
                       user confirms/edits
                                ▼
                      bloodwork.service
                       ├─► normaliseMarkers (drop anything outside catalog)
                       ├─► classifier.service  (rules, no LLM)  → bands
                       ├─► analyzer.service    (single Grok call) → program
                       └─► repository $transaction (report + markers + recommendation)
packages/shared/src/schemas/health.schema.ts
export const ProgramRecommendationResponseSchema = z.object({
  readinessScore:         z.number().int().min(0).max(100),
  recommendedCategories:  z.array(ClassCategoryEnum).max(6),
  avoidCategories:        z.array(ClassCategoryEnum).max(6),
  perMarker: z.array(z.object({
    canonicalName:        z.string(),
    interpretation:       MarkerInterpretationEnum,
    explanation:          z.string().max(400),
    impact:               z.enum(["NONE","LOW","MEDIUM","HIGH"]),
    suggestedCategories:  z.array(ClassCategoryEnum),
    avoidCategories:      z.array(ClassCategoryEnum),
  })).max(30),
  weeklyPlan:             z.string().max(1200),
  warnings:               z.array(z.string().max(240)).max(8),
  summary:                z.string().max(800),
});

ClassCategory (shared enum): HIIT, CARDIO, STRENGTH, YOGA, MOBILITY, PILATES, CYCLING, RECOVERY. Every class row carries one; the /book page matches it against the member's latest recommendedCategories / avoidCategories to decorate each card with a badge.

Marker catalog

packages/shared/src/constants/marker-catalog.ts — ~18 markers across hematology, iron, metabolic, lipid, thyroid, vitamin, inflammation, kidney, liver, electrolyte. Each entry has:

A label the catalog doesn't recognise is silently dropped — the catalog is the source of truth, Grok doesn't get to add markers.

Guardrails

Why one LLM call, not three

BloodKnows (the reference product) runs a recommendations pass, an insights pass, and a summary pass in parallel and stitches them. I collapsed it to one call because the rule layer already owns marker classification, so the LLM only needs to do the programming judgment on top. Result: lower latency, simpler error handling, still deterministic where it matters.

Redis (cache + rate limit)

Running locally

Requirements: Node ≥ 20, pnpm ≥ 8, Docker, a Grok API key from console.x.ai.

pnpm install
cp .env.example .env
# put your Grok key into GROK_API_KEY=…

pnpm db:up                                           # Postgres + Redis
pnpm --filter @gymflow/shared build                  # emit dist for api & web
pnpm --filter @gymflow/api prisma:deploy             # apply migrations
pnpm --filter @gymflow/api seed                      # ~42 members, 470 classes (8 categories), 2.5k booking history
pnpm dev                                             # api + web in parallel

URLs:

Seeded accounts (see apps/api/prisma/seed.ts):

Testing

pnpm --filter @gymflow/api test

Currently covered:

Repositories are not mocked at the Prisma level — they're mocked at the repository interface. That's the whole point of the repository layer: tests care about “was decrementCredits(memberId, cost) called?”, not “was updateMany with the right where?”.

AI assistant policy

An AI assistant (Claude Code) was used during scaffolding and for mechanical refactors (controller / service / repository split, DTO migration to Zod, UI boilerplate). Every architectural decision — the layering rules, the transaction boundaries, the AI advisor contract shape, the in-DB invariants — was made and reviewed by the author. I can defend every file live in the interview walkthrough.

What this project is not

Every feature is either core-necessary for the booking flow, part of the overbooking advisor, or part of the bloodwork analyzer.