Before you start
Decide three things first:- The caller. That decides the lane. See the table at the end of Route lanes.
- The tenant key. Every domain row belongs to a studio, directly through
studioIdor through a parent that has one. - Who may call it. Any coach, only
OWNERandHEAD_COACH, or coaches restricted to their assigned trainees. See Roles and permissions.
Add a new module
1
Change the data model in both schemas
The repo has two Prisma schemas and both must change.See Database packages for why there are two.
packages/db/prisma/schema.prismaowns migrations. Add the model, then add a migration folder underpackages/db/prisma/migrations/with amigration.sql.packages/database/prisma/schema.prismagenerates the client the API compiles against. Mirror the same model there and add@@schema("public").
core-api sees the new types:2
Write the schema file
Create
modules/<name>/<name>.schema.ts with one Zod schema per input: create<Entity>Body, update<Entity>Body, <entity>ListQuery, idParam. Export the inferred type beside each one. See Validation.3
Write the repository
create<Name>Repository(prisma: PrismaClient) returns the data access functions. Every read filters by studioId. Use findFirst({ where: { id, studioId } }) for single rows.4
Write the service
create<Name>Service(repo, ...deps). Take studioId as the first argument of every function. Throw AppError(ErrorCode.X, message) for rule violations. Before an update or delete by id, load the row through a studio scoped get so a foreign id answers NOT_FOUND.5
Write the controller
create<Name>Controller(service). Parse req.query, req.params and req.body with the schemas. Read the studio with a local studioIdOf(req). Respond with ok, created or noContent from http/respond.ts.6
Write the routes file
<name>Router(ctx: AppContext): Router. Build repository, service and controller from the context, then declare the routes. Put literal paths before /:id. Add requireRole(...) per route where needed.7
Mount it
In Place the line with the other
modules/index.ts, import the router and mount it with the lane’s guard array:/web mounts, above router.use(replay).8
Test it
Add
apps/core-api/tests/<name>.test.ts. Construct the service with a fake repository (a plain object with the same functions) and assert each branch that throws. See Testing.9
Run the gates
pnpm run ci at the repo root runs format check, lint, the em dash check, typecheck, tests and build for the whole workspace.Add an endpoint to an existing module
- Add the input schema to
<name>.schema.ts. - Add the repository function if new data access is needed.
- Add the service function with the rule.
- Add the controller handler and return it from the factory.
- Add the route line. Check its position relative to
/:idroutes.
create<Name>Service( first. Services such as createFormsService and createClientSubscriptionsService are built in several routers and in workers.
Add a trainee endpoint
Trainee routes live inmodules/trainee/ or in a trainee<Thing>Router exported from the owning module.
- Use
authenticateTrainee(ctx.env.BETTER_AUTH_SECRET, ctx.prisma). - Add
requireTraineeAppAccess(repository.accessState)unless the route must work for a frozen trainee. - Read the principal from
(req as TraineeRequest).trainee. Scope queries byclientIdandstudioIdfrom it. - Remember that a preview token is refused on any method other than
GET. A new read that has side effects (a “seen” stamp, an activity touch) must checkprincipal.previewitself, asmedoes. - Use the trainee’s time zone for day boundaries. See Time zones and day keys.
HEART_RATE in an enum and return explicit null heart rate fields for that reason.
Add an automation or MCP capability
Add the endpoint tomodules/automation-api/. Use sendOk(res, data, message) and write error messages as full sentences that say what to fix, because Make shows them to a coach and an AI model reads them to recover.
To expose it as an MCP tool, add a ToolDefinition to packages/mcp-tools/src/tools.ts. Both the stdio binary and the hosted /mcp endpoint pick it up. Read the scope rules on MCP first.
Add a background job
See “Adding a queue” on Queues and jobs.Checklist
- Both Prisma schemas changed, migration written,
@repo/databaserebuilt. - Every query scoped by
studioIdfrom the principal. - Inputs parsed with Zod. No field read from
req.bodyunparsed. - Role guard on writes that only admins may do.
- Plan limits respected if the endpoint creates a trainee or a team member (
billingFor(ctx).limits). - Notifications go through
createTraineeNotifier, never straight to Expo. - No secrets or tokens in logs.
- Lint, typecheck and tests green.