Skip to content

vonhatduc/workout

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

15 Commits
 
 
 
 
 
 
 
 

Repository files navigation

Everfit — Workout Logging API

A backend service for coaches to log client workouts and read back history and personal records. Built with NestJS + MongoDB (Mongoose), with a BullMQ/Redis queue that precomputes personal records for fast reads at scale.

Scope: features #1 Log, #2 History, #3 Personal Records. (#4 progress chart and #5 insights were descoped; the insights plugin pattern is discussed in the video and in Trade-offs.)

The app lives in everfit-api/.


Architecture overview

  POST /workouts                 GET history / prs
  (validate + normalize          (read path)
   + assign id)                        │
        │  202 Accepted                ▼
        ▼                       WorkoutsService
   WRITE_QUEUE ──► Redis ──► WriteProcessor          findHistory (cursor)
                              │ persist (Mongo)      getPersonalRecords:
                              │ then enqueue ───┐       read-model (O(1))
                              ▼                 │       └ fallback live $facet
                          MongoDB               ▼
                          workouts          PR_QUEUE ──► Redis ──► PrComputeProcessor
                                                                    │ $facet recompute
                                                                    ▼
                                                            personal_records (read-model)

Both the write and the PR precompute go through BullMQ:

  • POST validates + normalizes + assigns an id, then accepts onto WRITE_QUEUE and returns 202. A worker persists to workouts and triggers PR precompute. (Under test / QUEUE_ENABLED= false, an inline executor persists synchronously instead — no Redis needed, read-after-write holds.)

  • The PR worker recomputes all-time PRs into personal_records, so the common all-time PR query is O(1) (falls back to the live $facet when the read-model isn't filled yet).

  • One concern per module: units (weight conversion registry), exercises (muscle-group config), workouts (the three features), queue (write + PR precompute), health. Business logic never reads process.env — all config flows through ConfigService.

  • Every failure returns one envelope via AllExceptionsFilter (validation 400 is synchronous).


Setup

Option A — Docker (everything in containers)

cd everfit-api
docker compose up --build

Brings up the API (:3000), MongoDB (:27017) and Redis (:6379). The API connects to the mongo/redis service hosts inside the compose network.

Option B — Local (host) with your own Mongo + Redis

cd everfit-api
npm install
# defaults: MONGODB_URI=mongodb://localhost:27017/everfit, REDIS_HOST=localhost
cp .env.example .env   # optional; sensible defaults already baked in
npm run start:dev      # watch mode, pretty logs

Requires MongoDB on :27017 and Redis on :6379. To run without the queue (no Redis needed), set QUEUE_ENABLED=false — PRs then always compute live.

Useful commands

Command What it does
npm run start:dev Run with hot reload + pretty logs
npm test Unit tests (calculations, utils, filter, queue)
npm run test:e2e Integration tests (mongodb-memory-server)
npm run seed Insert 50,000 entries for one user (SEED_COUNT, SEED_USER_ID overridable)
npm run build Compile (strict TypeScript)

Explore the API


API documentation

All times are UTC, ISO-8601. Weights are stored normalized to kg; the original value + unit are preserved. See error codes for the failure shape.

POST /workouts — accept one or many entries (bulk, async)

Validated and normalized at the boundary, then accepted onto a queue: the response is 202 Accepted with the assigned ids; a worker persists the entries (and refreshes the PR read-model). They become visible via GET /workouts/history shortly after — eventual consistency.

// Request
{
  "userId": "665f1f77bcf86cd799439011",
  "entries": [
    {
      "exerciseName": "Bench Press",
      "date": "2026-06-09T00:00:00.000Z",      // ISO-8601 UTC (date-only or Z/offset)
      "sets": [{ "reps": 5, "weight": 100, "unit": "kg" }],
      "idempotencyKey": "client-uuid-1"          // optional — exactly-once on retries
    }
  ]
}
// 202 Accepted
{
  "accepted": 1,
  "entries": [
    { "_id": "6a27...db", "exerciseName": "Bench Press", "status": "accepted" }
  ]
}

Validation errors still return 400 synchronously (before anything is accepted). Idempotency and the kg normalization happen on the persist path; check the stored result via GET history.

GET /workouts/history — browse with filters + cursor pagination

Query: userId (required), exercise (prefix match), from, to, muscleGroup, unit (output conversion), limit (1–100, default 20), cursor.

// 200
{
  "items": [ /* entries, sets converted to `unit` if given */ ],
  "count": 20,
  "nextCursor": "eyJkIjoi..."   // opaque; pass back as ?cursor= ; null when no more pages
}
// no matches -> 200 { "items": [], "count": 0, "nextCursor": null, "message": "No workouts found..." }

GET /workouts/prs — personal records (+ range comparison)

Query: userId (required), exercise (required), from, to, unit.

// 200
{
  "userId": "665f...011", "exercise": "Bench Press", "unit": "kg",
  "current": {
    "range": null,                                       // null = all-time
    "heaviestSet":     { "weight": 110, "reps": 3, "unit": "kg", "date": "..." },
    "highestVolumeSet":{ "volume": 900, "weight": 90, "reps": 10, "unit": "kg", "date": "..." },
    "bestOneRepMax":   { "oneRepMax": 121, "weight": 110, "reps": 3, "unit": "kg", "date": "..." }
  },
  "previous": { /* same shape, preceding equal-length window — only when from+to given */ },
  "deltas":   { "heaviestWeight": 10, "highestVolume": 50, "bestOneRepMax": 11.67 }
}
  • Heaviest = max weightKg. Volume = reps × weightKg. 1RM = Epley weight × (1 + reps/30), computed on kg so kg/lb compare fairly. Ties break to the earliest date (first time achieved).

GET /health

{ "status": "ok", "info": { "mongodb": { "status": "up" } }, "details": { ... } }   // 503 if Mongo is down

Error codes

Every failure returns one envelope:

{ "statusCode": 400, "error": "Bad Request", "message": "Validation failed",
  "details": ["reps must not be less than 1"],   // optional (validation array)
  "timestamp": "2026-06-09T...Z", "path": "/workouts" }
Code When
400 Validation (bad unit, reps<1, weight<0, empty sets/entries, non-ObjectId userId, ambiguous/offset-less date), malformed cursor
404 Unknown route
409 Duplicate-key conflict
500 Unexpected error — generic message, real error logged (never leaked)

Database schema & design decisions

workouts collection

WorkoutEntry { userId: ObjectId, exerciseName, exerciseNameLower,
               date: Date (UTC), sets: [ { reps, weight, unit, weightKg } ],
               idempotencyKey?, createdAt, updatedAt }
  • Embedded sets (subdocuments): sets are always read/written with their entry and are few — no separate collection needed.
  • weightKg stored alongside the original weight+unit: normalize-on-write so queries/aggregations compare across units without losing what the user entered.
  • exerciseNameLower: search and indexes use the normalized name, never mixed case.
  • Muscle group is not stored — resolved live from a config map, so re-tagging an exercise never leaves persisted rows stale.

Indexes

Index Serves
{ userId, date:-1 } History newest-first / date range
{ userId, exerciseNameLower, date:-1 } Exercise filter + per-exercise PR scans
{ userId, date:-1, _id:-1 } Cursor pagination (keyset, _id tie-break)
{ userId, idempotencyKey } unique partial Idempotency (only when a key is present)

personal_records collection (read-model)

PersonalRecord { userId, exerciseNameLower, heaviest, volume, oneRepMax }   // unique {userId, exerciseNameLower}

Maintained asynchronously by the PR-compute worker so the all-time PR query is O(1).

Other decisions

  • Pagination = keyset (cursor), not offset — stable and fast at 50k+ rows; the (date, _id) cursor never skips/dupes same-day entries.
  • Idempotency via the unique partial index + upsert + duplicate-key catch: concurrent identical submits (with a key) collapse to one entry; keyless submits stay independent (they are legitimately separate logs).

Trade-offs & what I'd change at scale

Async writes (202 + eventual consistency). POST validates + normalizes synchronously, then accepts the batch onto a queue and returns 202 with the assigned ids; a worker persists. This decouples write load from the request and absorbs bursts. Trade-off: a just-accepted entry is not instantly readable (it appears within ~one worker cycle), and the client gets ids rather than the persisted body. For a cheap indexed insert this is a deliberate scalability choice, not a latency win per request — for a simpler contract, the persist handler can also be called inline (it already is, under QUEUE_ENABLED=false).

Timezone. The API contract is UTC, ISO-8601. A datetime without an offset is rejected (it would otherwise be parsed in the server's local timezone — different instant on host vs Docker vs CI). Clients send date-only (YYYY-MM-DD, treated as UTC midnight) or a datetime with Z/offset.

userId validated as a Mongo ObjectId. This couples the public contract to the storage ID format — accepted for the monolith (it is an ObjectId, fail-fast, and it prevents a 500 from new ObjectId(badInput)). If user identity moves to its own service or a non-Mongo store, treat userId as an opaque id and push ObjectId conversion into a persistence adapter (also map the resulting BSONError → 400).

10,000 concurrent coaches — in priority order (the DB is the bottleneck before the stateless app tier is):

  1. Connection pooling — bounded Mongo pool per instance; scale instances behind a LB.
  2. Read replicas — route history/PR reads to secondaries; writes to primary.
  3. Shard workouts on userId — every query is user-scoped, so reads are targeted (no scatter-gather); existing indexes already lead with userId.
  4. PR read-model (already built) — async precompute keeps the hot PR read O(1); extend with Redis caching / TTL if needed.
  5. Backpressure — gateway rate-limits per coach; cap bulk payload size.
  6. Observability — structured pino logs (per-request id) + p99 metrics + tracing.

Queue, then microservices. Both writes and PR precompute already run through BullMQ. The next step at scale is an insights worker (each insight type a consumer = the plugin pattern). Splitting into microservices (separate Ingestion / Query / Insights services) comes only when their scaling profiles diverge — the current module seams (workouts, queue, units, exercises) map directly onto those future services.


Testing

  • Unit (npm test): every calculation + pure util — unit conversion, Epley 1RM, cursor encode/decode, regex-injection escaping, muscle-group resolution, the error filter (incl. no-500-leak), the queue producers + read-model mapping, UTC date parsing.
  • Integration (npm run test:e2e, mongodb-memory-server): each endpoint, plus user isolation, multi-set PR selection, a real concurrency race (N simultaneous identical submits collapse to one entry), the timezone contract, and the one error shape.
  • Test design over coverage %: meaningful edge cases, not line-count.

See AI_WORKFLOW.md for how this was built with AI assistance.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors