Technical Architecture: GymStack Platform
Version: 1.0
Last Updated: April 2026
Status: Living Document
Tech Stack Decision
┌─────────────────────────────────────────────────────────────────┐
│ GYMSTACK TECH STACK │
│ │
│ CLIENTS │
│ ├── Mobile (Member + Trainer + Admin): Flutter (Dart) │
│ └── Web Dashboard (Admin): Next.js 14 + Tailwind CSS │
│ │
│ BACKEND │
│ ├── API Server: Node.js + Fastify + TypeScript │
│ ├── Database: PostgreSQL 16 (RDS, Row-Level Security) │
│ ├── Cache: Redis 7 (ElastiCache) │
│ ├── Job Queue: BullMQ (on Redis) │
│ ├── File Storage: AWS S3 + CloudFront CDN │
│ └── AI/ML Service: Python + FastAPI (microservice) │
│ │
│ INTEGRATIONS │
│ ├── Payments: Razorpay (UPI autopay, cards, subscriptions) │
│ ├── Messaging: WhatsApp Business API (Gupshup/Wati) │
│ ├── Notifications: Firebase Cloud Messaging │
│ ├── Biometrics: MQTT bridge (Mantra, Secugen scanners) │
│ ├── Wearables: Terra API (Apple Health, Fitbit, Garmin) │
│ └── AI Content: Anthropic Claude API (diet plans, insights) │
│ │
│ INFRASTRUCTURE │
│ ├── Cloud: AWS Mumbai (ap-south-1) │
│ ├── Containers: ECS Fargate │
│ ├── CI/CD: GitHub Actions + Fastlane (mobile) │
│ └── Monitoring: CloudWatch + Sentry + PagerDuty │
└─────────────────────────────────────────────────────────────────┘
Mobile: Flutter (Dart)
Decision: Flutter over React Native
| Criteria | Flutter | React Native |
|---|---|---|
| Market share (cross-platform) | 46% (and growing) | 32% |
| UI consistency | Pixel-perfect on both platforms (own rendering engine) | Native components differ across iOS/Android |
| White-label support | Build flavors are a first-class concept | Requires complex Metro bundler config |
| Web + Desktop | Single codebase can compile to web, macOS, Windows | Limited web support; no desktop |
| Performance | Compiles to native ARM code; Skia/Impeller rendering | JavaScript bridge overhead |
| Developer availability (India) | Large and growing; strong community in Bangalore, Hyderabad | Larger pool, but Flutter closing gap |
| Hot reload | Sub-second; preserves state | Fast, but occasionally loses state |
The decisive factor is white-label. Flutter’s build flavor system (also called “flavors” on Android, “schemes/configurations” on iOS) lets us create per-gym app variants from a single codebase by swapping: app ID, app name, icon, splash screen, colors, and API endpoint at build time. React Native has no equivalent first-class mechanism — it requires custom Metro bundler configurations, shell scripts, and fragile workarounds.
State Management: Riverpod
Riverpod was chosen over BLoC, Provider, and GetX for the following reasons:
- Compile-safe dependency injection: Providers are declared globally and resolved at runtime with compile-time type checking. No
BuildContextrequired to read providers, which simplifies testing and logic that runs outside the widget tree. - Auto-dispose: Providers automatically clean up when no longer observed, preventing memory leaks in a complex multi-screen app.
- Code generation: Using
riverpod_generator, providers are generated from annotated functions, reducing boilerplate. - Testability: Providers can be overridden in tests without mocking frameworks.
Key Flutter Packages:
| Package | Purpose |
|---|---|
flutter_riverpod + riverpod_generator | State management |
go_router | Declarative routing with deep linking |
dio + retrofit | HTTP client with type-safe API layer |
hive / isar | Local storage for offline capability |
flutter_secure_storage | Secure token storage (keychain/keystore) |
mobile_scanner | QR code scanning (check-in) |
qr_flutter | QR code generation (member check-in) |
fl_chart | Charts for progress tracking and analytics |
image_picker + image_cropper | Progress photos |
firebase_messaging | Push notifications |
flutter_local_notifications | Local notification scheduling |
freezed + json_serializable | Immutable data classes with JSON serialization |
cached_network_image | Image caching for exercise library |
intl | Internationalization (Hindi, regional languages) |
Web Dashboard: Next.js + Tailwind CSS
The admin dashboard is built with Next.js 14 (App Router) and Tailwind CSS. This decision leverages existing expertise from the profitness-redesign codebase and the broader Next.js ecosystem.
Architecture Decisions:
-
Server Components for data-heavy pages: The analytics dashboard, MIS reports, member lists, and financial summaries use React Server Components. These pages fetch data on the server, render HTML, and stream it to the client. This means: faster initial load (no client-side data fetching waterfall), smaller JavaScript bundle (data-fetching code stays on the server), and better SEO for any public-facing pages.
-
Client Components for interactive features: The workout plan builder (drag-and-drop), real-time check-in monitor (WebSocket), and interactive charts use Client Components with
"use client"directive. These need browser APIs and user interaction handlers. -
Static Generation for marketing/landing pages: The GymStack marketing site, pricing page, and blog use
generateStaticParamsfor build-time rendering. Deployed on CloudFront for sub-100ms load times globally. -
API Routes for BFF (Backend for Frontend): Next.js API routes act as a thin proxy between the frontend and the Fastify backend. This adds: session validation, request transformation (frontend format to API format), and response shaping (the web dashboard may need different data shapes than the mobile apps).
Key Libraries:
| Library | Purpose |
|---|---|
@tanstack/react-query | Server state management, caching, background refetch |
recharts | Charting library for analytics dashboards |
@dnd-kit/core | Drag-and-drop for workout plan builder |
react-hook-form + zod | Form handling with schema validation |
next-auth | Authentication (JWT-based, synced with Fastify backend) |
@radix-ui/react-* | Accessible UI primitives |
tailwind-merge + clsx | Dynamic Tailwind class composition |
date-fns | Date manipulation (timezone-aware for IST) |
xlsx | Excel export for reports |
react-pdf | Invoice PDF generation |
Backend: Node.js + Fastify
Why Fastify over Express:
| Criteria | Fastify | Express |
|---|---|---|
| Throughput | ~75,000 req/sec | ~15,000 req/sec |
| JSON serialization | 2-3x faster (schema-based fast-json-stringify) | Generic JSON.stringify |
| TypeScript support | First-class; type providers for routes | Bolt-on; @types/express |
| Validation | Built-in JSON Schema validation (Ajv) | Requires middleware (express-validator, Joi) |
| Plugin system | Encapsulated plugin architecture | Middleware chain (global side effects) |
| OpenAPI | @fastify/swagger auto-generates from route schemas | Manual setup with swagger-jsdoc |
| Logging | Built-in Pino logger (JSON, fast) | Requires winston/morgan setup |
Fastify’s schema-based approach is particularly valuable for GymStack because every route has a defined request/response schema (validated by Ajv, serialized by fast-json-stringify), which serves triple duty: runtime validation, TypeScript types, and OpenAPI documentation.
API Design: REST with OpenAPI Spec
API Structure:
/api/v1
├── /auth
│ ├── POST /otp/send (send OTP to phone)
│ ├── POST /otp/verify (verify OTP, return JWT)
│ └── POST /token/refresh (refresh access token)
├── /members
│ ├── GET / (list members, filterable)
│ ├── POST / (create member)
│ ├── GET /:id (get member details)
│ ├── PUT /:id (update member)
│ ├── DELETE /:id (soft delete)
│ ├── GET /:id/check-ins (member's check-in history)
│ ├── GET /:id/payments (member's payment history)
│ └── GET /:id/progress (member's progress data)
├── /trainers
│ ├── GET / (list trainers)
│ ├── POST / (create trainer)
│ ├── GET /:id/clients (trainer's assigned clients)
│ └── GET /:id/sessions (trainer's sessions)
├── /workouts
│ ├── GET /exercises (exercise library)
│ ├── POST /plans (create workout plan)
│ ├── GET /plans/:id (get plan details)
│ ├── PUT /plans/:id (update plan)
│ └── POST /plans/:id/assign (assign plan to member)
├── /diets
│ ├── GET /foods (food database)
│ ├── POST /plans (create diet plan)
│ └── POST /plans/:id/assign (assign to member)
├── /check-ins
│ ├── POST / (record check-in)
│ ├── GET /today (today's check-ins)
│ └── GET /analytics (check-in analytics)
├── /payments
│ ├── POST /create-order (Razorpay order creation)
│ ├── POST /webhook (Razorpay webhook handler)
│ ├── GET /invoices/:id (download invoice)
│ └── POST /record-offline (record cash/UPI payment)
├── /leads
│ ├── POST / (create lead)
│ ├── PUT /:id/status (update lead status)
│ └── GET /funnel (conversion funnel data)
├── /classes
│ ├── GET /schedule (class schedule)
│ ├── POST /:id/book (book a class)
│ └── DELETE /:id/book (cancel booking)
├── /analytics
│ ├── GET /revenue (revenue trends)
│ ├── GET /retention (retention/churn data)
│ ├── GET /engagement (member engagement scores)
│ └── GET /reports/monthly (monthly MIS report)
├── /notifications
│ ├── POST /whatsapp/send (send WhatsApp message)
│ ├── POST /whatsapp/broadcast (bulk WhatsApp)
│ └── POST /push/send (send push notification)
└── /gym
├── GET /config (gym branding/config)
├── PUT /config (update config, owner only)
└── GET /branches (list branches)
Every endpoint returns consistent JSON:
{
"success": true,
"data": { ... },
"meta": { "page": 1, "total": 150, "per_page": 20 }
}
Error responses:
{
"success": false,
"error": {
"code": "MEMBER_NOT_FOUND",
"message": "No member found with ID abc-123",
"status": 404
}
}
WebSocket for Real-Time Features:
A Fastify WebSocket plugin (@fastify/websocket) provides real-time updates for:
- Live check-in feed: Admin dashboard shows members checking in as it happens
- Current occupancy: Real-time count of people in the gym
- Notification delivery: Instant in-app notifications
- Session status: Trainer sees when their client checks in
WebSocket connections are authenticated using the same JWT as REST APIs. Messages are published via Redis Pub/Sub so that all API server instances can broadcast to their connected clients.
Database: PostgreSQL 16
Why PostgreSQL over MongoDB:
The GymStack data model is fundamentally relational. Members belong to gyms. Trainers are assigned to members. Payments reference members and plans. Workouts contain exercises. Check-ins reference members and gyms. These relationships are best modeled with foreign keys, joins, and referential integrity constraints.
Additionally:
- ACID compliance for payments: When a member pays, we must atomically: create a payment record, update the membership expiry date, generate an invoice, and trigger a notification. If any step fails, all must roll back. MongoDB’s multi-document transactions are an afterthought; PostgreSQL’s transactions are foundational.
- Row-Level Security for multi-tenancy: PostgreSQL’s RLS is a database-enforced security boundary that MongoDB lacks. We detail this in the Multi-Tenant Architecture section.
- Complex analytics queries: Retention cohort analysis, revenue trend calculations, and trainer performance aggregations require JOINs, window functions, and CTEs that PostgreSQL handles natively. MongoDB requires complex aggregation pipelines that are harder to write, debug, and optimize.
- Mature ecosystem: PostGIS for location (find nearby gyms), full-text search for exercise library, JSONB for flexible fields (gym-specific custom attributes), and pg_cron for scheduled jobs.
Schema Design Highlights:
The core schema has approximately 20 tables. The key design patterns are:
- Every table has
gym_id: This is the tenant isolation column. RLS policies filter on it. - Soft deletes everywhere:
deleted_at TIMESTAMPinstead ofDELETE. Members, trainers, and plans are never truly deleted (audit trail requirement). - JSONB for flexible fields: Gym-specific configuration, custom member attributes, and exercise metadata use JSONB columns to avoid schema changes per tenant.
- Temporal tracking:
created_at,updated_aton every table.membership_start,membership_endon memberships for date-range queries.
Indexing Strategy:
-- Primary lookup patterns and their indexes:
-- Members: lookup by gym + status (most common admin query)
CREATE INDEX idx_members_gym_status ON members(gym_id, status);
-- Members: lookup by phone (login, duplicate detection)
CREATE UNIQUE INDEX idx_members_phone ON members(phone) WHERE deleted_at IS NULL;
-- Check-ins: today's check-ins for a gym (dashboard query)
CREATE INDEX idx_checkins_gym_date ON check_ins(gym_id, checked_in_at DESC);
-- Payments: member's payment history
CREATE INDEX idx_payments_member ON payments(member_id, created_at DESC);
-- Payments: gym's revenue queries (date range scans)
CREATE INDEX idx_payments_gym_date ON payments(gym_id, created_at DESC);
-- Memberships: expiring soon (renewal reminder jobs)
CREATE INDEX idx_memberships_expiry ON memberships(gym_id, end_date)
WHERE status = 'active';
-- Workouts: member's active plan
CREATE INDEX idx_workout_assignments_member ON workout_assignments(member_id)
WHERE status = 'active';
-- Leads: funnel queries
CREATE INDEX idx_leads_gym_status ON leads(gym_id, status, created_at DESC);
-- Partial index for active sessions (trainer's today view)
CREATE INDEX idx_sessions_trainer_upcoming ON sessions(trainer_id, scheduled_at)
WHERE status IN ('scheduled', 'confirmed');
Why these indexes matter: Without idx_memberships_expiry, the nightly “find members expiring in 7 days” job would do a full table scan. With 500K members across 1,000 gyms, that’s the difference between 50ms and 5 seconds. The WHERE status = 'active' partial index means we only index the rows we actually query, saving storage and write overhead.
Cache: Redis 7
Redis serves four distinct roles in GymStack:
1. Session Management: JWT refresh tokens are stored in Redis with TTL matching the token expiry (30 days). On token refresh, the old refresh token is invalidated (deleted from Redis) and a new one is created. This prevents token reuse attacks and enables instant session revocation (delete the key, and the next refresh attempt fails).
Key pattern: session:{user_id}:{device_id}
Value: { refresh_token, role, gym_id, created_at }
TTL: 30 days
2. Rate Limiting: API rate limiting uses Redis’s atomic increment operations. Each API key (or IP for unauthenticated endpoints) gets a counter that resets every minute/hour.
Key pattern: ratelimit:{api_key}:{window}
Value: request count
TTL: window duration
Limits:
- Authenticated: 100 req/minute, 5,000 req/hour
- OTP send: 5 req/hour per phone number (prevent OTP spam)
- Webhook: 1,000 req/minute (Razorpay can burst)
3. Real-Time Check-in Data: Current gym occupancy and today’s check-in count are maintained in Redis for instant dashboard reads. Updated on every check-in/checkout event. This avoids hitting PostgreSQL for the most frequently read metric.
Key pattern: gym:{gym_id}:occupancy
Value: { current_count, today_total, last_updated }
TTL: 24 hours (auto-reset)
4. Workout/Diet Plan Caching: Active workout and diet plans are cached in Redis after first retrieval. Since members view their plan multiple times per day (every time they open the app at the gym), caching prevents repeated PostgreSQL queries. Cache is invalidated when the trainer updates the plan.
Key pattern: plan:workout:{member_id}:active
Value: full workout plan JSON
TTL: 1 hour (with invalidation on update)
Job Queue: BullMQ
BullMQ runs on the same Redis instance and handles all background processing. Jobs are categorized into queues:
1. WhatsApp Reminder Scheduling:
Queue: whatsapp-reminders
Jobs:
- renewal-reminder: Runs daily at 8 AM IST. Queries members expiring in 7/3/1 days.
For each member, creates a WhatsApp send job with the appropriate template.
- birthday-wish: Runs daily at 7 AM IST. Queries members with birthday today.
- follow-up-sequence: Triggered when a new lead is created. Schedules a sequence of
messages at Day 0, Day 1, Day 3, Day 7, Day 14.
- payment-confirmation: Triggered by Razorpay webhook. Sends payment receipt via WhatsApp.
Rate limiting: Max 30 messages/second (WhatsApp Business API limit per number).
Retry: 3 attempts with exponential backoff (1min, 5min, 15min).
Dead letter queue for failed messages (reviewed manually).
2. Billing Cycle Automation:
Queue: billing
Jobs:
- generate-invoice: Triggered after successful payment. Creates GST invoice PDF,
stores in S3, links to payment record.
- autopay-check: Runs daily. For members with UPI autopay, checks Razorpay subscription
status. If payment failed, triggers manual reminder sequence.
- overdue-flagging: Runs daily. Marks memberships as "overdue" if expiry was 3+ days ago
and no renewal payment received.
3. Report Generation:
Queue: reports
Jobs:
- monthly-mis: Runs on the 1st of every month at 6 AM IST. Generates monthly MIS
report for each gym (revenue, members, churn, trainer metrics). Stores as PDF
in S3 and sends via WhatsApp/email to the gym owner.
- export-csv: Triggered on-demand when an admin exports data. Generates CSV for
large datasets asynchronously (member list, payment history, check-in data).
- analytics-rollup: Runs nightly. Pre-calculates analytics aggregations
(daily revenue totals, weekly check-in counts, monthly retention rates) and
stores in a materialized view/summary table for fast dashboard queries.
4. Churn Prediction Jobs (Phase 3):
Queue: ml-predictions
Jobs:
- weekly-churn-prediction: Runs every Sunday at 2 AM IST. Calls the Python ML
microservice with member feature vectors. Stores prediction scores in PostgreSQL.
Triggers alerts for members with >70% churn probability.
- engagement-score-update: Runs nightly. Calculates composite engagement score
(0-100) for each member based on recent check-ins, workout completions, payment
history, and app usage.
Multi-Tenant Architecture
GymStack uses a single-database, shared-schema multi-tenant architecture with PostgreSQL Row-Level Security (RLS) as the isolation mechanism.
┌─────────────────────────────────────────────────────────────────┐
│ REQUEST FLOW │
│ │
│ Member App │
│ (Pro Fitness) ──── HTTPS ────┐ │
│ │ │
│ Trainer App ▼ │
│ (Pro Fitness) ──── HTTPS ──► LOAD BALANCER (ALB) │
│ │ │
│ Admin Dashboard │ │
│ (Iron Gym) ──── HTTPS ────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Fastify API │ │
│ │ Server (ECS) │ │
│ │ │ │
│ │ 1. Extract JWT │ │
│ │ 2. Decode │ │
│ │ gym_id │ │
│ │ 3. SET │ │
│ │ app.current │ │
│ │ _gym = │ │
│ │ 'gym_abc' │ │
│ │ 4. Execute │ │
│ │ query │ │
│ └────────┬─────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────┐ │
│ │ PostgreSQL (RDS) │ │
│ │ │ │
│ │ RLS Policy on every │ │
│ │ table enforces: │ │
│ │ │ │
│ │ WHERE gym_id = │ │
│ │ current_setting( │ │
│ │ 'app.current_gym' │ │
│ │ )::uuid │ │
│ │ │ │
│ │ ┌────────┬────────┐ │ │
│ │ │Pro Fit │Iron Gym│ │ │
│ │ │members │members │ │ │
│ │ │payments│payments│ │ │
│ │ │plans │plans │ │ │
│ │ │........│........│ │ │
│ │ └────────┴────────┘ │ │
│ │ │ │
│ │ Data is co-located in │ │
│ │ the same tables but │ │
│ │ logically isolated by │ │
│ │ gym_id + RLS policies │ │
│ └─────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Tenant Configuration Table
CREATE TABLE gym_configs (
gym_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL, -- 'profitness-kankarbagh'
owner_id UUID REFERENCES users(id),
-- Branding
logo_url TEXT,
primary_color TEXT DEFAULT '#6366F1', -- hex color
secondary_color TEXT DEFAULT '#1E1B4B',
accent_color TEXT DEFAULT '#F59E0B',
splash_url TEXT,
app_icon_url TEXT,
tagline TEXT,
-- Contact
phone TEXT,
email TEXT,
address JSONB, -- { line1, line2, city, state, pin }
-- Business
gstin TEXT, -- GST registration number
pan TEXT,
-- Configuration
timezone TEXT DEFAULT 'Asia/Kolkata',
currency TEXT DEFAULT 'INR',
features TEXT[] DEFAULT '{}', -- enabled feature flags
plan_type TEXT DEFAULT 'starter', -- starter, growth, pro, enterprise
plan_limits JSONB DEFAULT '{ -- enforced by API middleware
"max_members": 200,
"max_trainers": 5,
"max_branches": 1,
"whatsapp_messages_per_month": 1000,
"white_label": false
}'::jsonb,
-- White-label
white_label BOOLEAN DEFAULT false,
app_bundle_id TEXT, -- 'in.profitness.app'
play_store_url TEXT,
app_store_url TEXT,
-- Metadata
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
deleted_at TIMESTAMPTZ
);
RLS Policy Implementation
-- Enable RLS on every tenant-scoped table
ALTER TABLE members ENABLE ROW LEVEL SECURITY;
ALTER TABLE trainers ENABLE ROW LEVEL SECURITY;
ALTER TABLE memberships ENABLE ROW LEVEL SECURITY;
ALTER TABLE payments ENABLE ROW LEVEL SECURITY;
ALTER TABLE check_ins ENABLE ROW LEVEL SECURITY;
-- ... (all tables)
-- Create policy: users can only see rows matching their gym_id
CREATE POLICY tenant_isolation ON members
USING (gym_id = current_setting('app.current_gym')::uuid);
CREATE POLICY tenant_isolation ON payments
USING (gym_id = current_setting('app.current_gym')::uuid);
-- Repeat for every table...
-- The API sets this session variable before every query:
-- SET LOCAL app.current_gym = 'gym-uuid-here';
-- SET LOCAL ensures it's scoped to the current transaction only.
API Middleware for Tenant Resolution
// Fastify middleware: runs before every route handler
fastify.addHook('preHandler', async (request, reply) => {
// 1. Extract JWT from Authorization header
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new UnauthorizedError('Missing token');
// 2. Verify and decode JWT
const payload = await verifyJWT(token);
// payload = { user_id, gym_id, role, exp }
// 3. Attach to request for route handlers
request.tenant = {
gymId: payload.gym_id,
userId: payload.user_id,
role: payload.role // 'owner' | 'manager' | 'trainer' | 'frontdesk' | 'member'
};
// 4. Set PostgreSQL session variable for RLS
// This happens at the start of every database transaction
await db.raw(`SET LOCAL app.current_gym = '${payload.gym_id}'`);
});
How a Single Request Flows
Here is the complete lifecycle of a request from a member opening their workout plan:
1. Member opens "My Workout" in the Pro Fitness app
└── App sends: GET /api/v1/workouts/plans/active
Headers: { Authorization: "Bearer eyJhbGc..." }
2. Request hits ALB (load balancer)
└── Routed to any available Fastify container (stateless)
3. Fastify preHandler hook:
a. Extracts JWT from header
b. Verifies signature (RS256 public key)
c. Decodes: { user_id: "mem_123", gym_id: "gym_abc", role: "member" }
d. Checks Redis for session validity (refresh token exists)
e. Runs: SET LOCAL app.current_gym = 'gym_abc'
4. Route handler executes:
SELECT wp.*, json_agg(we.*) as exercises
FROM workout_plans wp
JOIN workout_exercises we ON we.plan_id = wp.id
JOIN workout_assignments wa ON wa.plan_id = wp.id
WHERE wa.member_id = 'mem_123'
AND wa.status = 'active'
GROUP BY wp.id;
-- RLS automatically adds: AND wp.gym_id = 'gym_abc'
-- Even if the query "forgets" the gym_id filter, RLS enforces it
-- A member from Iron Gym can NEVER see Pro Fitness workout plans
5. Response:
{
"success": true,
"data": {
"id": "plan_xyz",
"name": "Push/Pull/Legs - Week 3",
"today": "push",
"exercises": [
{ "name": "Bench Press", "sets": 4, "reps": "8-10",
"last_weight": 60, "rest_seconds": 90 },
...
]
}
}
6. App renders the workout plan. Member starts training.
Integration Architecture
Razorpay (Payments)
Razorpay is the sole payment processor for GymStack. It handles all online payments including one-time membership fees, recurring subscriptions, and payment link generation.
┌──────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────┐
│ Member │ │ GymStack │ │ Razorpay │ │ Member │
│ App │ │ Backend │ │ API │ │ Bank │
└────┬─────┘ └──────┬───────┘ └──────┬───────┘ └────┬─────┘
│ │ │ │
│ Tap "Pay Now" │ │ │
├────────────────►│ │ │
│ │ Create Order │ │
│ ├──────────────────►│ │
│ │ order_id │ │
│ │◄─────────────────┤│ │
│ Razorpay │ │ │
│ Checkout UI │ │ │
│◄────────────────┤ │ │
│ │ │ │
│ Selects UPI │ │ │
├─────────────────┼──────────────────►│ │
│ │ │ UPI collect │
│ │ ├─────────────────►│
│ │ │ Approved │
│ │ │◄─────────────────┤
│ │ │ │
│ │ Webhook: │ │
│ │ payment.captured │ │
│ │◄─────────────────┤│ │
│ │ │ │
│ │ [Backend] │ │
│ │ 1. Verify sig │ │
│ │ 2. Update member │ │
│ │ 3. Extend expiry │ │
│ │ 4. Generate inv │ │
│ │ 5. Send WhatsApp │ │
│ │ │ │
│ "Payment │ │ │
│ Successful!" │ │ │
│◄────────────────┤ │ │
│ │ │ │
UPI Autopay for Recurring Memberships:
Razorpay’s Subscription API creates a UPI autopay mandate. The member approves a recurring debit authorization on their UPI app (Google Pay, PhonePe, Paytm). On renewal day, Razorpay automatically debits the amount without any member action.
Setup flow:
1. Member selects "Auto-pay" during enrollment
2. GymStack creates a Razorpay Subscription with plan_id and customer_id
3. Member is redirected to UPI autopay mandate flow
4. Member approves mandate in their UPI app (one-time approval)
5. On each billing cycle, Razorpay auto-debits the amount
6. Webhook notifies GymStack of success/failure
7. On failure (insufficient funds), member gets WhatsApp notification to pay manually
Payment Links via WhatsApp:
For members who don’t have the app installed (or prefer paying via WhatsApp), GymStack generates Razorpay payment links and sends them via WhatsApp Business API. The link opens a Razorpay-hosted payment page with the amount pre-filled and the gym’s branding.
Webhook Handling:
All Razorpay events are received at a single webhook endpoint (POST /api/v1/payments/webhook). The handler:
- Verifies the webhook signature using Razorpay’s secret
- Checks for duplicate events (idempotency key stored in Redis)
- Routes to the appropriate handler based on event type
- Events handled:
payment.captured,payment.failed,subscription.charged,subscription.halted,refund.processed
WhatsApp Business API
Provider: Gupshup (primary) or Wati (fallback). Gupshup is chosen for its reliable delivery in India, competitive pricing (Rs 0.50-0.75 per session message), and robust API.
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ GymStack │ │ Gupshup │ │ WhatsApp │
│ Backend │ │ API │ │ (Member) │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
│ Send template │ │
│ message │ │
├───────────────────►│ │
│ │ Deliver message │
│ ├───────────────────►│
│ │ │
│ │ Member replies │
│ │◄───────────────────┤
│ Incoming webhook │ │
│◄───────────────────┤ │
│ │ │
│ [Phase 2: Chatbot]│ │
│ Parse intent, │ │
│ generate response │ │
│ "Your membership │ │
│ expires Apr 15" │ │
├───────────────────►│ │
│ ├───────────────────►│
│ │ │
Message Templates (Pre-approved by Meta):
| Template Name | Trigger | Content Example |
|---|---|---|
welcome_member | New member onboarded | ”Welcome to {gym_name}! Download your member app: {link}“ |
renewal_reminder_7d | 7 days before expiry | ”Hi {name}, your {plan} membership expires on {date}. Renew now: {pay_link}“ |
renewal_reminder_3d | 3 days before expiry | ”Reminder: {plan} membership expiring in 3 days. Pay Rs {amount}: {pay_link}“ |
expiry_notice | Expiry day | ”Your membership at {gym_name} has expired. Renew to continue your fitness journey: {pay_link}“ |
payment_receipt | Payment received | ”Payment of Rs {amount} received for {plan}. Valid until {date}. Invoice: {link}“ |
birthday_wish | Member’s birthday | ”Happy Birthday {name}! Wishing you a fit and fantastic year ahead. - {gym_name}“ |
workout_assigned | Trainer assigns plan | ”{trainer_name} has assigned you a new workout plan. Open your app to view: {link}“ |
class_booking_confirm | Class booked | ”Confirmed: {class_name} with {instructor} on {date} at {time}. See you there!” |
lead_follow_up | Lead captured | ”Thanks for visiting {gym_name}! Here’s what we offer: {link}” |
2-Way Chatbot (Phase 2):
Members can message the gym’s WhatsApp number to:
- “When does my membership expire?” — Bot checks membership and replies with expiry date
- “Book PT session” — Bot shows available slots and lets member pick one
- “Today’s diet” — Bot fetches active diet plan and sends meal list
- “Pay” — Bot sends Razorpay payment link
The chatbot uses keyword matching (Phase 2) and upgrades to NLU/intent classification (Phase 3) for natural language understanding.
Biometric Devices
Many Indian gyms already have fingerprint scanners (Mantra MFS100, Secugen Hamster Pro) connected to legacy Windows software. GymStack bridges these devices to the cloud.
┌───────────────┐ ┌──────────────┐ ┌──────────────┐
│ Fingerprint │ │ MQTT Bridge │ │ GymStack │
│ Scanner │ │ (Local PC) │ │ Backend │
│ (Mantra/ │ │ │ │ │
│ Secugen) │ │ Lightweight │ │ │
└──────┬────────┘ │ app running │ │ │
│ │ on gym's PC │ │ │
│ Finger scan └──────┬───────┘ └──────┬───────┘
├─────────────────────► │
│ │ 1. Capture template │
│ │ 2. Match against │
│ │ local DB │
│ │ 3. Publish to MQTT │
│ │ topic: │
│ │ gym/{id}/checkin │
│ ├─────────────────────►│
│ │ │ 4. Record check-in
│ │ │ 5. Update occupancy
│ │ │ 6. Send notification
│ │ │
MQTT Bridge Details:
- A lightweight Electron or .NET app runs on the gym’s existing Windows PC (the same one connected to the biometric device)
- The bridge captures fingerprint events from the device SDK (Mantra RD Service or Secugen SDKs are well-documented)
- It matches the fingerprint against locally cached templates (synced from GymStack) and identifies the member
- It publishes a check-in event to an MQTT broker (AWS IoT Core or a managed Mosquitto instance)
- The GymStack backend subscribes to the MQTT topic and records the check-in
Fallback: If the biometric device or bridge fails, the front desk switches to QR code check-in. The system handles both methods seamlessly.
Terra API (Wearables)
Terra provides a single SDK that connects to 200+ wearable devices and health data sources. Instead of integrating separately with Apple Health, Fitbit, Garmin, Whoop, and Strava, GymStack integrates once with Terra.
Data Sync Architecture:
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Apple │ │ │ │ Terra │ │ GymStack │
│ Watch / │────►│ Terra │────►│ Webhook │────►│ Backend │
│ Fitbit /│ │ SDK │ │ │ │ │
│ Garmin │ │ (in app)│ │ Push │ │ Store │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
Data received:
- Daily steps, calories burned, active minutes
- Heart rate (resting, average, max)
- Sleep duration and quality
- Workout sessions (type, duration, calories)
Stored in: member_health_data table (JSONB per day)
Displayed in: Member app "My Progress" section
Used by: AI churn prediction (wearable activity as feature)
Privacy and Consent:
- Members explicitly opt in to wearable sync (not enabled by default)
- Consent screen explains what data is shared and how it’s used
- Data is stored encrypted and accessible only to the member and their trainer
- Members can disconnect their wearable and delete synced data at any time
- GymStack does not sell or share wearable data with third parties
AI/ML Layer
Churn Prediction Model
Problem: The average Indian gym has a 70-80% dropout rate. Most churn is invisible until it happens — the owner notices only at renewal time that the member has been gone for weeks. By then, it’s too late.
Solution: A machine learning model that predicts which members are likely to churn in the next 30 days, enabling proactive intervention.
Architecture:
┌─────────────────────────────────────────────────────────┐
│ CHURN PREDICTION PIPELINE │
│ │
│ ┌──────────┐ ┌──────────────┐ ┌──────────┐ │
│ │PostgreSQL│ │ Feature │ │ XGBoost │ │
│ │(raw data)│────►│ Engineering │────►│ Model │ │
│ └──────────┘ │ (Python) │ │ │ │
│ └──────────────┘ └────┬─────┘ │
│ │ │
│ Predictions │
│ │ │
│ ┌──────────────┐ ┌────▼─────┐ │
│ │ WhatsApp │◄────│ Alert │ │
│ │ Alert to │ │ Engine │ │
│ │ Gym Owner │ └──────────┘ │
│ └──────────────┘ │
└─────────────────────────────────────────────────────────┘
Features (Input Variables):
| Feature | Description | Weight |
|---|---|---|
check_in_frequency_last_14d | Average weekly check-ins in last 14 days | High |
check_in_trend | Is frequency increasing, stable, or decreasing? | High |
days_since_last_check_in | Days since member last visited | High |
payment_history | On-time payments vs. late/missed | Medium |
membership_age_days | How long they’ve been a member | Medium |
workout_completion_rate | % of assigned workouts completed | Medium |
trainer_interaction_count | Messages/sessions with trainer in last 30 days | Medium |
class_booking_rate | % of booked classes attended | Low |
app_open_frequency | How often they open the member app | Low |
wearable_activity | Steps/active minutes from synced wearable | Low |
membership_type | Plan tier (higher tiers churn less) | Low |
age_group | Demographic (younger members churn more) | Low |
Model Architecture:
- Algorithm: Gradient boosting (XGBoost) — chosen for its performance on tabular data with mixed feature types, handling of missing values (not all members have wearables or workout plans), and interpretability (feature importance rankings).
- Training data: Historical check-in, payment, and membership data. A member is labeled as “churned” if they did not renew within 15 days of expiry.
- Target accuracy: 85% precision at 70% recall (we’d rather alert on a false positive than miss a true churn).
- Update frequency: Model retrained monthly as more data accumulates. Feature drift monitoring to detect when model performance degrades.
Deployment:
- Microservice: Python FastAPI service running on ECS Fargate (separate from the Node.js backend). Communicates via internal HTTP API.
- Endpoint:
POST /predict/churnaccepts a batch of member feature vectors, returns churn probability scores (0.0-1.0). - Trigger: BullMQ job runs weekly (Sunday 2 AM IST). Fetches feature vectors from PostgreSQL, calls the prediction service, stores results in
member_churn_scorestable. - Alert: Members with score >0.7 are flagged in the admin dashboard’s “At Risk” widget. Gym owner gets a weekly WhatsApp summary: “5 members at high risk of leaving. Tap to view and take action.”
Workout Recommendation Engine
Approach: Hybrid of collaborative filtering and rule-based logic.
Collaborative Filtering: “Members similar to you (same age, goals, experience level) achieved best results with these workout programs.” Uses member-workout-outcome data to identify patterns. Cold start problem solved by rule-based fallback for new members.
Rule-Based Component:
- Body type classification: Ectomorph, mesomorph, endomorph (self-reported + measurements) -> base program recommendations
- Goal mapping: Weight loss -> higher rep ranges, more cardio. Muscle gain -> progressive overload, compound lifts. General fitness -> balanced program.
- Equipment availability: Gym’s equipment list (configured in admin) filters exercises. No point recommending cable flyes if the gym doesn’t have a cable machine.
- Past performance: Progressive overload algorithm: if member completed 4x10 at 50kg last week, suggest 4x10 at 52.5kg this week (or 4x12 at 50kg for rep progression).
- Injury/limitation awareness: If a member has a tagged knee injury, exercises with high knee stress (deep squats, jumping lunges) are excluded or replaced.
AI Diet Planning
Architecture: LLM-powered diet plan generation using Claude API (Anthropic).
How It Works:
- Trainer enters client details: weight, height, age, goal (fat loss/muscle gain/maintenance), activity level, dietary preference (veg/non-veg/egg), allergies, and budget (economy/moderate/premium)
- GymStack constructs a detailed prompt including: calculated macro targets, the Indian food database, and any dietary restrictions
- Claude API generates a complete 7-day meal plan with Indian foods, correct portions, and macro breakdowns
- The plan is parsed into structured data (JSON) and presented to the trainer for review
- Trainer can edit any meal, swap foods, and adjust portions before assigning to the client
Regional Cuisine Awareness: The prompt includes region-specific food options. A member in Chennai gets different suggestions (idli, dosa, sambar, rasam) than a member in Patna (litti-chokha, sattu, thekua). The system uses the gym’s location to default to regional cuisine while allowing override.
Cost Control:
- Each diet plan generation uses approximately 2,000-4,000 tokens (input + output)
- At Claude API pricing, this is roughly Rs 2-4 per plan generation
- Offered as a value-added feature: Rs 199/month per gym for unlimited AI diet plans
- Plans are cached — regeneration only needed when goals or preferences change
White-Label Build Pipeline
┌─────────────────────────────────────────────────────────────────┐
│ WHITE-LABEL CI/CD PIPELINE │
│ │
│ ┌──────────┐ ┌──────────────┐ ┌──────────────────────┐ │
│ │ GitHub │ │ GitHub │ │ Build Matrix │ │
│ │ Repo │───►│ Actions │───►│ │ │
│ │ (single │ │ Trigger: │ │ For each tenant: │ │
│ │ Flutter │ │ - push to │ │ 1. Load flavor │ │
│ │ codebase│ │ main │ │ config from DB │ │
│ │ ) │ │ - new tenant│ │ 2. Generate assets │ │
│ └──────────┘ │ added │ │ (icon, splash, │ │
│ │ - manual │ │ colors) │ │
│ └──────────────┘ │ 3. Flutter build │ │
│ │ --flavor={slug} │ │
│ │ 4. Sign with tenant │ │
│ │ keystore/cert │ │
│ └──────────┬───────────┘ │
│ │ │
│ ┌─────────────────┼──────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ ┌─────┐ │
│ │ Fastlane │ │ Fastlane │ │ OTA │ │
│ │ Android │ │ iOS │ │ Upd │ │
│ │ │ │ │ │ │ │
│ │ Upload │ │ Upload │ │Shorebird│
│ │ to Play │ │ to App │ │ or │ │
│ │ Console │ │ Store │ │ Code│ │
│ │ │ │ Connect │ │ Push│ │
│ └──────────┘ └──────────┘ └─────┘ │
│ │
│ Tenant Config (from gym_configs table): │
│ ┌────────────────────────────────────────────┐ │
│ │ { │ │
│ │ "slug": "profitness", │ │
│ │ "app_name": "Pro Fitness", │ │
│ │ "bundle_id": "in.profitness.app", │ │
│ │ "primary_color": "#FF6B00", │ │
│ │ "logo": "s3://assets/profitness/logo", │ │
│ │ "icon": "s3://assets/profitness/icon", │ │
│ │ "splash": "s3://assets/profitness/splash",│ │
│ │ "api_url": "https://api.gymstack.in", │ │
│ │ "keystore_secret": "aws-sm://profitness" │ │
│ │ } │ │
│ └────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Build Process Details:
- Trigger: A new app version is tagged on
main, or a new gym is onboarded to white-label - Config Loading: GitHub Action fetches tenant configs from the GymStack API (or a config repository). Each config specifies: slug, app name, bundle ID, colors, and asset URLs.
- Asset Generation: A script downloads the gym’s logo, icon, and splash screen from S3. It generates adaptive icons (Android) and App Icon sets (iOS) from the source images using
flutter_launcher_icons. It also generates the flavor-specificcolors.xml/Info.plistvalues. - Flutter Build:
flutter build appbundle --flavor profitness --target lib/main_profitness.dartfor Android.flutter build ipa --flavor profitness --target lib/main_profitness.dartfor iOS. - Signing: Android APK/AAB signed with the tenant’s upload keystore (stored in AWS Secrets Manager). iOS IPA signed with the tenant’s distribution certificate and provisioning profile.
- Upload: Fastlane uploads the signed build to Google Play Console and App Store Connect under the tenant’s developer account.
- OTA Updates: For non-native changes (new features, bug fixes in Dart code), Shorebird (or similar) enables over-the-air updates that bypass store review. This means bug fixes reach all white-label apps within minutes, not days.
Scale: This pipeline supports 100+ white-label tenants. Each build takes approximately 15-20 minutes. With GitHub Actions matrix strategy, 10 tenants build in parallel. A full re-release of all 100 tenants completes in under 4 hours.
Infrastructure
┌─────────────────────────────────────────────────────────────────┐
│ AWS INFRASTRUCTURE (ap-south-1) │
│ │
│ ┌─────────────┐ ┌───────────────────────────────────┐ │
│ │ CloudFront │ │ VPC (10.0.0.0/16) │ │
│ │ CDN │ │ │ │
│ │ (static + │ │ Public Subnets (2 AZs) │ │
│ │ media) │ │ ┌─────────────────────────────┐ │ │
│ └──────┬──────┘ │ │ ALB (Application Load │ │ │
│ │ │ │ Balancer) │ │ │
│ │ │ │ - HTTPS termination │ │ │
│ │ │ │ - Path-based routing │ │ │
│ ┌──────▼──────┐ │ └──────────────┬──────────────┘ │ │
│ │ S3 Bucket │ │ │ │ │
│ │ (media) │ │ Private Subnets (2 AZs) │ │
│ │ - progress │ │ ┌──────────────▼──────────────┐ │ │
│ │ photos │ │ │ ECS Fargate Cluster │ │ │
│ │ - exercise │ │ │ │ │ │
│ │ images │ │ │ ┌────────┐ ┌────────┐ │ │ │
│ │ - invoices │ │ │ │Fastify │ │Fastify │ │ │ │
│ │ - exports │ │ │ │API (x3)│ │API (x3)│ │ │ │
│ └─────────────┘ │ │ └────────┘ └────────┘ │ │ │
│ │ │ │ │ │
│ │ │ ┌────────┐ ┌────────┐ │ │ │
│ │ │ │Next.js │ │Python │ │ │ │
│ │ │ │Web (x2)│ │ML (x1) │ │ │ │
│ │ │ └────────┘ └────────┘ │ │ │
│ │ │ │ │ │
│ │ │ ┌────────┐ │ │ │
│ │ │ │BullMQ │ │ │ │
│ │ │ │Workers │ │ │ │
│ │ │ │(x2) │ │ │ │
│ │ │ └────────┘ │ │ │
│ │ └─────────────────────────────┘ │ │
│ │ │ │
│ │ Data Subnets (2 AZs) │ │
│ │ ┌─────────────────────────────┐ │ │
│ │ │ │ │ │
│ │ │ ┌──────────────┐ │ │ │
│ │ │ │ RDS │ │ │ │
│ │ │ │ PostgreSQL │ │ │ │
│ │ │ │ Multi-AZ │ │ │ │
│ │ │ │ db.r6g.large │ │ │ │
│ │ │ └──────────────┘ │ │ │
│ │ │ │ │ │
│ │ │ ┌──────────────┐ │ │ │
│ │ │ │ ElastiCache │ │ │ │
│ │ │ │ Redis 7 │ │ │ │
│ │ │ │ cache.r6g. │ │ │ │
│ │ │ │ medium │ │ │ │
│ │ │ └──────────────┘ │ │ │
│ │ │ │ │ │
│ │ └─────────────────────────────┘ │ │
│ └───────────────────────────────────┘ │
│ │
│ External Services: │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ GitHub │ │ Sentry │ │ PagerDuty │ │
│ │ Actions │ │ (errors) │ │ (alerting) │ │
│ │ (CI/CD) │ │ │ │ │ │
│ └────────────┘ └────────────┘ └────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Component Sizing (MVP / Phase 1):
| Component | Spec | Monthly Cost (approx.) |
|---|---|---|
| ECS Fargate - API (3 tasks) | 0.5 vCPU, 1GB RAM each | Rs 5,000 |
| ECS Fargate - Web (2 tasks) | 0.5 vCPU, 1GB RAM each | Rs 3,500 |
| ECS Fargate - Workers (2 tasks) | 0.25 vCPU, 0.5GB RAM each | Rs 2,000 |
| ECS Fargate - ML (1 task) | 1 vCPU, 2GB RAM | Rs 3,000 |
| RDS PostgreSQL (Multi-AZ) | db.r6g.large, 100GB | Rs 15,000 |
| ElastiCache Redis | cache.r6g.medium | Rs 5,000 |
| S3 + CloudFront | 50GB storage, 100GB transfer | Rs 1,500 |
| ALB | 1 ALB, moderate traffic | Rs 3,000 |
| Miscellaneous (ECR, CloudWatch, Secrets Manager, etc.) | — | Rs 2,000 |
| Total Infrastructure | Rs 40,000/month (~Rs 4.8L/year) |
This scales linearly. At 500 gyms, expect Rs 80,000-1,00,000/month as API tasks and database scale up.
Monitoring Stack:
- CloudWatch: Infrastructure metrics (CPU, memory, disk, network), ECS service health, RDS performance insights, ALB request counts and latency. Custom metrics for: active WebSocket connections, BullMQ queue depth, check-ins per minute.
- Sentry: Application-level error tracking for both backend (Node.js) and frontend (Flutter, Next.js). Source maps uploaded during CI/CD for readable stack traces. Alerts for new error types and error rate spikes.
- Custom Dashboards: Grafana (hosted on ECS) visualizing: API response times (p50/p95/p99), database query performance, Redis hit rates, BullMQ job processing times, and business metrics (daily check-ins, daily payments, active users).
- Alerting Rules:
- API error rate >1% for 5 minutes: Sentry alert to Slack
- API p95 latency >1s for 10 minutes: CloudWatch alarm to PagerDuty
- Database CPU >80% for 15 minutes: CloudWatch alarm to Slack
- BullMQ dead letter queue >10 jobs: Custom alert to Slack
- Payment webhook failure: Immediate PagerDuty alert (payments are critical path)
Security Architecture
Authentication Flow
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Mobile │ │ Fastify │ │ Redis │
│ App │ │ Backend │ │ │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
│ 1. POST /auth/otp/send │ │
│ { phone: "+91..." } │ │
├──────────────────────────────►│ │
│ │ 2. Generate 6-digit OTP│
│ │ 3. Store in Redis │
│ │ (TTL: 5 min) │
│ ├─────────────────────────►│
│ │ 4. Send OTP via SMS │
│ │ (MSG91 / Twilio) │
│ "OTP sent" │ │
│◄──────────────────────────────┤ │
│ │ │
│ 5. POST /auth/otp/verify │ │
│ { phone, otp: "123456" } │ │
├──────────────────────────────►│ │
│ │ 6. Verify OTP from │
│ │ Redis │
│ │◄─────────────────────────┤
│ │ 7. Delete OTP │
│ ├─────────────────────────►│
│ │ 8. Generate JWT pair │
│ │ access: 15min exp │
│ │ refresh: 30day exp │
│ │ 9. Store refresh token │
│ │ in Redis │
│ ├─────────────────────────►│
│ { access_token, refresh_token │ │
│ user, gym } │ │
│◄──────────────────────────────┤ │
│ │ │
│ 10. Store refresh_token in │ │
│ secure storage (keychain)│ │
│ │ │
JWT Payload:
{
"sub": "user_abc123",
"gym_id": "gym_xyz789",
"role": "trainer",
"permissions": ["read:clients", "write:workouts", "write:diets", "read:sessions"],
"iat": 1712000000,
"exp": 1712000900
}
Token Refresh: When the access token expires (every 15 minutes), the app automatically calls POST /auth/token/refresh with the refresh token. A new access/refresh pair is issued and the old refresh token is invalidated in Redis. This ensures that stolen tokens have a maximum 15-minute window.
Role-Based Access Control
┌────────────────────────────────────────────────────────────────┐
│ PERMISSION MATRIX │
│ │
│ Resource │ Owner │ Manager │ FrontDesk │Trainer│Member│
│ ──────────────────┼───────┼─────────┼───────────┼───────┼──────│
│ Members (CRUD) │ Full │ Full │ C/R/U │ R │ Self │
│ Members (delete) │ Yes │ No │ No │ No │ No │
│ Trainers (CRUD) │ Full │ Full │ R │ Self │ R │
│ Payments (view) │ Full │ Full │ Full │ Own │ Own │
│ Payments (refund) │ Yes │ No │ No │ No │ No │
│ Analytics │ Full │ Branch │ No │ Own │ No │
│ Financial reports │ Full │ Branch │ No │ No │ No │
│ Check-in │ Full │ Full │ Full │ Own │ Self │
│ Workout plans │ Full │ Full │ R │ Own │ Own │
│ Diet plans │ Full │ Full │ R │ Own │ Own │
│ Lead CRM │ Full │ Full │ Full │ No │ No │
│ Gym config │ Full │ R │ No │ No │ No │
│ Branch management │ Full │ Own │ No │ No │ No │
│ Billing config │ Full │ No │ No │ No │ No │
│ Staff management │ Full │ Branch │ No │ No │ No │
│ Communication │ Full │ Full │ Full │ Own │ No │
│ Audit logs │ Full │ No │ No │ No │ No │
└────────────────────────────────────────────────────────────────┘
Legend:
Full = All records across the gym/branch
Branch = Only records from their assigned branch
Own = Only records related to their assigned clients
Self = Only their own records
R = Read only
C/R/U = Create, Read, Update (no delete)
Data Encryption
- At Rest: RDS encryption using AWS KMS (AES-256). S3 server-side encryption (SSE-S3). ElastiCache encryption at rest. All EBS volumes encrypted.
- In Transit: TLS 1.3 enforced on ALB. Certificate pinning in Flutter apps. Internal service-to-service communication within the VPC uses TLS.
- Application-Level Encryption: Sensitive fields (Aadhaar number if collected, medical history notes) are encrypted at the application level before storage using
libsodium(NaCl). The encryption key is stored in AWS Secrets Manager, separate from the database.
API Security
- Rate Limiting: Per-API-key limits enforced via Redis (see Cache section). Stricter limits on auth endpoints to prevent brute force.
- Input Validation: Every request body validated against JSON Schema (Fastify’s built-in Ajv). Type checking, length limits, format validation (email, phone, UUID). Rejects malformed requests before they reach business logic.
- SQL Injection Prevention: All database queries use parameterized queries via the Knex.js query builder or Prisma ORM. No raw string concatenation in SQL.
- XSS Prevention: All user-generated content (member names, notes, community posts) is sanitized on input and encoded on output. Content Security Policy headers on the web dashboard.
- OWASP Top 10 Compliance: Regular dependency audits (
npm audit,snyk), security headers (HSTS, X-Frame-Options, X-Content-Type-Options), CORS configured per-origin (only GymStack domains allowed), and no sensitive data in URLs (all in request bodies or headers).
MVP Development Plan
Team Composition
| Role | Person | Responsibility |
|---|---|---|
| Product + Backend + Dashboard | Rakesh (founder) | Product decisions, Fastify API, PostgreSQL schema, Next.js admin dashboard, Razorpay integration |
| Mobile Lead (Flutter) | Hire #1 | Member app, Trainer app, Admin mobile companion, white-label build system |
| Full-Stack Engineer | Hire #2 | WhatsApp integration, BullMQ jobs, QR check-in system, supporting backend APIs |
| UI/UX Designer | Freelance/contract | Design system, all app screens, user testing |
Cost Estimate:
| Item | Amount |
|---|---|
| Flutter developer (6 months) | Rs 12-18L |
| Full-stack engineer (6 months) | Rs 8-12L |
| UI/UX designer (3-4 months, contract) | Rs 5-8L |
| Cloud infrastructure (6 months) | Rs 2.5-3L |
| Razorpay/WhatsApp API costs (6 months) | Rs 0.5-1L |
| Misc (testing devices, accounts, tools) | Rs 1-2L |
| Buffer (20%) | Rs 6-9L |
| Total | Rs 35-53L |
With Rakesh building the backend and dashboard, the cost stays under Rs 50L. Without a technical founder, it would be Rs 70-80L+.
Timeline: 4-6 Months to MVP
Month 1: Foundation
├── Week 1-2: Architecture setup
│ ├── PostgreSQL schema design + RLS policies
│ ├── Fastify project scaffolding + auth system
│ ├── Flutter project setup with flavor system
│ └── Next.js dashboard boilerplate
├── Week 3-4: Core APIs + basic screens
│ ├── Member CRUD API + admin member list screen
│ ├── Auth flow (OTP login) for all apps
│ ├── Gym config API + onboarding flow
│ └── Design system + component library (designer)
Month 2: Core Features
├── Week 5-6: Billing
│ ├── Razorpay integration (orders, webhooks)
│ ├── Payment recording (online + offline)
│ ├── GST invoice generation
│ └── Payment dashboard (admin)
├── Week 7-8: Check-in + Members
│ ├── QR code generation and scanning
│ ├── Check-in recording + history
│ ├── Member app: profile, check-in, payment history
│ └── WhatsApp: welcome message, payment receipt
Month 3: Workout + Diet
├── Week 9-10: Workout system
│ ├── Exercise library (seed 200+ exercises)
│ ├── Workout plan builder (trainer app)
│ ├── Plan assignment + member view
│ └── Workout logging (member app)
├── Week 11-12: Diet system
│ ├── Food database (Indian foods, 500+ items)
│ ├── Diet plan builder (trainer app)
│ ├── Plan assignment + member view
│ └── Meal check-off + daily macro tracking
Month 4: Retention + CRM
├── Week 13-14: Communication
│ ├── WhatsApp reminder sequences (renewal, expiry)
│ ├── Push notification system (Firebase)
│ ├── Birthday automation
│ └── Admin: communication hub
├── Week 15-16: Lead CRM + Analytics
│ ├── Lead capture + status pipeline
│ ├── Automated follow-up sequences
│ ├── Revenue dashboard + charts
│ └── Member analytics (check-in trends, at-risk)
Month 5: Polish + Pilot
├── Week 17-18: Trainer app completion
│ ├── Session management + calendar
│ ├── Client dashboard
│ ├── Client progress (basic: weight, measurements)
│ └── Class/batch booking system
├── Week 19-20: Testing + pilot prep
│ ├── End-to-end testing (all flows)
│ ├── Performance testing (load, stress)
│ ├── Pro Fitness pilot deployment
│ ├── Bug fixes from pilot feedback
│ └── Onboarding documentation + video tutorials
Month 6 (buffer): Iterate + Launch
├── Pilot feedback incorporation
├── Stability fixes
├── Onboard 10-20 additional pilot gyms in Patna
├── Production monitoring setup
└── Public launch
MVP Scope vs. Deferred Features
In MVP (Phase 1):
- Member management (profiles, plans, status)
- Billing with Razorpay (UPI, cards, GST invoicing)
- QR code check-in with attendance tracking
- Workout plan builder with exercise library
- Diet plan builder with Indian food database
- WhatsApp reminders (renewal, expiry, birthday)
- Basic lead tracking
- Revenue dashboard
- Class/batch booking
- Member app (workouts, diet, check-in, payments)
- Trainer app (clients, plans, sessions)
Deferred to Phase 2:
- White-label app publishing
- Biometric device integration
- 2-way WhatsApp chatbot
- Multi-branch dashboard
- Advanced analytics (cohort, retention curves)
- Hindi language support
- Progress photos and strength graphs
- Remote coaching (in-app messaging)
- Trainer earnings tracker
Deferred to Phase 3:
- AI churn prediction
- AI workout recommendations
- AI diet generation (LLM-powered)
- Wearable integration (Terra API)
- Community and gamification
- Offline-first capability
- Regional languages
- Supplement/merchandise marketplace
Database Schema (Key Tables)
-- =============================================
-- CORE TABLES
-- =============================================
CREATE TABLE gyms (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
owner_id UUID REFERENCES users(id),
phone TEXT,
email TEXT,
address JSONB, -- { line1, line2, city, state, pincode, lat, lng }
gstin TEXT,
timezone TEXT DEFAULT 'Asia/Kolkata',
plan_type TEXT DEFAULT 'starter',
config JSONB DEFAULT '{}', -- branding, features, limits
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
deleted_at TIMESTAMPTZ
);
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
phone TEXT UNIQUE NOT NULL,
name TEXT,
email TEXT,
role TEXT NOT NULL CHECK (role IN ('owner','manager','frontdesk','trainer','member')),
gym_id UUID NOT NULL REFERENCES gyms(id),
avatar_url TEXT,
is_active BOOLEAN DEFAULT true,
last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
deleted_at TIMESTAMPTZ
);
-- =============================================
-- MEMBER-SPECIFIC
-- =============================================
CREATE TABLE members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id),
gym_id UUID NOT NULL REFERENCES gyms(id),
name TEXT NOT NULL,
phone TEXT NOT NULL,
email TEXT,
date_of_birth DATE,
gender TEXT CHECK (gender IN ('male','female','other')),
photo_url TEXT,
emergency_contact JSONB, -- { name, phone, relation }
medical_history JSONB, -- { allergies, injuries, conditions, notes }
body_metrics JSONB, -- { height_cm, weight_kg, bmi, body_fat_pct }
fitness_goal TEXT, -- 'weight_loss', 'muscle_gain', 'general_fitness', 'sports'
source TEXT, -- 'walk_in', 'referral', 'instagram', 'google', 'justdial'
referred_by UUID REFERENCES members(id),
notes TEXT,
status TEXT DEFAULT 'active' CHECK (status IN ('active','expiring','expired','frozen','cancelled')),
engagement_score INTEGER DEFAULT 50,
churn_risk_score NUMERIC(3,2), -- 0.00 to 1.00 (from ML model)
gym_id UUID NOT NULL, -- for RLS
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
deleted_at TIMESTAMPTZ
);
CREATE TABLE memberships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
member_id UUID NOT NULL REFERENCES members(id),
gym_id UUID NOT NULL REFERENCES gyms(id),
plan_name TEXT NOT NULL, -- 'Silver', 'Gold', 'Platinum', or custom
plan_config JSONB, -- { price, duration_months, includes_pt, pt_sessions }
start_date DATE NOT NULL,
end_date DATE NOT NULL,
amount_paid NUMERIC(10,2),
amount_due NUMERIC(10,2) DEFAULT 0,
payment_mode TEXT, -- 'full', 'emi', 'partial'
status TEXT DEFAULT 'active' CHECK (status IN ('active','expired','frozen','cancelled','pending')),
auto_renew BOOLEAN DEFAULT false,
razorpay_subscription_id TEXT, -- for UPI autopay
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- =============================================
-- TRAINER-SPECIFIC
-- =============================================
CREATE TABLE trainers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id),
gym_id UUID NOT NULL REFERENCES gyms(id),
name TEXT NOT NULL,
phone TEXT NOT NULL,
photo_url TEXT,
specializations TEXT[], -- ['weight_loss', 'bodybuilding', 'yoga', 'functional']
certifications JSONB, -- [{ name, issuer, date, expiry, doc_url }]
experience_years INTEGER,
bio TEXT,
pt_rate NUMERIC(10,2), -- rate per PT session
batch_rate NUMERIC(10,2), -- rate per batch session
max_pt_clients INTEGER DEFAULT 25,
max_batch_size INTEGER DEFAULT 15,
schedule JSONB, -- weekly availability slots
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
deleted_at TIMESTAMPTZ
);
CREATE TABLE trainer_assignments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
trainer_id UUID NOT NULL REFERENCES trainers(id),
member_id UUID NOT NULL REFERENCES members(id),
gym_id UUID NOT NULL REFERENCES gyms(id),
type TEXT NOT NULL CHECK (type IN ('pt','batch','floor')),
status TEXT DEFAULT 'active' CHECK (status IN ('active','paused','ended')),
started_at TIMESTAMPTZ DEFAULT now(),
ended_at TIMESTAMPTZ,
UNIQUE(trainer_id, member_id, status) -- one active assignment per pair
);
-- =============================================
-- PAYMENTS
-- =============================================
CREATE TABLE payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
member_id UUID NOT NULL REFERENCES members(id),
membership_id UUID REFERENCES memberships(id),
gym_id UUID NOT NULL REFERENCES gyms(id),
amount NUMERIC(10,2) NOT NULL,
currency TEXT DEFAULT 'INR',
payment_method TEXT, -- 'upi', 'card', 'netbanking', 'wallet', 'cash', 'upi_offline'
status TEXT DEFAULT 'pending' CHECK (status IN ('pending','captured','failed','refunded')),
razorpay_order_id TEXT,
razorpay_payment_id TEXT,
razorpay_signature TEXT,
invoice_number TEXT,
invoice_url TEXT, -- S3 URL to PDF invoice
gst_amount NUMERIC(10,2),
base_amount NUMERIC(10,2),
hsn_sac_code TEXT DEFAULT '99971',
notes TEXT,
recorded_by UUID REFERENCES users(id), -- staff who recorded (for offline payments)
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- =============================================
-- CHECK-INS
-- =============================================
CREATE TABLE check_ins (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
member_id UUID NOT NULL REFERENCES members(id),
gym_id UUID NOT NULL REFERENCES gyms(id),
branch_id UUID REFERENCES gym_branches(id),
checked_in_at TIMESTAMPTZ NOT NULL DEFAULT now(),
checked_out_at TIMESTAMPTZ,
method TEXT DEFAULT 'qr' CHECK (method IN ('qr','biometric','manual','auto')),
verified_by UUID REFERENCES users(id), -- staff who scanned QR
duration_minutes INTEGER, -- calculated on checkout
created_at TIMESTAMPTZ DEFAULT now()
);
-- =============================================
-- WORKOUTS
-- =============================================
CREATE TABLE exercises (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
name_hi TEXT, -- Hindi name
muscle_groups TEXT[] NOT NULL, -- ['chest', 'triceps']
equipment TEXT[], -- ['barbell', 'bench']
difficulty TEXT CHECK (difficulty IN ('beginner','intermediate','advanced')),
instructions TEXT,
instructions_hi TEXT,
image_url TEXT,
animation_url TEXT,
is_global BOOLEAN DEFAULT true, -- true = available to all gyms
gym_id UUID REFERENCES gyms(id), -- null for global exercises
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE workout_plans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
gym_id UUID NOT NULL REFERENCES gyms(id),
created_by UUID NOT NULL REFERENCES trainers(id),
name TEXT NOT NULL, -- 'Push/Pull/Legs - Hypertrophy'
description TEXT,
duration_weeks INTEGER,
difficulty TEXT,
goal TEXT, -- 'muscle_gain', 'fat_loss', 'strength'
is_template BOOLEAN DEFAULT false,
schedule JSONB NOT NULL, -- { "monday": { "name": "Push", "exercises": [...] }, ... }
-- schedule.exercises: [{ exercise_id, sets, reps, rest_seconds, weight_kg, notes, order }]
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
deleted_at TIMESTAMPTZ
);
CREATE TABLE workout_assignments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
plan_id UUID NOT NULL REFERENCES workout_plans(id),
member_id UUID NOT NULL REFERENCES members(id),
trainer_id UUID NOT NULL REFERENCES trainers(id),
gym_id UUID NOT NULL REFERENCES gyms(id),
status TEXT DEFAULT 'active' CHECK (status IN ('active','completed','replaced')),
start_date DATE NOT NULL,
end_date DATE,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE workout_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
member_id UUID NOT NULL REFERENCES members(id),
gym_id UUID NOT NULL REFERENCES gyms(id),
plan_id UUID REFERENCES workout_plans(id),
exercise_id UUID NOT NULL REFERENCES exercises(id),
logged_at TIMESTAMPTZ DEFAULT now(),
sets JSONB NOT NULL, -- [{ "set": 1, "reps": 10, "weight_kg": 50, "rpe": 8 }, ...]
notes TEXT,
is_pr BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT now()
);
-- =============================================
-- DIET PLANS
-- =============================================
CREATE TABLE foods (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
name_hi TEXT,
category TEXT, -- 'grain', 'protein', 'dairy', 'vegetable', 'fruit', 'supplement'
cuisine TEXT, -- 'north_indian', 'south_indian', 'generic'
is_vegetarian BOOLEAN,
is_vegan BOOLEAN,
serving_size TEXT, -- '1 roti', '1 katori', '100g', '1 glass'
calories NUMERIC(6,1),
protein_g NUMERIC(6,1),
carbs_g NUMERIC(6,1),
fat_g NUMERIC(6,1),
fiber_g NUMERIC(6,1),
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE diet_plans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
gym_id UUID NOT NULL REFERENCES gyms(id),
created_by UUID NOT NULL REFERENCES trainers(id),
name TEXT NOT NULL,
description TEXT,
target_calories INTEGER,
target_protein INTEGER,
target_carbs INTEGER,
target_fat INTEGER,
dietary_type TEXT, -- 'vegetarian', 'non_vegetarian', 'eggetarian', 'vegan'
is_template BOOLEAN DEFAULT false,
meals JSONB NOT NULL, -- { "early_morning": { "time": "06:00", "items": [...] }, ... }
-- meals.items: [{ food_id, quantity, serving_size, calories, protein, carbs, fat }]
is_ai_generated BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
deleted_at TIMESTAMPTZ
);
CREATE TABLE diet_assignments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
plan_id UUID NOT NULL REFERENCES diet_plans(id),
member_id UUID NOT NULL REFERENCES members(id),
trainer_id UUID NOT NULL REFERENCES trainers(id),
gym_id UUID NOT NULL REFERENCES gyms(id),
status TEXT DEFAULT 'active',
start_date DATE NOT NULL,
end_date DATE,
created_at TIMESTAMPTZ DEFAULT now()
);
-- =============================================
-- LEADS (CRM)
-- =============================================
CREATE TABLE leads (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
gym_id UUID NOT NULL REFERENCES gyms(id),
name TEXT NOT NULL,
phone TEXT NOT NULL,
email TEXT,
source TEXT, -- 'walk_in', 'instagram', 'google', 'justdial', 'referral', 'website'
interest TEXT, -- 'membership', 'pt', 'group_class', 'specific_program'
status TEXT DEFAULT 'new' CHECK (status IN (
'new', 'contacted', 'interested', 'trial_booked',
'trial_completed', 'negotiating', 'converted', 'lost'
)),
score INTEGER DEFAULT 0, -- lead score 0-100
assigned_to UUID REFERENCES users(id),
notes TEXT,
follow_up_at TIMESTAMPTZ,
converted_at TIMESTAMPTZ,
converted_member_id UUID REFERENCES members(id),
lost_reason TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- =============================================
-- NOTIFICATIONS
-- =============================================
CREATE TABLE notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
gym_id UUID NOT NULL REFERENCES gyms(id),
recipient_id UUID NOT NULL REFERENCES users(id),
channel TEXT NOT NULL CHECK (channel IN ('whatsapp','push','sms','in_app')),
template TEXT, -- template name for WhatsApp
title TEXT,
body TEXT NOT NULL,
data JSONB, -- additional context (deep link, payment_id, etc.)
status TEXT DEFAULT 'pending' CHECK (status IN ('pending','sent','delivered','read','failed')),
sent_at TIMESTAMPTZ,
delivered_at TIMESTAMPTZ,
read_at TIMESTAMPTZ,
error_message TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
-- =============================================
-- SESSIONS (PT / CLASS)
-- =============================================
CREATE TABLE sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
gym_id UUID NOT NULL REFERENCES gyms(id),
trainer_id UUID NOT NULL REFERENCES trainers(id),
member_id UUID REFERENCES members(id), -- null for group classes
type TEXT NOT NULL CHECK (type IN ('pt','batch','assessment','trial')),
class_name TEXT, -- for batch: 'Yoga', 'Zumba', etc.
scheduled_at TIMESTAMPTZ NOT NULL,
duration_minutes INTEGER DEFAULT 60,
status TEXT DEFAULT 'scheduled' CHECK (status IN (
'scheduled', 'confirmed', 'completed', 'no_show', 'cancelled'
)),
notes TEXT, -- session notes by trainer
rating INTEGER CHECK (rating BETWEEN 1 AND 5),
feedback TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- =============================================
-- PROGRESS TRACKING
-- =============================================
CREATE TABLE member_measurements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
member_id UUID NOT NULL REFERENCES members(id),
gym_id UUID NOT NULL REFERENCES gyms(id),
measured_by UUID REFERENCES trainers(id),
measured_at DATE NOT NULL DEFAULT CURRENT_DATE,
weight_kg NUMERIC(5,2),
body_fat_pct NUMERIC(4,1),
muscle_mass_kg NUMERIC(5,2),
chest_cm NUMERIC(5,1),
waist_cm NUMERIC(5,1),
hips_cm NUMERIC(5,1),
bicep_left_cm NUMERIC(5,1),
bicep_right_cm NUMERIC(5,1),
thigh_left_cm NUMERIC(5,1),
thigh_right_cm NUMERIC(5,1),
calf_left_cm NUMERIC(5,1),
calf_right_cm NUMERIC(5,1),
notes TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE progress_photos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
member_id UUID NOT NULL REFERENCES members(id),
gym_id UUID NOT NULL REFERENCES gyms(id),
photo_url TEXT NOT NULL, -- S3 URL (encrypted bucket)
pose TEXT CHECK (pose IN ('front','side','back')),
taken_at DATE NOT NULL DEFAULT CURRENT_DATE,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
-- =============================================
-- MULTI-BRANCH
-- =============================================
CREATE TABLE gym_branches (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
gym_id UUID NOT NULL REFERENCES gyms(id),
name TEXT NOT NULL, -- 'Kankarbagh Branch', 'Boring Road Branch'
address JSONB,
phone TEXT,
manager_id UUID REFERENCES users(id),
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- =============================================
-- AUDIT LOG
-- =============================================
CREATE TABLE audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
gym_id UUID NOT NULL,
user_id UUID NOT NULL,
action TEXT NOT NULL, -- 'member.create', 'payment.refund', 'plan.delete'
resource_type TEXT NOT NULL, -- 'member', 'payment', 'workout_plan'
resource_id UUID,
old_data JSONB, -- previous state (for updates)
new_data JSONB, -- new state
ip_address INET,
user_agent TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Audit logs are append-only; no UPDATE or DELETE allowed
-- Partition by month for performance at scale
Entity Relationship Summary:
gyms ──────────┬──── gym_branches
│
├──── users (owner, manager, frontdesk)
│
├──── members ────┬──── memberships
│ ├──── payments
│ ├──── check_ins
│ ├──── workout_assignments ──── workout_plans
│ ├──── workout_logs
│ ├──── diet_assignments ──── diet_plans
│ ├──── member_measurements
│ ├──── progress_photos
│ └──── sessions
│
├──── trainers ───┬──── trainer_assignments
│ ├──── workout_plans (created_by)
│ ├──── diet_plans (created_by)
│ └──── sessions
│
├──── leads
│
├──── notifications
│
└──── audit_logs
Shared (no gym_id):
exercises (global library)
foods (global library)
Appendix: Technology Decision Log
| Decision | Chosen | Alternatives Considered | Rationale |
|---|---|---|---|
| Mobile framework | Flutter | React Native, Native (Kotlin+Swift) | White-label build flavors, single codebase, pixel-perfect UI consistency |
| State management | Riverpod | BLoC, Provider, GetX | Compile-safe DI, auto-dispose, best testability |
| Backend framework | Fastify | Express, NestJS, Hono | 5x throughput vs Express, schema-first design, TypeScript-native |
| Database | PostgreSQL | MongoDB, MySQL, PlanetScale | ACID for payments, RLS for multi-tenancy, window functions for analytics |
| Cache | Redis | Memcached | Pub/Sub for WebSocket, BullMQ dependency, data structures (sorted sets for leaderboards) |
| Job queue | BullMQ | Agenda, pg-boss, SQS | Redis-backed (already have Redis), robust retry/backoff, dashboard (Bull Board) |
| Cloud provider | AWS | GCP, Azure | Mumbai region maturity, RDS Multi-AZ, ECS Fargate simplicity |
| Payments | Razorpay | PayU, Cashfree, Stripe | UPI autopay, best Indian gym ecosystem fit, webhook reliability |
| Gupshup | Wati, Twilio, Meta direct | Pricing, India delivery reliability, 2-way chatbot support | |
| AI/LLM | Claude (Anthropic) | GPT-4 (OpenAI), Gemini | Quality of structured output, pricing, context window for diet plans |
| Wearables | Terra API | Direct integrations | Single SDK for 200+ devices, saves 6+ months of integration work |
| CI/CD | GitHub Actions | GitLab CI, CircleCI | GitHub ecosystem, Fastlane integration, matrix builds for white-label |
| Monitoring | CloudWatch + Sentry | Datadog, New Relic | Cost-effective at MVP scale; upgrade to Datadog at 500+ gyms if needed |