Feature Flags (Tenant-Level)¶
Feature flags are per-tenant boolean toggles stored in the database. They control visibility of features on both frontend and backend without code deploys.
How It Works¶
Database¶
Feature flags live in the TenantSettings table in the features JSON column:
{
"rtmBillingDashboard": true,
"allActivePatientsDashboard": true,
"therapistPatientAssignment": true
}
To enable a feature for a tenant, update (or insert) the JSON directly:
UPDATE "TenantSettings"
SET features = jsonb_set(COALESCE(features, '{}'), '{myNewFeature}', 'true')
WHERE "tenantId" = <TENANT_ID>;
Backend¶
Reading flags: Use prisma.tenantSettings.findUnique({ where: { tenantId } }) and cast .features to access individual keys.
Endpoint: GET /v2/tenant/features (authenticated) returns all flags for the requesting user's tenant:
Guarding an endpoint: Add a check at the top of any controller that should be restricted:
const settings = await prisma.tenantSettings.findUnique({
where: { tenantId },
});
const features = (settings?.features as any) || {};
if (features.myNewFeature !== true) {
res.status(403).json({ error: "Feature not enabled for this tenant" });
return;
}
See OktaPT-API/src/routes/doctor/controller.ts (getAllTenantPatients) for a working example.
Frontend¶
Store: OktaPT-FE/lib/stores/tenantFeatures.ts is a Zustand store that fetches and caches flags.
import { useTenantFeaturesStore } from "@/lib/stores/tenantFeatures";
// Inside a component:
const { hasFeature, fetchFeatures } = useTenantFeaturesStore();
const isEnabled = hasFeature("myNewFeature");
Initialization: fetchFeatures() is called once during dashboard init (in pages/doctor/dashboard.tsx). It caches the result so subsequent calls are no-ops. If your feature is on a page that doesn't go through the dashboard, call fetchFeatures() in that page's init useEffect.
Conditional rendering:
Adding a New Feature Flag¶
- Pick a name. Use camelCase (e.g.,
myNewFeature). - Backend guard. If the feature has an API endpoint, add the guard pattern shown above to the controller.
- Frontend guard. Use
hasFeature("myNewFeature")to conditionally render UI. - Enable per tenant. Set the flag to
truein the tenant'sTenantSettings.featuresJSON.
No schema migration is needed -- the features column is a JSON field that accepts arbitrary keys.
Examples ofExisting Feature Flags¶
| Flag | Purpose |
|---|---|
rtmBillingDashboard |
Enables RTM billing dashboard tab and endpoints |
claimsPortal |
Insurance claims tab in the clinic area and the /v2/clinic-admin/claims endpoints (see claims-portal.md); needs STEDI_API_KEY on the API to send anything |
allActivePatientsDashboard |
Shows the "All Active Patients" sub-tab in the doctor dashboard. Since 2026-10-06 a search in the patient list (web and app) also reaches other therapists' patients under a "Not your patients" heading, and the add-patient form's existing-patient check names a patient's therapists. See therapist-patient-assignment.md |
therapistPatientAssignment |
Enables multi-therapist assignment for patients (at invite and from dashboard), and since 2026-10-06 "Assign to me" on a colleague's patient found by the patient list's search or by the add-patient form's check |
todaysPatients |
On by default (!== false). The therapist app's Today's patients: the pill beside Patients on the Patients screen, the short list of who is in the clinic today (Add patient to today, the dot and dismiss on a row, the today search, Seeing today and the add/remove action on the patient's screen). Set to false for a clinic that plans its day elsewhere: the app shows the directory only, with no pills; with scheduling on, the Today's appointments and This week cards move above the directory. The list kept by /v2/doctor/in-clinic-patients stays and comes back when the flag is on again; the server doesn't enforce the flag. App only: the admin In-clinic patients page and the daily review on the web still read and edit these lists. App builds from before 2026-10-29 ignore the flag and keep showing Today's patients. See mobile-doctor-dashboard.md |
therapistPersonalizedExerciseVideo |
Enables personalized exercise video recording and playback for doctors. Since 2026-10-21 also the app's "Who is this video for?" question on a new library exercise's video, the HEP picker's "Is this video for {Name} only?" and Keep with one patient on an exercise (see Personalized Videos) |
exerciseAutoCanonicalization |
Enables automatic AI-powered duplicate exercise detection and merging |
therapistExerciseManagement |
Enables the Exercises tab for doctors on mobile (browse, create, edit, filter exercises) |
therapistMobileHepManagement |
Enables mobile HEP management — doctors can view and edit patient workout plans from the mobile app |
hepSmartSearch |
Search by meaning as you type (the decision model's, POST /v2/hep-suggestions/search) in the web's HEP builder palette and, since 2026-10-06, on the exercise library page, and in the app's HEP picker, personalized-video picker and Exercises tab. Where it is on, the on-demand "Search with AI" is labelled "Extended search" and keeps working either way. See exercise-ai-search.md |
planAssistant |
The plan assistant of the web HEP builder, the classic plan editors and the app's Manage HEP: the therapist says what should change in words and it happens on the plan in front of them, step by step, as they watch (add or remove exercises, change a dose or any detail of the prescription on some days only, move, swap or reorder exercises, name a session, swap or reorder a day's sessions, write a block note where workoutLogbook is on, copy a day, repeat one of the patient's past sessions, apply a template, build the first week from the notes), with Undo. Nothing reaches the patient until the therapist saves (the app autosaves as for any edit). Off: every surface keeps its older assistant, which proposes a rewritten week to accept or reject. The request, the week, the patient's record and recent sessions, the exercise library and template names go to OpenAI (gpt-5.4-mini for a change, gpt-5.4 for a first week). The API answers 404 where it is off; applies at once. See plan-assistant.md |
smartNotifications |
Enables AI-powered smart notifications — personalized workout reminders, celebrations, and engagement-based messaging |
appEntranceTracking |
Enables app entrance tracking — records patient app opens and creates inferred workouts when no real workout is completed that day |
promoCodeCapture |
Enables the promotion campaigns system — branded partner landing pages, code redemption at signup or from profile, and admin campaign analytics |
selfServePlanUpdates |
Whether patients may rewrite their own coming week with the AI: the Adjust your plan card in the app, Manage your plan on the web dashboard, the /v2/ai/ai-workout-plan-edit-* and accept-workout-plan routes and the web's older plan-rewrite routes. Missing means on for the main tenant (id 1) and off for a clinic; a stored true/false wins. GET /v2/tenant/features returns it resolved so the app and the web read it like any other flag. Off hides the card and the API answers 404, so a therapist's plan stays theirs; on the main site it is on for every patient, therapist-connected ones included. A new account from the app's builder always gets its plan, whatever the flag. Applies within 30 seconds. See Self-Serve Plans |
openaiDecisions |
Sends the clinic's quick AI decisions (HEP smart search as you type, the AI picks of Often Paired With, custom-tag categories and look-alikes, matching guidelines to a patient, the live-notes listener of video visits) to OpenAI's Decisions API (gpt-6-luna, covered by the OpenAI BAA with Zero Data Retention) instead of TypeSafe's Jev via OpenRouter. Off: Jev as before; where the server has no OpenRouter key (dev and the RU server), those features give their non-AI answer, so on dev only clinics with the flag get AI decisions. A clinic's decisions never fall back to the other provider, and the AI Model Lab won't run its cases on Jev. Needs OPENAI_API_KEY on the API and the worker; applies within 30 seconds. hepPairingPatientContext sends its patient summary only where this is on. The app's self-serve plans do not follow this flag: they use OpenAI's Decisions API wherever the server has OPENAI_API_KEY (see Self-Serve Plans). See internal/decision-models.md |
multiProviderGroupMessaging |
Enables care-team messaging — group conversations, adding therapists to a patient's conversation (including expanding a one-on-one), removing/leaving, and the clinic-wide therapist picker. See messaging.md |
feedbackNotifications |
Enables the patient–therapist feedback loop — feedback cards posted into conversations after completed sessions and therapist note bundles pushed back to the patient. See patient-therapist-feedback.md |
autoWorkoutGeneration |
On by default. When a plan runs low, new sessions are added on the patient's schedule as copies of the current plan; with this on (and the therapist's AI Workouts toggle on) the AI adapts those copies to the patient's progress, otherwise they are exact. With it off, planContinuation decides whether exact copies are added or nothing is. See Plan Scheduling |
planContinuation |
On by default. Whether a plan that runs low gets exact copies of its sessions. Read only while autoWorkoutGeneration is off (that one takes precedence), so the two work as a pair of which at most one is on. With both off, nothing is added when a plan runs out (a new personalized video goes only into the sessions that are left), editors stop pre-filling the week, and missed sessions still move to the next scheduled day. See Plan Scheduling and Flags that replace each other |
doctorSelfSignup |
Lets doctors create their own accounts at /signup (including promo-code sign-ups). Turn it on only for the main Otera and OKTA tenants; clinic tenants leave it off and onboard therapists from the admin panel, because self-signed-up doctors are approved immediately. When off, POST /v2/user refuses DOCTOR with 403 DOCTOR_SIGNUP_DISABLED, /signup offers patient accounts only, and /therapist-promo/<slug> shows a notice instead of the code box. Exposed publicly through GET /v2/tenant/public-features. Enable it on the main Otera and OKTA tenants before deploying the API that introduces it (nothing seeds it), and keep it off for clinic tenants. It also marks the self-serve tenants: a tenant other than the main one (id 1) has a sign-up form, Sign up links and the app's builder sign-up only while this is on; elsewhere /signup says accounts are made by the clinic and POST /v2/user (and a Google or Apple sign-up) answers 403 SIGNUP_DISABLED, so a clinic's patients come through their therapist or the clinic's /join link (patientSelfSignup). See Sign in with Google and Apple |
doctorApprovalRequired |
Read next to doctorSelfSignup: where therapists sign themselves up and this is on, a new therapist account is created unapproved (User.isApproved = false). The therapist can sign in, but sees a waiting screen on the web and in the app, and the API answers 403 APPROVAL_PENDING to every request but the account itself (GET /v2/user, sign-out, the consent, the push token, DELETE /v2/user/me, GET /v2/tenant/features; the account can be read but not edited, so the email the administrators were sent is the one they approve) until an administrator approves them: the Approved switch on the admin panel's Users page (filter Not approved; PATCH /v2/admin/users/:id/approval with { isApproved }) or Approve on the clinic area's Therapists page (PATCH /v2/clinic-admin/therapists/:id). The platform's administrators and the clinic's own administrators get an email when someone is waiting (once per therapist, at most 10 an hour per tenant; the rest show on the Not approved filter), the therapist gets one when approved (a sign-in button, or "open the app" on Otera Measure's tenant), and /signup says under the therapist choice that accounts are reviewed first. Exposed through GET /v2/tenant/public-features; the account and sign-in responses carry approvalPending, which the web and the app show the waiting screen on. Off: therapists who sign themselves up are approved at once, as before, and the Approved switch is a record with no effect (approvalPending stays false, so the app shows no waiting screen; the web dashboard keeps its older "under review" notice for a therapist whose switch is off); turning it off lets every waiting therapist in. Turning it on also holds any existing therapist whose switch is off, so check the Users page's Not approved filter for the tenant first. The API keeps the flag and an approval in memory for up to a minute |
workoutLogbook |
The workout logbook: a clinic's patients see Start workout open the whole session as one list, grouped by block, and log what they did set by set (tick a set, type the reps or seconds, a note per exercise, the video only on tap), with pain and effort asked once at the end, and under each exercise what they logged the last time they did it; therapists get Vary by set (a different rep count per set, a range such as 8–12, or max) in the plan builder and the classic editors, and a note per block in the builder's list view. Off: the guided exercise-by-exercise flow; per-set plans and block notes already saved stay and still show, their controls are hidden. The API does not gate on it, but an API older than the logbook refuses its saves: turn the flag off before rolling the API back. See workout-logbook.md |
assistant |
The assistant prototype for therapists and platform administrators: an Ask button in the web header (⌘K) and an ask bar under the therapist's title in the app. One box to ask about patients, plans and progress, or to say what to note, send or change; notes, messages and plan changes are shown to confirm before anything is written; the assistant knows which other features the clinic has switched on and tells a clinic without one who to write to. Each question goes to OpenAI with the names, dates and session facts the tools read (contact details stay out), under the OpenAI BAA. Patients get it only with patientAssistant. Off: no button or bar for therapists and administrators, their requests answer 404. See internal/assistant-prototype.md |
patientAssistant |
The assistant prototype for patients, switched on apart from assistant: the Ask button in the web header and the ask bar at the top of the app's home screen. A patient asks what is on today, how an exercise is done and what it is for, and how they are doing, and can send a message to their care team after confirming it. The answers reach the patient without a therapist reading them first (the design system's one exception), and the assistant gives no medical advice of its own. Off: no button or bar for patients, their requests answer 404. See internal/assistant-prototype.md |
socialSignIn |
Offers Sign in with Google and Apple (POST /v2/user/social-login): the buttons on the tenant's web sign-in and sign-up pages, and in the Otera and OKTA apps for the tenant's patients and therapists (the app shows them when the main tenant has the flag; a clinic's own flag lets the app find the clinic's accounts). A Google or Apple account whose verified email already has an account here signs straight in and is linked. The Measure tenant (measureOnly) has it without the flag. Elsewhere the endpoint answers 403 SOCIAL_SIGN_IN_DISABLED and the app sends the person to their password. Resolved into GET /v2/tenant/public-features with the web clients. Off everywhere until a clinic asks. See Sign in with Google and Apple |
measureOnly |
Marks the tenant behind Otera Measure (the screening tools sold to clinicians as an app of their own), whose clinicians are strangers to one another. Its sessions open only the account (/v2/user, sign-out, consent, push token, DELETE /v2/user/me) and /v2/clinical-assessment/*; every other route answers 403 MEASURE_ONLY before its handler runs, so the colleague and patient lists, connection requests, messages and plans stay shut. Patients cannot sign up there (403 PATIENT_SIGNUP_DISABLED), the other apps' email lookup (/v2/user/check-email) never offers the tenant, and its clinicians get the session-report push but none of the clinical assessment emails (they link to a web the app does not have). Set it, with doctorSelfSignup, on the measure.oterahealth.com tenant only. See measure-app.md |
samplePatient |
Gives a therapist who has no patient connections at all (and never had a sample) a private, clearly labeled sample patient with three weeks of history, created the first time a client that understands samples loads their patient list (?includeSample=1). Turning it off stops creation; existing samples stay visible and removable. See internal/sample-patient.md for the eligibility rules. The sample also brings a movement screening (when screeningAssignments is on and the admin has uploaded test recordings), a video visit (when videoVisits is on) and a week of scripted follow-ups with real notifications to the therapist; the follow-ups also stop when this flag is turned off |
scheduling |
Enables scheduling: the Schedule page for therapists and the clinic area's Schedule tab (calendar, booking, working hours, waitlist, the clinic's types, locations and policies), Appointments for patients on the web and in the app (booking within the clinic's policies, confirming from a reminder's link, moving, cancelling, the intake before a first visit), reminders at the hours the clinic sets, and the AI steps: clarifying intake questions, the intake summary and the pre-visit brief, all through OpenAI with no patient name. Switched off again, booked appointments keep their links, but the calendar feeds stop, no reminders go out and nothing new can be booked. Needs the worker and Redis for reminders. See scheduling.md |
clinicalDocumentation |
Enables clinical documentation: visit notes of six types (initial evaluation, treatment note, progress note, re-evaluation, recertification, discharge summary) started from an appointment, a video visit or the patient's chart, a plan of care with goals, outcome measures (also sent to the patient as questionnaires), measurements, special tests and strength grades, note templates, the referral read by AI from its photo or PDF and the documents on the record (private storage, SPACES_BUCKET), the home program built from a signed evaluation, an AI first draft labelled in words, sign-and-lock with amendments, the Medicare rules (a progress note every tenth visit, the plan's certification, the yearly KX threshold, the 60-day lapse), the therapist's Notes tab and the clinic area's Documentation tab, and, where claimsPortal is on, the coding panel, the visit fee schedule in Claims › Setup and signed notes in To bill. The draft sends the record's facts, the intake summary, the earlier notes, the session data, the measurements, a video visit's notes or transcript and the therapist's own notes to OpenAI with store: false and no patient name; needs OPENAI_API_KEY (notes are written and signed by hand without it). The Medicare rules need the patient's insurance from the claims portal (or the clinic's "everyone" setting). Switched off again, the pages and endpoints disappear and the reminders stop; signed notes stay in the record and in the patient's Notes feed. See clinical-documentation.md |
leaderboards |
Weekly boards for patients and a monthly board for therapists. Every onboarded patient of the clinic gets a "Your week" card and a leaderboard under a random nickname (points for the share of this week's planned sessions done, a podium of three, the three directly ahead, their own row, tiers, a Monday recap push); they can hide themselves or change the nickname once a day. Therapists get a Leaderboard tab (web) and card (app). Scores are rewritten every ten minutes and after each session. See leaderboards.md |
leaderboardsStaff |
On by default while leaderboards is on (!== false). The clinic's therapists ranked by name for the month: caseload adherence, first-week starts, comebacks, reply time, with the worklist of patients who can still make their week. Set to false to keep patient boards without ranking the clinic's staff |
leaderboardsCrossClinic |
This clinic's people appear on, and can see, the all-clinics boards (an All clinics segment on both boards). Nicknames and numbers only, for therapists too; everyone has a switch to stay off it. The all-clinics podium shows only once three people have scored on it, so turn it on for several clinics at a time. Turning it on is the clinic agreeing that its people's nicknames and numbers show to other clinics' users |
videoVisits |
Enables video visits: clinicians start a branded video call from the web dashboard or the iPhone app, the patient joins from a link in any browser without an account, and (when the clinician asked for it and the patient agreed to an audio recording) AI drafts the visit note, a patient summary and the plan changes for the clinician to review. A visit with AI notes is joined only by a patient who gave that agreement. With the same agreement, the points of the conversation worth keeping are written down for the clinician while the visit runs (live notes; no flag of their own), and the assistant beside the call picks exercises and guideline passages from the conversation; the exercise on screen and the AI chat beside the call come with the feature too. The server also needs the Zoom Video SDK keys. Zoom carries the live call and, with that agreement, records its audio and transcribes it live. The recording goes to OpenAI after the visit (transcription and drafting); during the visit the live captions go to OpenAI as text and, with names and contact details struck out, to the decision model behind OpenRouter. The call's sound and picture go to neither. Check the agreements with each before enabling it for a clinic. See internal/video-visits.md |
quickWorkoutBuilder |
The quick workout builder on the public /patients page and its hero button to /aiworkout-builder. Missing means on for the main tenant (id 1) and off for a clinic; a stored true/false wins. Resolved into GET /v2/tenant/public-features. The web's /patients page reads it. See internal/tenant-branding.md |
hideExercisePublicToggle |
Hides the "Make public" checkbox on the exercise form on this clinic's site. The main sites never show it. |
hidePatientManagePlan |
Hides the Manage Your Plan card and the mobile app card on the patient's web dashboard. |
On-by-Default Flags¶
Most flags follow the === true guard shown above: absent means off. A flag that gates an existing behaviour has to work the other way round, otherwise enabling it would require backfilling every tenant.
Such flags are read as features[key] !== false — absent means on, and only an explicit false disables the feature:
In the admin flag catalog (OktaPT-FE/components/admin/feature-flags/flagCatalog.ts, shown on the admin Feature Flags page), these entries are declared with defaultOn: true, which makes the UI compute the same !== false check and show a "Default on" badge beside the label so the blank state isn't mistaken for "off". A flag that another one stands in for is on by default only while that one is off, so it gets no badge (see Flags that replace each other).
autoWorkoutGeneration, planContinuation, leaderboardsStaff and todaysPatients use this pattern.
Pairing with a per-therapist switch¶
autoWorkoutGeneration is the tenant half of a two-level control. The other half is a real column — TherapistSettings.autoWorkoutGenerationEnabled (Boolean @default(true), migration 20260708081911_adding_auto_gen_toggle) — toggled per doctor from admin User Management.
resolveContinuationMode(...) in OktaPT-API/src/services/planSchedule/continuation.ts requires the tenant flag and every therapist connected to that patient (active, accepted, non-archived connections, plus the plan's own doctor) to allow it. Any single false means the plan continues with exact copies instead of AI-adapted ones.
Flags that replace each other¶
autoWorkoutGeneration and planContinuation do the same job two ways (adding sessions when a plan runs low), so at most one of them is on for a tenant. tenantContinuationOf(features) in OktaPT-API/src/services/planSchedule/continuation.ts reads the pair as exactly one policy:
autoWorkoutGeneration |
planContinuation |
Policy | When a plan runs low |
|---|---|---|---|
unset or true |
anything | AI |
Copies on the schedule, AI-adapted where every connected therapist allows it, exact otherwise |
false |
unset or true |
REPEAT |
Exact copies on the schedule |
false |
false |
OFF |
Nothing is added. Missed sessions still move, and editors show only the sessions that exist |
The AI flag takes precedence because its own fallback is exact copies, so a tenant with it on always continues. In the admin catalog the plain flag declares supersededBy: "autoWorkoutGeneration", and the page keeps the pair to one of the three policies:
- While the AI flag is on, Plan Continuation shows as off, with the reason beside it in the tenant view and in the switch's tooltip in the grid.
- Turning Plan Continuation on turns the AI flag off.
- Changing the AI flag, either way, clears Plan Continuation's stored value. Turning AI off therefore always hands over to exact copies, as its "When off" text says; a
falsekept from earlier would otherwise decide unseen, since the page cannot show it while AI is on. Stopping continuation altogether is a second step: turn AI off, then Plan Continuation. - The toast after a change names the other flag when its switch flips, and its Undo puts back only the keys that change wrote, so a later change to another flag stays.
A tenant that wants no continuation at all has both set to false. The admin page does that in two clicks; the same as SQL (it also creates the settings row of a tenant that has none):
INSERT INTO "TenantSettings" ("tenantId", "allowedDomains", features)
VALUES (<TENANT_ID>, '{}', '{"autoWorkoutGeneration": false, "planContinuation": false}')
ON CONFLICT ("tenantId") DO UPDATE
SET features = "TenantSettings".features || EXCLUDED.features;
Switch a tenant off only once the API that reads planContinuation is live: an older API reads both flags false as exact copies.
Mobile¶
The mobile app has its own feature flag system using a React context provider.
File: Okta-Mobile/context/TenantFeaturesContext.tsx
import { useTenantFeatures } from "@/context/TenantFeaturesContext";
// Inside a component:
const { hasFeature } = useTenantFeatures();
const isEnabled = hasFeature("therapistPersonalizedExerciseVideo");
How it works:
- TenantFeaturesProvider wraps the root stack in app/_layout.tsx (inside AuthProvider)
- Fetches features from GET /v2/tenant/features when the user is authenticated
- Re-fetches when the app returns to the foreground (via AppState listener)
- Exposes hasFeature(name: string): boolean
File: Okta-Mobile/services/tenantFeatureService.ts — fetchTenantFeatures() API call using httpClient.
Key Files¶
| File | Role |
|---|---|
OktaPT-API/src/routes/tenant/controller.ts |
getTenantFeatures controller |
OktaPT-API/src/routes/tenant/index.ts |
GET /features route |
OktaPT-FE/lib/stores/tenantFeatures.ts |
Zustand store (fetch + cache + hasFeature) |
Okta-Mobile/context/TenantFeaturesContext.tsx |
React context provider (hasFeature) |
Okta-Mobile/services/tenantFeatureService.ts |
Mobile API service for fetching flags |