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) automatically injects tenantId into every query. The helper does not add tenantId by default. Only use explicitTenantId when querying through prismaWithoutTenant (e.g., unauthenticated endpoints).
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 tenantId: N (validated as positive integer) |
Only when using prismaWithoutTenant. The tenant middleware handles this automatically for normal prisma queries. |
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
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 |