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_OWNERthrows ifuserIdis not providedexplicitTenantIdthrows 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 |