Skip to content

Exercise Scope Service

Overview

The exercise scope service (exerciseScopeService.ts) centralizes exercise visibility and filtering logic into a single helper. Before this service, the same WHERE clause pattern (isActive: true, OR: [{ isPublic: true }, { createdById: userId }]) was duplicated across 11 call sites with inconsistent variations. Some endpoints hardcoded tenantId: 1, others included isAIExercise: false, and others omitted isActive entirely.

The service provides two functions: - buildExerciseWhere — builds a Prisma ExerciseWhereInput filter based on a visibility mode and optional modifiers - applyExerciseScope — merges a scope filter into an existing Prisma query object

Tenant middleware interaction: The Prisma tenant middleware (via AsyncLocalStorage) scopes every Exercise read through the prisma client to the tenant's own rows and the platform-wide ones (tenantId NULL: the shared Kinescope library and MedBridge AI videos, see Platform Exercise Library). The helper does not add a tenant filter by default. Only use explicitTenantId when querying through prismaWithoutTenant (e.g., unauthenticated endpoints); it reads the same two sets.

Visibility Modes

Mode Filter Use Case
PUBLIC_OR_OWNER OR: [{ isPublic: true }, { createdById: userId }] Authenticated endpoints where a therapist should see public exercises + their own custom exercises. Requires userId in context.
PUBLIC_ONLY isPublic: true Unauthenticated endpoints or contexts where only globally public exercises should appear.
PUBLIC_OR_HAS_VIDEO OR: [{ isPublic: true }, { kinescopeVideoId: { not: null } }] Mobile AI endpoints where exercises need to be demonstrable (either public or have a video).

All modes always include isActive: true.

Options

Option Effect When to Use
excludeAIExercises Adds isAIExercise: false When AI-generated placeholder exercises should be excluded (e.g., therapist plan editing).
requireVideo Adds kinescopeVideoId: { not: null } When only exercises with video content should appear.
explicitTenantId Adds AND: [{ OR: [{ tenantId: N }, { tenantId: null }] }] (validated as positive integer): the tenant's exercises and the platform-wide ones Only when using prismaWithoutTenant. The tenant middleware handles this automatically for normal prisma queries.

Platform helpers

Helper Returns Use
tenantOrPlatformWhere(tenantId) { OR: [{ tenantId }, { tenantId: null }] } A lookup by id that used where: { id, tenantId }; without it a platform exercise is not found
exerciseTagsVisibleWhere(tenantId?) { tag: { OR: [{ isGlobal: true, tenantId: null }, { tenantId }] } } The where of a tags include on an exercise. A platform exercise carries other clinics' custom-tag links; each clinic sees only its own. Defaults to the request's tenant; shared tags only outside a request
isPlatformExercise(exercise) exercise.tenantId === null Refusing tenant-side edits (updateExerciseShared, the tag routes)
resolveRetiredLibraryCopies(db, ids) A Map from each retired library copy's id to its platform exercise Writes that take exercise ids from a client (favorites, templates, the plan editor): a client that loaded the library before the consolidation still sends the copies' ids

Usage

Basic — authenticated endpoint

import { buildExerciseWhere } from "../services/exerciseScopeService";

const exercises = await prisma.exercise.findMany({
  where: buildExerciseWhere("PUBLIC_OR_OWNER", { userId: req.user.userId }),
  select: { id: true, name: true },
});

With options — exclude AI exercises

const exercises = await prisma.exercise.findMany({
  where: buildExerciseWhere("PUBLIC_OR_OWNER", { userId: doctorId }, {
    excludeAIExercises: true,
  }),
  select: { id: true, name: true },
});

Composing with existing filters

When you need to add exercise scope on top of other conditions (e.g., favorites), use AND:

const exercises = await prisma.exercise.findMany({
  where: {
    AND: [
      { favoritedBy: { some: { doctorId: req.user.userId } } },
      buildExerciseWhere("PUBLIC_OR_OWNER", { userId: req.user.userId }),
    ],
  },
});

Nested relation filter (e.g., tag exercise counts)

_count: {
  select: {
    exercises: {
      where: {
        AND: [
          { tenantId },
          { exercise: buildExerciseWhere("PUBLIC_OR_OWNER", { userId }) },
        ],
      },
    },
  },
},

Unauthenticated endpoint — explicit tenant

import { prismaWithoutTenant } from "../../index";

// Intentional: unauthenticated endpoint, tenant 1 is the default for mobile onboarding.
// Reads tenant 1's public exercises and the platform-wide ones.
const exercises = await prismaWithoutTenant.exercise.findMany({
  where: buildExerciseWhere("PUBLIC_ONLY", {}, {
    requireVideo: true,
    explicitTenantId: 1,
  }),
});

applyExerciseScope — merge into query object

import { applyExerciseScope } from "../services/exerciseScopeService";

const result = applyExerciseScope(
  { where: { favoritedBy: { some: { doctorId } } }, orderBy: { name: "asc" } },
  "PUBLIC_OR_OWNER",
  { userId: doctorId }
);
// result.where = { AND: [originalWhere, scopeWhere] }

Note: applyExerciseScope can cause TypeScript type widening when used inline with Prisma's findMany. If you hit type errors (e.g., orderBy string widening), use buildExerciseWhere with manual AND composition instead.

Validation

  • PUBLIC_OR_OWNER throws if userId is not provided
  • explicitTenantId throws if the value is not a positive integer (0, negative, or fractional values are rejected)

Key Files

File Role
OktaPT-API/src/services/exerciseScopeService.ts The helper — buildExerciseWhere and applyExerciseScope
OktaPT-API/src/services/__tests__/exerciseScopeService.test.ts Unit tests (14 cases)
OktaPT-API/src/middleware/prisma.ts Tenant middleware — auto-injects tenantId (read-only reference)
OktaPT-API/src/middleware/tenant-context.ts runWithTenant — used to set tenant context in async jobs