Skip to content

Latest commit

 

History

History
1466 lines (1044 loc) · 56.6 KB

File metadata and controls

1466 lines (1044 loc) · 56.6 KB

API Reference

Back to README

Overview

fitai.coach exposes a RESTful API under the /api/v1/ prefix. All endpoints (except the health check and cron trigger) require Clerk JWT authentication. Request bodies are validated with Zod schemas, and rate limiting is applied per-user per-category.

Base URL: /api/v1 Content-Type: application/json (requests and responses) Authentication: Clerk JWT via credentials: "include" (cookie-based)

Machine-readable spec: a committed OpenAPI 3.0 snapshot lives at docs/openapi.json. It is regenerated from the same Zod registry that powers Swagger UI (shared/openapi.ts) via pnpm docs:openapi and is CI-gated — the Build workflow fails if the committed file drifts from the schemas. Use it for client-code generation, contract testing, or importing into tools like Postman / Insomnia. Endpoints marked below that are not yet registered with the registry are not in the snapshot; coverage is being broadened as routes are migrated.


Table of Contents


Error Responses

All errors follow a standard format:

{
  "error": "Human-readable error message",
  "code": "ERROR_CODE",
  "details": { "issues": [{ "path": "field", "message": "..." }] }
}
  • details is only included for validation errors and non-500 responses.
  • 500 errors always return "Internal Server Error" to prevent leaking internals.

Validation error example (400):

{
  "error": "Invalid workout data",
  "code": "VALIDATION_ERROR",
  "details": {
    "issues": [
      { "path": "date", "message": "Must be a valid date in YYYY-MM-DD format" },
      { "path": "rpe", "message": "Number must be less than or equal to 10" },
      { "path": "exercises[0].exerciseName", "message": "String must contain at least 1 character(s)" }
    ]
  }
}

Rate limit error example (429):

HTTP/1.1 429 Too Many Requests
Retry-After: 45
RateLimit-Limit: 5
RateLimit-Remaining: 0
RateLimit-Reset: 1710500045
{
  "error": "Rate limit exceeded",
  "code": "RATE_LIMITED"
}

Not found error example (404):

{
  "error": "Workout not found",
  "code": "NOT_FOUND"
}

Common HTTP status codes:

Status Code Meaning
400 BAD_REQUEST, VALIDATION_ERROR, INVALID_CSV Invalid input
401 UNAUTHORIZED Missing or invalid auth
404 NOT_FOUND Resource not found
429 RATE_LIMITED Rate limit exceeded (includes Retry-After header)
500 INTERNAL_SERVER_ERROR Server error

Rate Limiting

Rate limits are applied per-user (keyed by Clerk userId) and namespaced by category so limits are independent across route groups.

  • Default window: 60 seconds
  • Strava routes: 15-minute window
  • Response on limit: 429 with Retry-After header and RATE_LIMITED code
  • Headers: Standard RateLimit-* headers (RFC 6585)
  • Storage: PostgreSQL-backed rate_limit_buckets, shared across app replicas outside tests

Implementation: server/routeUtils.tsrateLimiter(category, maxRequests, windowMs)


CSRF Protection

All mutating endpoints (POST/PUT/PATCH/DELETE) require a valid CSRF token. The token is obtained via:

GET /api/v1/csrf-token

Retrieve a CSRF token for use in subsequent mutating requests.

  • Auth: Not strictly required (works pre-login, bound to IP; after login, bound to userId)
  • Response: { token: string }
  • Side effects: Sets a signed __Host-fitai.x-csrf cookie (production) or fitai.x-csrf (development)

The returned token must be sent as the x-csrf-token header on all mutating requests. Missing or invalid tokens result in a 403 Forbidden response.


Idempotency

Mutating endpoints support the X-Idempotency-Key header for safe request replay.

  • Header: X-Idempotency-Key (optional, max 255 characters)
  • Behavior: When present on a mutating request (POST/PUT/PATCH/DELETE), the server caches the response for 7 days keyed by (userId, key). Repeat requests with the same key return the cached response without re-executing the handler.
  • Use case: The client's offline queue sends this header when replaying mutations that were queued while offline, preventing duplicate state changes.

Request Validation

Most protected mutation routes now use a shared builder (protectedPost, protectedPatch, protectedDelete) that codifies middleware order and keeps error responses consistent.

Protected mutation builder contract

For builder-backed routes, middleware runs in this order:

  1. protectedMutationGuards (auth + idempotency guard chain)
  2. route-local limiter (rateLimiter(...))
  3. route-specific middleware list (typically validateBody(...), validateParams(...), and optional feature guards)
  4. handler (wrapped with asyncHandler for async routes)

This ordering guarantees auth/idempotency and cost guards execute before validation or business logic, while still letting validation produce the canonical VALIDATION_ERROR shape. CSRF is enforced separately at router registration (app.use("/api/v1", csrfProtection)).

Endpoints use Zod schemas for request body validation via two patterns:

  1. validateBody(schema) middleware — Parses req.body with the schema, returns 400 on failure, replaces req.body with parsed data on success.
  2. Inline safeParse() — Used in routes that need custom error messages or partial validation.

Validation errors return:

{
  "error": "First validation error message",
  "code": "VALIDATION_ERROR",
  "details": {
    "issues": [
      { "path": "field.nested", "message": "Must be at least 1" }
    ]
  }
}

Auth Routes

File: server/routes/auth.ts

GET /api/v1/auth/user

Returns the current authenticated user's profile. Creates the user in the database if they don't exist yet (first-call sync from Clerk).

  • Auth: Required
  • Rate limit: auth category, 20/min
  • Response: User object (id, email, firstName, lastName, profileImageUrl, preferences)

Account Routes

File: server/routes/account.ts

DELETE /api/v1/account

Permanently delete the authenticated user's account and all associated data (GDPR "right to erasure").

  • Auth: Required
  • Rate limit: accountDelete category, 3/min
  • Body: none
  • Response: { "success": true } (or 404 { "error": "User not found", "code": "NOT_FOUND" })
  • Side effects, in order:
    1. Clerk identity is deleted first. If Clerk returns HTTP 404, the identity is treated as already-deleted (idempotent retry); any other error aborts the request so the DB row is not orphaned. Without this ordering, ensureUserExists on the next authenticated request would silently re-provision the account.
    2. Best-effort Strava deauthorizationPOST https://www.strava.com/oauth/deauthorize is called with the stored access token. Failures are logged and ignored (non-fatal).
    3. DB user row is deleted. FK ON DELETE CASCADE cleans up: workout_logs, exercise_sets, training_plans, plan_days, chat_messages, coaching_materials, document_chunks, strava_connections, garmin_connections, custom_exercises, push_subscriptions, ai_usage_logs, idempotency_keys, and timeline_annotations.
    4. Auth seen-cache evictionevictUserFromSeenCache(userId) clears the local and shared 5-minute ensureUserExists cache so a stale Clerk session held by another tab or replica cannot re-provision the user within the TTL window.

Workout Routes

Files: server/routes/workouts/ — a composite router (index.ts) that mounts workoutsCrud.routes.ts, workoutsAi.routes.ts, workoutsTimeline.routes.ts, workoutsExport.routes.ts, and workoutsMigration.routes.ts.

GET /api/v1/workouts

List workout logs for the current user with pagination.

  • Auth: Required
  • Rate limit: workoutList category, 60/min
  • Query params: limit (default 50, max capped), offset (default 0)
  • Response: WorkoutLog[]

GET /api/v1/workouts/latest

Get the most recent workout log for the current user.

  • Auth: Required
  • Rate limit: workout category, 60/min
  • Response: WorkoutLog with exerciseSets and structureBlocks
  • 404: No workouts found

GET /api/v1/workouts/:id

Get a single workout log by ID. If the workout has structure blocks but no exercise sets, the missing sets are derived from the structure on read.

  • Auth: Required
  • Rate limit: workout category, 60/min
  • Response: WorkoutLog with exerciseSets and structureBlocks
  • 404: Workout not found

POST /api/v1/workouts

Create a new workout log, optionally with parsed exercises and/or structure blocks.

  • Auth: Required
  • Rate limit: workout category, 40/min
  • Body: InsertWorkoutLog fields + optional exercises: ParsedExercise[] + structureBlocks
  • Validation: createWorkoutRouteSchema (insertWorkoutLogSchema extended with exercisesPayloadSchema + structureBlocksPayloadSchema)
  • Side effects: If user has AI coach enabled, sets isAutoCoaching flag and queues an auto-coach job. A text-only write guard (rejectTextOnlyWriteIfNeeded) may reject the request when structured exercise data is required.
  • Response: Created WorkoutLog with expanded exerciseSets

Request example:

{
  "date": "2025-03-15",
  "focus": "Strength and Running",
  "mainWorkout": "4x8 back squat at 80kg, then 5km easy run",
  "accessory": "3x12 lunges, 3x15 wall balls",
  "duration": 75,
  "rpe": 7,
  "exercises": [
    {
      "exerciseName": "back_squat",
      "category": "strength",
      "confidence": 95,
      "sets": [
        { "setNumber": 1, "reps": 8, "weight": 80 },
        { "setNumber": 2, "reps": 8, "weight": 80 },
        { "setNumber": 3, "reps": 8, "weight": 80 },
        { "setNumber": 4, "reps": 8, "weight": 80 }
      ]
    },
    {
      "exerciseName": "easy_run",
      "category": "running",
      "confidence": 90,
      "sets": [
        { "setNumber": 1, "distance": 5000, "time": 28 }
      ]
    }
  ]
}

Response example:

{
  "id": "wl_abc123",
  "userId": "user_456",
  "date": "2025-03-15",
  "focus": "Strength and Running",
  "mainWorkout": "4x8 back squat at 80kg, then 5km easy run",
  "accessory": "3x12 lunges, 3x15 wall balls",
  "duration": 75,
  "rpe": 7,
  "source": "manual",
  "createdAt": "2025-03-15T10:30:00.000Z",
  "exerciseSets": [
    {
      "id": "es_001",
      "workoutLogId": "wl_abc123",
      "exerciseName": "back_squat",
      "category": "strength",
      "setNumber": 1,
      "reps": 8,
      "weight": 80,
      "distance": null,
      "time": null
    },
    {
      "id": "es_002",
      "workoutLogId": "wl_abc123",
      "exerciseName": "back_squat",
      "category": "strength",
      "setNumber": 2,
      "reps": 8,
      "weight": 80,
      "distance": null,
      "time": null
    }
  ]
}

PATCH /api/v1/workouts/:id

Update an existing workout log.

  • Auth: Required
  • Rate limit: workout category, 40/min
  • Body: Partial UpdateWorkoutLog fields + optional exercises: ParsedExercise[] + structureBlocks
  • Validation: updateWorkoutRouteSchema
  • Response: Updated WorkoutLog

DELETE /api/v1/workouts/:id

Delete a workout log and its exercise sets.

  • Auth: Required
  • Rate limit: workout category, 40/min
  • Response: { success: true }

POST /api/v1/workouts/:id/sets

Add a single exercise set to a workout log.

  • Auth: Required (user must own the workout)
  • Rate limit: workoutSet category, 60/min
  • Body: addExerciseSetBodySchema
  • Response: 201 Created exercise set (or 404 when the workout doesn't belong to the user)

PATCH /api/v1/workouts/:id/sets/:setId

Update a single exercise set on a workout log.

  • Auth: Required (user must own the workout)
  • Rate limit: workoutSet category, 120/min
  • Body: patchExerciseSetBodySchema
  • Response: Updated exercise set (or 404)

DELETE /api/v1/workouts/:id/sets/:setId

Delete a single exercise set from a workout log.

  • Auth: Required (user must own the workout)
  • Rate limit: workoutSet category, 60/min
  • Response: { success: true } (or 404)

POST /api/v1/workouts/:id/seed-from-plan

Seed a workout log's exercise sets from its linked plan day.

  • Auth: Required
  • Rate limit: workoutSet category, 20/min
  • Response: { seededCount: number }

PATCH /api/v1/workouts/:id/structure-blocks/:blockId/score

Set or clear the score on a single structure block of a workout log.

  • Auth: Required
  • Rate limit: workout category, 40/min
  • Body: { score: StructureBlockScore | null }
  • Response: { structureBlocks } (or 404)

GET /api/v1/workouts/:id/history

Get history stats for a workout log.

  • Auth: Required
  • Rate limit: workoutHistory category, 60/min
  • Response: Workout history stats (or 404)

POST /api/v1/workouts/bulk-delete

Delete multiple workout logs and/or plan days in a single request.

  • Auth: Required
  • Rate limit: workoutBulkDelete category, 20/min
  • Body: { workoutLogIds: string[], planDayIds: string[] } — each capped at 100 entries, at least one id required
  • Response: Bulk delete result (or 404 when a target is not found)

POST /api/v1/workouts/combine

Combine multiple workout logs into a single new workout, deleting the sources.

  • Auth: Required
  • Rate limit: workout category, 10/min
  • Body: { newWorkout: InsertWorkoutLog, deleteWorkoutIds: string[] (1-10), skipPlanDayIds?: string[] (max 10) }
  • Response: 201 Created WorkoutLog

GET /api/v1/workouts/unstructured

List workouts that have no parsed exercise sets (candidates for reparsing).

  • Auth: Required
  • Rate limit: workoutList category, 60/min
  • Response: WorkoutLog[]

POST /api/v1/workouts/:id/reparse

Re-parse a single workout's text into structured exercise sets using the configured text AI provider. Writes the parsed sets through; responds 422 PARSE_WRITE_THROUGH_REQUIRED when parsing produces no persisted sets.

  • Auth: Required (user must own the workout)
  • Rate limit: reparse category, 5/min
  • AI gates: aiConsentCheck, aiBudgetCheck
  • Body: Optional { prescribedMainWorkout?: string | null, prescribedAccessory?: string | null }
  • Response: { exercises: ParsedExercise[], saved: boolean, setCount: number, rejectedCount: number, rejectionReasons: string[] }

POST /api/v1/workouts/batch-reparse

Re-parse all unstructured workouts for the current user.

  • Auth: Required
  • Rate limit: batchReparse category, 2/min
  • AI gates: aiConsentCheck, aiBudgetCheck
  • Response: { total: number, parsed: number, failed: number }

GET /api/v1/workouts/migration/reviews

List assisted-migration backfill reviews for the current user.

  • Auth: Required
  • Rate limit: migrationReviews category, 20/min
  • Query: ownerType? (workoutLog | planDay), ownerId? — must be supplied together
  • Response: Backfill review list

POST /api/v1/workouts/migration/backfill

Run an assisted-migration backfill pass for the current user.

  • Auth: Required
  • Rate limit: migrationBackfill category, 2/min
  • Response: Backfill result summary

POST /api/v1/workouts/migration/reviews/resolve

Resolve a single assisted-migration backfill review.

  • Auth: Required
  • Rate limit: migrationReviewResolve category, 20/min
  • Body: { ownerType: "workoutLog" | "planDay", ownerId: string, action: "accept" | "reject" | "edit", reason?: string | null }
  • Response: { ok: true } (or 404 when the target is not found)

Custom Exercise Routes

File: server/routes/workouts/workoutsCrud.routes.ts

GET /api/v1/custom-exercises

List all custom exercises defined by the current user.

  • Auth: Required
  • Rate limit: customExercise category, 60/min
  • Response: CustomExercise[]

POST /api/v1/custom-exercises

Create or upsert a custom exercise.

  • Auth: Required
  • Rate limit: customExercise category, 20/min
  • Body: { name: string, category?: string } (category defaults to "conditioning")
  • Validation: createCustomExerciseSchema (insertCustomExerciseSchema without userId)
  • Response: CustomExercise

Training Plan Routes

File: server/routes/plans.ts

GET /api/v1/plans

List all training plans for the current user.

  • Auth: Required
  • Response: TrainingPlan[]

GET /api/v1/plans/:id

Get a training plan with all its days.

  • Auth: Required
  • Response: TrainingPlanWithDays

POST /api/v1/plans/import

Import a training plan from CSV content.

  • Auth: Required
  • Rate limit: planImport category, 5/min
  • Body: { csvContent: string, fileName?: string, planName?: string }
  • Validation: importPlanRequestSchema (csvContent max 100,000 chars)
  • Response: TrainingPlanWithDays

POST /api/v1/plans/sample

Create the built-in sample Hyrox training plan.

  • Auth: Required
  • Rate limit: planSample category, 5/min
  • Response: TrainingPlanWithDays

POST /api/v1/plans/generate

Kick off an asynchronous AI training-plan generation. The request creates a pending plan immediately, enqueues a background plan-generation job (see Integrations — Job Queue), and returns the stub so the client can poll for completion.

  • Auth: Required
  • Rate limit: planGenerate category, 3/min
  • AI gates: aiConsentCheck, aiBudgetCheck
  • Body: GeneratePlanInput{ goal, totalWeeks (1-24), daysPerWeek (2-7), experienceLevel, raceDate?, startDate?, restDays?, focusAreas?, injuries? }
  • Validation: generatePlanInputSchema
  • Response: 202 Accepted with the pending TrainingPlan stub. Poll GET /api/v1/plans/:id/generation-status for progress.
  • 409 PLAN_GENERATION_IN_PROGRESS: Returned when the user already has a generation in flight (hasInFlightPlanGeneration), so rapid re-submits (triple-clicks, retries) can't enqueue parallel jobs that each burn AI budget and race to write the same user's plans.

GET /api/v1/plans/:id/generation-status

Poll the status of an asynchronous plan generation.

  • Auth: Required
  • Rate limit: planStatus category, 60/min
  • Response: { planId: string, generationStatus: "pending" | "generating" | "ready" | "failed", error?: string } (404 when the plan is not found)

PATCH /api/v1/plans/:id

Rename a training plan.

  • Auth: Required
  • Rate limit: planUpdate category, 20/min
  • Body: { name: string } (1-255 chars)
  • Response: Updated TrainingPlan

PATCH /api/v1/plans/:id/goal

Update a training plan's goal.

  • Auth: Required
  • Rate limit: planUpdate category, 20/min
  • Body: { goal: string | null } (max 500 chars)
  • Response: Updated TrainingPlan

PATCH /api/v1/plans/:planId/days/:dayId

Update a plan day scoped to its parent plan.

  • Auth: Required
  • Rate limit: planDayUpdate category, 20/min
  • Body: Partial UpdatePlanDay (focus, mainWorkout, accessory, notes, status, scheduledDate)
  • Validation: updatePlanDaySchema
  • Response: Updated PlanDay

PATCH /api/v1/plans/days/:dayId

Update a plan day with cleanup (unlinks workout logs when changing status away from completed).

  • Auth: Required
  • Rate limit: planDayUpdate category, 20/min
  • Body: Partial UpdatePlanDay
  • Response: Updated PlanDay

PATCH /api/v1/plans/days/:dayId/status

Update only the status, scheduled date and/or skip reason of a plan day.

  • Auth: Required
  • Rate limit: planDayStatus category, 20/min
  • Body: { status?: "planned" | "completed" | "missed" | "skipped", scheduledDate?: string | null, skipReason?: "ill" | "injured" | "schedule" | "low_energy" | null }
  • Response: Updated PlanDay
  • Notes: skipReason is only meaningful alongside status: "skipped" — any other status clears it. Omitting the key leaves an existing reason untouched; sending null clears it explicitly. Sending only scheduledDate (no status) takes the reschedule path and skips the status-transition check.

DELETE /api/v1/plans/:id

Delete a training plan and all its days (cascade).

  • Auth: Required
  • Rate limit: planDelete category, 10/min
  • Response: { success: true }

DELETE /api/v1/plans/days/:dayId

Delete a single plan day.

  • Auth: Required
  • Rate limit: planDayDelete category, 10/min
  • Response: { success: true }

POST /api/v1/plans/:planId/schedule

Schedule a plan by assigning dates to all days starting from a given date.

  • Auth: Required
  • Rate limit: planSchedule category, 10/min
  • Body: { startDate: "YYYY-MM-DD" }
  • Validation: schedulePlanRequestSchema
  • Response: { success: true }

GET /api/v1/plans/days/:dayId/sets

List the exercise sets for a plan day. With ?includeStructure=true, also returns structure blocks (and derives missing sets from structure when needed).

  • Auth: Required
  • Rate limit: planDaySet category, 60/min
  • Query: includeStructure? ("true")
  • Response: ExerciseSet[], or { exerciseSets, structureBlocks } when includeStructure=true (or 404)

POST /api/v1/plans/days/:dayId/sets

Add a single exercise set to a plan day.

  • Auth: Required
  • Rate limit: planDaySet category, 60/min
  • Body: addExerciseSetBodySchema
  • Response: 201 Created exercise set (or 404)

PATCH /api/v1/plans/days/:dayId/sets/:setId

Update a single exercise set on a plan day.

  • Auth: Required
  • Rate limit: planDaySet category, 120/min
  • Body: patchExerciseSetBodySchema
  • Response: Updated exercise set (or 404)

DELETE /api/v1/plans/days/:dayId/sets/:setId

Delete a single exercise set from a plan day.

  • Auth: Required
  • Rate limit: planDaySet category, 60/min
  • Response: { success: true } (or 404)

PATCH /api/v1/plans/days/:dayId/structure

Replace the structure blocks of a plan day.

  • Auth: Required
  • Rate limit: planDaySet category, 60/min
  • Body: { structureBlocks: StructureBlock[] }
  • Response: The updated plan day structure (or 404)

POST /api/v1/plans/days/:dayId/reparse

Re-parse a plan day's mainWorkout/accessory text into structured exercise sets, replacing existing prescribed rows. Responds 422 PARSE_WRITE_THROUGH_REQUIRED when parsing produces no persisted sets.

  • Auth: Required
  • Rate limit: planDayReparse category, 5/min
  • AI gates: aiConsentCheck, aiBudgetCheck
  • Body: Optional { mainWorkout?: string | null, accessory?: string | null }
  • Response: { exercises, saved: true, setCount, rejectedCount, rejectionReasons } (or 404)

POST /api/v1/plans/days/:dayId/reparse-from-image

Photo sibling of /reparse — re-parse a plan day's prescribed exercises from an uploaded image. Same replace semantics.

  • Auth: Required
  • Rate limit: planDayReparse category, 5/min
  • AI gates: aiConsentCheck, aiBudgetCheck
  • Body: { imageBase64, mimeType }
  • Response: Same shape as /reparse (or 404)

POST /api/v1/plans/days/:dayId/coach-note/regenerate

Manually refresh the AI coach note (ai_rationale) for a planned day. The service enforces a 30-second cooldown.

  • Auth: Required
  • Rate limit: coachNoteRegenerate category, 10/min
  • AI gates: aiConsentCheck, aiBudgetCheck
  • Response: The updated plan day, or 429 COOLDOWN (with Retry-After header) when called inside the cooldown window

Timeline Annotation Routes

File: server/routes/timelineAnnotations.ts

User-authored bands spanning [startDate, endDate] that annotate injury, illness, travel, or rest periods on the Timeline and as shaded bands on Analytics charts. The DB layer (timeline_annotations table) enforces type IN ('injury','illness','travel','rest') and end_date >= start_date. Per-user ownership is enforced at the storage layer — mismatched IDs silently return 404 to avoid leaking existence.

GET /api/v1/timeline-annotations

List all annotations for the authenticated user, ordered by startDate ASC.

  • Auth: Required
  • Rate limit: annotations category, 60/min
  • Response: TimelineAnnotation[]

POST /api/v1/timeline-annotations

Create a new annotation.

  • Auth: Required
  • Rate limit: annotations category, 20/min
  • Body: { startDate: "YYYY-MM-DD", endDate: "YYYY-MM-DD", type: "injury" | "illness" | "travel" | "rest", note?: string } (note max 500 chars)
  • Validation: insertTimelineAnnotationSchema (Zod), with a .refine that endDate >= startDate when both dates are present
  • Response: 201 TimelineAnnotation

PATCH /api/v1/timeline-annotations/:id

Partially update an annotation. The handler fetches the existing row, merges the partial over it, and re-checks the date bounds before writing so a single-field PATCH cannot slip an invalid range past Zod.

  • Auth: Required
  • Rate limit: annotations category, 20/min
  • Body: Partial { startDate?, endDate?, type?, note? }
  • Validation: updateTimelineAnnotationSchema
  • Response: TimelineAnnotation (or 404 when the id doesn't belong to the user)

DELETE /api/v1/timeline-annotations/:id

Delete an annotation.

  • Auth: Required
  • Rate limit: annotations category, 20/min
  • Response: { success: true } (or 404 when the id doesn't belong to the user)

Analytics Routes

File: server/routes/analytics.ts

All analytics endpoints support optional date filtering via query parameters: ?from=YYYY-MM-DD&to=YYYY-MM-DD.

Coalesced request cache. Exercise sets and workout logs used by these routes pass through two in-memory promise caches (getExerciseSetsCoalesced and getWorkoutLogsCoalesced) keyed by userId + from + to. The cache holds the pending promise, so three concurrent requests for the same user/window trigger a single database query. Parameters:

Knob Value Source
TTL 5 minutes (ANALYTICS_CACHE_TTL_MS) server/constants.ts
Max entries per cache 500 (MAX_CACHE_SIZE) server/routes/analytics.ts
Eviction Expired entries first, then oldest-by-timestamp once over the size cap evictStale()
Failure behavior The rejected promise is evicted so the next caller retries immediately .catch in getExerciseSetsCoalesced / getWorkoutLogsCoalesced

GET /api/v1/personal-records

Calculate personal records across all exercises.

  • Auth: Required
  • Rate limit: analytics category, 20/min
  • Query: from?, to?
  • Response: PersonalRecord[] — max weight, max distance, best time per exercise category

GET /api/v1/exercise-analytics

Calculate per-exercise analytics (volume, intensity trends).

  • Auth: Required
  • Rate limit: analytics category, 20/min
  • Query: from?, to?
  • Response: Exercise analytics breakdown

GET /api/v1/training-overview

Calculate weekly training summaries, category totals, station coverage, and week-over-week deltas.

  • Auth: Required

  • Rate limit: analytics category, 20/min

  • Query: from?, to?

  • Response shape:

    {
      weeklySummaries: WeeklySummary[],
      workoutDates: string[],
      categoryTotals: { /* per-category totals */ },
      stationCoverage: { /* Hyrox station coverage */ },
      currentStats: {
        totalWorkouts: number,
        avgPerWeek: number,
        totalDuration: number,
        avgDuration: number,
        avgRpe: number | null,
      },
      // Omitted when no meaningful previous window exists — e.g. the user
      // picked "all time" so `from` is absent.
      previousStats?: {
        totalWorkouts: number,
        avgPerWeek: number,
        totalDuration: number,
        avgDuration: number,
        avgRpe: number | null,
      },
    }
  • Previous-window derivation (computePreviousWindow): The previous period is the equal-length, non-overlapping range ending the day before from. If to is omitted, the current window's upper bound is pinned to midnight UTC of today (not wall-clock now) so the previous window doesn't drift across the day. Returns null when from is absent, and the route responds without previousStats.

  • The client's DeltaIndicator component renders the percentage change between currentStats and previousStats for each of the four stat cards.

GET /api/v1/race-prediction

Predict the athlete's HYROX finish time from their logged history. Stored-first: returns the last persisted prediction instantly (no AI spend) so the tab paints on open without a spinner; ?refresh=1 (the manual refresh button) — or the absence of any stored row — regenerates and persists a fresh prediction. The stored row is also kept warm by the midnight analytics recompute job.

  • Auth: Required
  • Rate limit: race-prediction category, 12/min
  • AI gates: None — the service degrades to a deterministic estimate when AI is disabled, unconsented, or over budget, and only calls the model when consent + budget allow.
  • Query: refresh?refresh=1 forces regeneration and persistence
  • Response: RacePredictionResponse & { generatedAt: string, stale: boolean } — on a stored read, stale is true when a workout was logged after generatedAt; a freshly generated prediction returns stale: false.

AI and Chat Routes

File: server/routes/ai.ts

POST /api/v1/parse-exercises

Parse free-text or voice input into structured exercise data using the configured text AI provider.

  • Auth: Required
  • Rate limit: parse category, 5/min
  • Body: { text: string } (1-2000 chars)
  • Validation: parseExercisesRequestSchema
  • Response: ParsedExercise[] with confidence scores and category classification

Request example:

{
  "text": "3 sets bench 225lbs x 8, then 3 miles in 24 min"
}

Response example:

[
  {
    "exerciseName": "bench_press",
    "category": "strength",
    "confidence": 95,
    "missingFields": [],
    "sets": [
      { "setNumber": 1, "reps": 8, "weight": 225 },
      { "setNumber": 2, "reps": 8, "weight": 225 },
      { "setNumber": 3, "reps": 8, "weight": 225 }
    ]
  },
  {
    "exerciseName": "easy_run",
    "category": "running",
    "confidence": 80,
    "missingFields": [],
    "sets": [
      { "setNumber": 1, "distance": 4828, "time": 24 }
    ]
  }
]

POST /api/v1/parse-workout-structure

Parse free text into a structured workout (blocks/structure) using the configured text AI provider.

  • Auth: Required
  • Rate limit: parse category, 5/min (shared budget with parse-exercises)
  • AI gates: aiConsentCheck, aiBudgetCheck
  • Body: { text: string } (1-2000 chars)
  • Validation: parseExercisesRequestSchema
  • Response: Parsed workout structure

POST /api/v1/parse-exercises-from-image

Parse a photo of a workout plan (whiteboard, printout, screenshot) into structured exercise data using Gemini's multi-modal vision model. Images are expected to be compressed client-side via client/src/lib/image.ts (compressImage) before upload. A route-scoped express.json({ limit: "10mb" }) parser handles the base64 body.

  • Auth: Required
  • Rate limit: parse category, 5/min (shared budget with text parsing)
  • AI gates: aiConsentCheck, aiBudgetCheck
  • Body: { imageBase64: string, mimeType: "image/jpeg" | "image/png" | "image/webp" }
  • Response: Same ParsedExercise[] shape as /api/v1/parse-exercises.

POST /api/v1/parse-workout-structure-from-image

Photo sibling of /parse-workout-structure — parse a workout-structure photo into structured blocks.

  • Auth: Required
  • Rate limit: parse category, 5/min (shared budget)
  • AI gates: aiConsentCheck, aiBudgetCheck
  • Body: { imageBase64, mimeType }
  • Response: Parsed workout structure

POST /api/v1/workouts/:id/reparse-from-image

Re-parse an existing workout's exercises against a newly-uploaded photo. Used from the workout detail dialog when the coach updates the prescribed block or when the athlete captures the post-session whiteboard. Defined in server/routes/workouts/workoutsAi.routes.ts.

  • Auth: Required (user must own the workout)
  • Rate limit: reparse category, 5/min
  • AI gates: aiConsentCheck, aiBudgetCheck
  • Body: { imageBase64, mimeType } (same as above)
  • Response: { exercises, saved: true, setCount, rejectedCount, rejectionReasons }, or 422 PARSE_WRITE_THROUGH_REQUIRED when no sets are persisted.

POST /api/v1/chat

Send a message to the AI coach and receive a complete response.

  • Auth: Required
  • Rate limit: chat category, 10/min
  • Body: { message: string (1-1000 chars), history?: ChatMessage[] (max 20) }
  • Validation: chatRequestSchema
  • Response: { response: string, ragInfo: RagInfo }

POST /api/v1/chat/stream

Send a message to the AI coach and receive a streaming response via Server-Sent Events.

  • Auth: Required
  • Rate limit: chat category, 10/min
  • Body: Same as /api/v1/chat
  • Response headers: Content-Type: text/event-stream, Cache-Control: no-cache
  • SSE events:
    • { ragInfo: RagInfo } — First event with RAG metadata
    • { text: string } — Streaming text chunks
    • { done: true } — Stream complete
    • { error: string } — Stream error

Request example:

{
  "message": "How should I pace my sled push at competition?",
  "history": [
    { "role": "user", "content": "I have a Hyrox race in 6 weeks" },
    { "role": "assistant", "content": "Great! Let me help you prepare..." }
  ]
}

SSE response sequence:

data: {"ragInfo":{"source":"rag","chunkCount":3,"materialCount":2}}

data: {"text":"For the sled push, "}

data: {"text":"I recommend breaking it into "}

data: {"text":"three phases: an aggressive start, steady middle, and controlled finish."}

data: {"done":true}

If an error occurs mid-stream:

data: {"error":"Stream error"}

GET /api/v1/chat/history

Retrieve saved chat messages for the current user, cursor-paginated.

  • Auth: Required
  • Query: limit? (1-200), before? (ISO datetime), beforeId? (string) — before and beforeId must be supplied together
  • Response: ChatMessage[] (plain array for backward compatibility). When more rows exist, the cursor for the next page is returned in the X-Next-Cursor (timestamp) and X-Next-Cursor-Id (row id) response headers, both of which must be echoed back on the next request.

POST /api/v1/chat/message

Save a chat message to history.

  • Auth: Required
  • Rate limit: chatMessage category, 20/min
  • Body: { userId: string, role: "user" | "assistant", content: string (1-50000 chars) }
  • Validation: insertChatMessageSchema
  • Response: Saved ChatMessage

DELETE /api/v1/chat/history

Clear all chat messages for the current user.

  • Auth: Required
  • Rate limit: chatHistoryDelete category, 5/min
  • Response: { success: true }

GET /api/v1/coach-insights

Return the last stored Coach Insights analysis instantly, with no AI spend, so the Analytics tab paints the previous result on open instead of a blank state.

  • Auth: Required
  • Rate limit: analytics category, 60/min
  • AI gates: None (read-only; never calls the model)
  • Response: { ...CoachInsightsResult, generatedAt: string, stale: boolean }stale is true when a workout was logged after generatedAt. Returns { insights: null } when the user has never generated insights.

POST /api/v1/coach-insights

Regenerate the single-shot AI analysis of the athlete's progress against their stated goal and persist it to the durable analytics_results store. Generation lives in services/coachInsightsService so the route and the midnight recompute job share one path.

  • Auth: Required
  • Rate limit: suggestions category, 3/min
  • AI gates: aiConsentCheck, aiBudgetCheck
  • Response: { ...CoachInsightsResult, stale: false } — freshly generated against the current latest workout, so never stale (CoachInsightsResult includes insights and ragInfo).

POST /api/v1/timeline/ai-suggestions

Generate AI coaching suggestions for upcoming planned workouts.

  • Auth: Required
  • Rate limit: suggestions category, 3/min
  • AI gates: aiConsentCheck, aiBudgetCheck
  • Response: { suggestions: WorkoutSuggestion[], ragInfo: RagInfo }
  • Note: Returns empty suggestions if no upcoming planned workouts exist.

POST /api/v1/timeline/ai-suggestions/apply

Apply a generated timeline AI suggestion to a plan day's field.

  • Auth: Required
  • Rate limit: suggestionApply category, 10/min
  • AI gates: aiConsentCheck
  • Body: { workoutId: string, targetField: "notes" | "mainWorkout" | "accessory", action: "replace" | "append", recommendation: string, rationale?: string | null, aiSource?: "rag" | "legacy" | "none" | null }
  • Response: The updated plan day (or 404 when the plan day is not found)

GET /api/v1/timeline/ai-suggestions/debug/:workoutId

Inspect the AI suggestion trace and metadata for a plan day. Debugging aid.

  • Auth: Required
  • Response: { workoutId, focus, aiSource, aiRationale, aiNoteUpdatedAt, trace, debugSummary } (or 404)

Coaching Material Routes

File: server/routes/coaching.ts

GET /api/v1/coaching-materials

List all coaching materials for the current user.

  • Auth: Required
  • Response: CoachingMaterial[]

POST /api/v1/coaching-materials

Create a new coaching material. Triggers background embedding via pg-boss queue.

  • Auth: Required
  • Rate limit: coaching category, 10/min
  • AI gates: aiConsentCheck, aiBudgetCheck
  • Body limit: 2MB (elevated from default 100kb)
  • Body: { title: string (1-255 chars), content: string (1-1,500,000 chars), type: "principles" | "document" }
  • Validation: createMaterialSchema (insertCoachingMaterialSchema without userId)
  • Side effects: Queues embed-coaching-material job for RAG chunking/embedding.
  • Response: 201 Created CoachingMaterial

PATCH /api/v1/coaching-materials/:id

Update a coaching material. Re-embeds if content or title changed.

  • Auth: Required
  • Rate limit: coaching category, 10/min
  • AI gates: aiConsentCheck, aiBudgetCheck
  • Body: Partial { title?, content?, type? }
  • Side effects: Queues re-embedding if content or title changed.
  • Response: Updated CoachingMaterial

DELETE /api/v1/coaching-materials/:id

Delete a coaching material. Document chunks are cascade-deleted via FK.

  • Auth: Required
  • Rate limit: coaching category, 10/min
  • Response: { success: true }

GET /api/v1/coaching-materials/rag-status

Check the RAG pipeline status (embedding counts, dimension info).

  • Auth: Required
  • Response: RAG status object

POST /api/v1/coaching-materials/re-embed

Re-embed all coaching materials for the current user.

  • Auth: Required
  • Rate limit: coaching category, 5/min
  • AI gates: aiConsentCheck, aiBudgetCheck
  • Response: Re-embed result summary

Preferences Routes

File: server/routes/preferences.ts

GET /api/v1/preferences

Get the current user's preferences.

  • Auth: Required
  • Response: Serialized preferences — { weightUnit, distanceUnit, weeklyGoal, emailNotifications, emailWeeklySummary, emailMissedReminder, showAdherenceInsights, aiCoachEnabled, trainingStyleId, trainingStylePreviousId, trainingStyleChangedAt, trainingStyleRecomputeNow, onboardingCompleted, mafAge, mafInjuryIllnessMedication, mafConsistency, mafTrend, mafHrDataAvailable, mafHr, mafBaselineTestScheduledAt } plus two derived fields: planWeeklyDensity (the active plan's per-week density, or null) and weeklyGoalExceedsPlan (boolean hint when the user's weeklyGoal exceeds that density).

PATCH /api/v1/preferences

Update user preferences.

  • Auth: Required
  • Rate limit: preferences category, 20/min
  • Body: Partial of the serialized preference fields above (e.g. weightUnit?: "kg" | "lbs", distanceUnit?: "km" | "miles", weeklyGoal?, the email* toggles, aiCoachEnabled?, showAdherenceInsights?, onboardingCompleted?, trainingStyleId?, and the maf* fields).
  • Validation: updateUserPreferencesSchema
  • MAF validation: Switching trainingStyleId to maf_method requires mafAge, mafConsistency, and mafTrend to be set (in the body or already persisted); otherwise the route returns 400 { code: "MAF_SETUP_REQUIRED" }.
  • Response: Updated serialized preferences object
  • Email toggle semantics: emailNotifications is the master switch — when false, no email is sent regardless of the per-type flags. emailWeeklySummary and emailMissedReminder gate the individual categories and take effect only when the master is on. All three default to false at the database level for new users (GDPR-compliant opt-in).
  • AI consent semantics: aiCoachEnabled gates every outbound AI provider call (workout parsing, chat, auto-coach, embeddings, and image parsing). It defaults to false for new users; the AI features are hidden or disabled in the UI until the user explicitly opts in. Flipping it to false immediately stops new AI requests; already-persisted chat history and plan AI artifacts remain until the user deletes them.
  • Onboarding completion: onboardingCompleted stores whether the welcome flow has finished across devices. It is app state, not a visible Settings preference.

Email Routes

File: server/routes/email.ts

POST /api/v1/emails/check

Manually trigger email checks for the current user (weekly summary, missed reminders).

  • Auth: Required (Clerk JWT)
  • Rate limit: emailCheck category, 5/min
  • Response: { sent: string[] } — list of email types sent

GET /api/v1/cron/emails

External cron trigger endpoint for batch email processing across all users.

  • Auth: x-cron-secret header (timing-safe comparison with CRON_SECRET env var)
  • No Clerk auth required
  • Response: Cron job result summary

GET /api/v1/analytics/internal/structured-exercise-health

Internal structured exercise health rollup endpoint. Defined in server/routes/analytics.ts.

  • Auth: Clerk auth plus x-internal-analytics-secret header (timing-safe comparison with INTERNAL_ANALYTICS_SECRET env var)
  • Rate limit: internalAnalytics category, 5/min
  • Response: { rollups, counters }

Push Notification Routes

File: server/routes/push.ts

Web Push (VAPID) endpoints used by the PWA to deliver missed-workout nudges and weekly summary notifications. All endpoints return 404 PUSH_NOT_CONFIGURED when VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY are not set.

GET /api/v1/push/vapid-key

Return the server's VAPID public key so the client can call PushManager.subscribe.

  • Auth: Required
  • Response: { publicKey: string }

POST /api/v1/push/subscribe

Persist a PushSubscription for the authenticated user. Multiple endpoints per user are allowed (one per device).

  • Auth: Required
  • Rate limit: push category, 10/min
  • Body: { endpoint: string, keys: { p256dh: string, auth: string } }
  • Response: { success: true }

DELETE /api/v1/push/unsubscribe

Remove a specific subscription endpoint for the authenticated user.

  • Auth: Required
  • Rate limit: push category, 10/min
  • Body: { endpoint: string }
  • Response: { success: true }

POST /api/v1/push/test

Dispatch a test notification to every registered subscription for the authenticated user. Used by the Settings UI to verify the PWA install is wired up correctly.

  • Auth: Required
  • Rate limit: push category, 5/min
  • Response: { success: true, sent: number }

Strava Routes

File: server/strava.ts

GET /api/v1/strava/status

Check if the current user has a Strava connection.

  • Auth: Required
  • Response: { connected: boolean, athleteId?: string, lastSyncedAt?: string, requiresReauth?: boolean }requiresReauth: true means Strava rejected our stored credentials (the user revoked the app on strava.com); the client should offer a Reconnect flow.

GET /api/v1/strava/auth

Generate a Strava OAuth authorization URL with CSRF-protected signed state.

  • Auth: Required
  • Rate limit: IP-based, 20 per 15 minutes
  • Response: { url: string } — Redirect URL for Strava OAuth
  • State parameter: HMAC-SHA256 signed with userId:timestamp:nonce:signature, max age enforced, single-use (atomically claimed on callback)

GET /api/v1/strava/callback

OAuth callback handler. Exchanges authorization code for tokens, encrypts and stores them.

  • Auth: Not required (redirect from Strava)
  • Rate limit: IP-based, 20 per 15 minutes
  • Query: code, state (CSRF-verified, single-use — replays redirect to /settings?strava=error), scope
  • Side effects: Creates stravaConnections record with AES-256-GCM encrypted tokens; clears any requires_reauth tombstone on reconnect
  • Response: Redirect to /settings

POST /api/v1/strava/sync

Incrementally sync Strava activities into workout logs (since lastSyncedAt with a 7-day overlap; 90-day backfill on first sync; up to 5 × 200-activity pages per call).

  • Auth: Required
  • Rate limit: IP-based, 5 per 15 minutes
  • Side effects: Fetches activities from Strava API, maps to WorkoutLog format, deduplicates by stravaActivityId, auto-refreshes expired tokens (serialized under a per-user advisory lock), enriches calories for the newest ≤25 imports from the activity-detail endpoint, advances the lastSyncedAt cursor.
  • Response: { success: true, imported: number, skipped: number, total: number, hasMore: boolean }hasMore: true means the page cap was hit and another sync will continue where this one stopped.
  • Errors: 401 { code: "STRAVA_REAUTH_REQUIRED" } (revoked — reconnect needed), 401 { code: "UNAUTHORIZED" } (not connected), 429 { code: "RATE_LIMITED", retryAfterSeconds } (Strava rate limit, after retries), 502 { code: "EXTERNAL_API_ERROR" } (transient upstream failure).

DELETE /api/v1/strava/disconnect

Disconnect the Strava integration. Performs a best-effort upstream POST /oauth/deauthorize (non-fatal on failure) before deleting the local connection.

  • Auth: Required
  • Response: { success: true }

Garmin Routes

File: server/garmin.ts

Garmin Connect sync uses a reverse-engineered SSO flow (email + password), not a public OAuth application. See Integrations → Garmin Connect for the rationale, safety stack, and storage model.

All mutating routes apply protectedMutationGuards (auth + CSRF + idempotency). Every route short-circuits with HTTP 503 GARMIN_CIRCUIT_OPEN when the global 429 circuit breaker is tripped.

GET /api/v1/garmin/status

Returns the Garmin connection state for the authenticated user.

  • Auth: Required
  • Response: { connected: false } or { connected: true, garminDisplayName: string | null, lastSyncedAt: string | null, lastError: string | null }

POST /api/v1/garmin/connect

Authenticate with Garmin using email + password and persist the encrypted credentials / OAuth tokens.

  • Auth: Required
  • Rate limit: garmin-connect category, 5 per 15-minute window per user
  • Body: { email: string (valid email, max 254), password: string (1-256) }
  • Behavior: Logs into Garmin before writing any DB row — nothing is stored on failure. Fetches getUserProfile() to capture the display name (optional; non-fatal if it fails).
  • Responses:
    • 200 { success: true, garminDisplayName: string | null }
    • 400 { code: "BAD_REQUEST" } — invalid email / empty password
    • 401 { code: "GARMIN_AUTH_FAILED" } — invalid credentials or 2SV enabled (see error translation in server/garmin.ts)
    • 409 { code: "GARMIN_BUSY" } — another Garmin op for the same user is in progress (per-user mutex)
    • 503 { code: "GARMIN_CIRCUIT_OPEN" } — global 429 breaker is tripped

DELETE /api/v1/garmin/disconnect

Removes the garmin_connections row for the user (credentials, tokens, display name).

  • Auth: Required
  • Response: { success: true }

POST /api/v1/garmin/sync

Imports the most recent activities from Garmin into workout_logs.

  • Auth: Required
  • Rate limit: garmin-sync category, 5 per 15-minute window per user
  • Preflight rejections (checked before login):
    • 404 { code: "GARMIN_NOT_CONNECTED" }
    • 429 { code: "GARMIN_SYNC_TOO_SOON" } — less than 5 minutes since lastSyncedAt
    • 401 { code: "GARMIN_RECONNECT_REQUIRED" } — prior lastError is set; user must disconnect + reconnect
    • 503 { code: "GARMIN_CIRCUIT_OPEN" } — global 429 breaker tripped
  • Behavior: Calls client.getActivities(0, 20), dedupes against the partial unique index (user_id, garmin_activity_id) WHERE garmin_activity_id IS NOT NULL, and inserts the new rows via onConflictDoNothing.
  • Success response: { success: true, imported: number, skipped: number, total: number }imported is the true insert count; anything caught by the partial index is rolled into skipped.
  • Error responses: 401 GARMIN_AUTH_FAILED, 502 GARMIN_API_ERROR (with lastError persisted), 409 GARMIN_BUSY.

Timeline and Export Routes

Files: server/routes/workouts/workoutsTimeline.routes.ts, server/routes/workouts/workoutsExport.routes.ts, and the exercise-history route in server/routes/workouts/workoutsCrud.routes.ts.

GET /api/v1/timeline

Get merged timeline of planned and logged workouts.

  • Auth: Required
  • Rate limit: timeline category, 60/min
  • Query: planId? (filter by plan), limit? (default capped), offset?
  • Response: TimelineEntry[] — merged planned + logged workouts sorted by date

Response example:

[
  {
    "id": "pd_101",
    "date": "2025-03-17",
    "type": "planned",
    "status": "planned",
    "focus": "Sled Push + SkiErg",
    "mainWorkout": "4x50m sled push at 100kg, 3x500m SkiErg",
    "accessory": "3x15 wall balls",
    "notes": null,
    "planDayId": "pd_101",
    "workoutLogId": null,
    "weekNumber": 3,
    "dayName": "Monday",
    "planName": "Hyrox 8-Week Prep",
    "planId": "plan_abc"
  },
  {
    "id": "wl_202",
    "date": "2025-03-16",
    "type": "logged",
    "status": "completed",
    "focus": "Easy Run",
    "mainWorkout": "5km easy run",
    "accessory": null,
    "notes": "Felt good, kept HR under 145",
    "duration": 30,
    "rpe": 4,
    "planDayId": null,
    "workoutLogId": "wl_202",
    "source": "strava",
    "exerciseSets": [
      {
        "id": "es_501",
        "workoutLogId": "wl_202",
        "exerciseName": "easy_run",
        "category": "running",
        "setNumber": 1,
        "distance": 5000,
        "time": 30,
        "reps": null,
        "weight": null
      }
    ],
    "calories": 320,
    "distanceMeters": 5000,
    "avgHeartrate": 142,
    "maxHeartrate": 155
  }
]

GET /api/v1/exercises/:exerciseName/history

Get historical exercise sets for a specific exercise, newest session first.

The name is resolved through normalizeExerciseName, so RDL finds romanian_deadlift; names it can't resolve fall back to an exact match.

  • Auth: Required
  • Rate limit: exerciseHistory category, 120/min — the client fires one request per distinct exercise when a session is opened, so this has its own bucket rather than sharing workoutHistory
  • Query: sessions (1–20, optional) — bounds the number of distinct training dates returned, not the number of rows, so a session's sets are never returned in part
  • Response: Exercise set history with dates

GET /api/v1/export

Export all training data as CSV or JSON.

  • Auth: Required
  • Rate limit: export category, 5/min
  • Query: format"csv" (default) or "json"
  • Response: File download with appropriate Content-Type and Content-Disposition headers

Nutrition Routes

Base path: /api/v1/nutrition (paths in the table are relative to it). Files: server/routes/nutrition/*.

The entire nutrition surface is gated by the NUTRITION_ENABLED server flag — when it is not "true", every route below returns 404. The AI-backed routes (/parse/*, POST /insights) additionally require AI consent and budget. These routes are not yet registered with the OpenAPI registry, so they are absent from docs/openapi.json and Swagger UI; this catalog and Nutrition & Fuelling § API surface are the reference until they are migrated.

Method Path Purpose Rate limit (per min)
GET /foods/search Search local cache + Edamam + USDA + Open Food Facts (fuzzy / synonym / optional semantic) nutritionSearch (30)
GET /foods/recent Recently logged foods (FoodWithPortionMemory[] — each food plus its lastQuantityG / lastMealType) nutritionRead (60)
GET /foods/custom The user's custom foods nutritionRead (60)
POST /foods/barcode Barcode → food (Open Food Facts) nutritionBarcode (30)
POST /foods Create a custom food (+ servings) nutritionWrite (30)
GET /foods/:id Food + named servings nutritionRead (60)
PATCH /foods/:id Edit a custom food nutritionWrite (30)
DELETE /foods/:id Delete a custom food (409 if referenced by a log) nutritionWrite (30)
POST /foods/:id/servings Add a named serving nutritionWrite (30)
DELETE /foods/:id/servings/:servingId Delete a serving nutritionWrite (30)
GET /favorites List favourites (FoodWithPortionMemory[] — each food plus its lastQuantityG / lastMealType) nutritionRead (60)
POST /favorites Add a favourite nutritionFav (30)
DELETE /favorites/:foodId Remove a favourite nutritionFav (30)
POST /logs Log a food nutritionLog (60)
PATCH /logs/:id Edit a log entry nutritionLog (60)
DELETE /logs/:id Delete a log entry nutritionLog (60)
POST /logs/repeat Repeat a day / meal nutritionLog (20)
POST /logs/batch Confirm reviewed parsed items nutritionLog (60)
GET /summary Daily totals + per-meal breakdown, incl. mealTargets nutritionRead (60)
GET /session-fuelling/:workoutId Pre/post-session fuelling windows nutritionRead (60)
GET /block Daily intake macros vs. training UTSS nutritionRead (60)
GET /targets Current target + history nutritionRead (60)
POST /targets Set / replace the target version nutritionWrite (30)
GET /micros The day's micronutrients vs. RDI nutritionRead (60)
POST /parse/text Natural-language meal → items (AI) parse (5) + consent + budget
POST /parse/photo Photo → items (AI) parse (5) + consent + budget
GET /insights Last stored AI nutrition analysis nutritionRead (60)
POST /insights Regenerate the analysis (AI) suggestions (3) + consent + budget
GET /recipes List recipes nutritionRead (60)
POST /recipes Create a recipe nutritionWrite (30)
GET /recipes/:id Recipe + ingredients + per-serving macros nutritionRead (60)
PATCH /recipes/:id Edit a recipe nutritionWrite (30)
DELETE /recipes/:id Delete a recipe nutritionWrite (30)

See Nutrition & Fuelling for request/response shapes, the per-100g scaling model, and AI safety details.


See Also

  • AI and RAG -- Architecture of the RAG pipeline, embedding strategy, and coaching material processing.
  • Authentication -- Clerk JWT setup, middleware configuration, and session management.
  • Nutrition & Fuelling -- The full nutrition subsystem: data model, food search, fuelling, and AI usage.