The coaches module manages the studio’s team. Each staff member is a Coach row with a role (HEAD_COACH or SUB_COACH), a job title, a permission group and a permissions JSON blob. The module invites members, edits them, and dismisses them while moving their open tasks and trainees to someone else. Source: backend/apps/core-api/src/modules/coaches/.

Mounting and auth

coachesRouter is mounted once in modules/index.ts: There is no bearer-lane mount. See API overview for the lane and the response envelope.

Roles

Role guards come from requireRole(...roles) in middleware/require-role.ts. It throws UNAUTHORIZED (“authentication required”) when req.auth is missing and FORBIDDEN (“insufficient role”) when the caller’s role is not in the list. The role is the one the web app sends in x-user-role. A missing or unknown value is read as SUB_COACH.

Owner protection

PATCH and DELETE also run assertCoachMutable in the service:
  • Editing your own row is always allowed.
  • The studio owner is found through the organization membership table: the member row with role owner for the studio’s externalOrgId. If the target row is the owner’s and the caller is someone else, the request fails with FORBIDDEN (“only the studio owner can change their own profile”).
  • When the owner cannot be identified (no linked organization, or no owner member), only a caller with role OWNER may change a HEAD_COACH other than themselves. Others get FORBIDDEN (“cannot change another admin on this team”).
webUserContext (middleware/web-context.ts) touches Coach rows on every web request:
  • For a caller with role OWNER, it upserts a HEAD_COACH row for that user, using the x-user-name and x-user-email headers.
  • It updates lastActiveAt on the caller’s row, at most once every 5 minutes per coach. This feeds the “last activity” column on the team page.

The coach object

Endpoints return the raw Coach row.
externalUserId is the auth user id the row is linked to, and it is optional. How an invited member’s row gets linked is outside this module. The values used for permissionGroup are not defined in this module. They are passed to organizationRoleForStaff from @perform/types.

Permissions blob

coachPermissions in coaches.schema.ts: How the blob is read (readCoachPermissions and coachAccessFrom):
  • A HEAD_COACH always holds everything, whatever is stored.
  • A SUB_COACH with no stored blob, or a blob that fails the schema, also holds everything. Coaches created before the permission model keep studio-wide access until someone configures them on the team screen.
  • A SUB_COACH with a valid blob holds what the blob says.
  • A row with active: false grants nothing. The clients router answers such a caller with FORBIDDEN.
In the code read for this page, the only field the server acts on is trainees. It drives the trainee scoping described on the Clients page. No server check on the other fields was found.

Endpoints

GET /v1/web/coaches

Lists the studio’s team members, newest first. Auth: web lane, any role.
string
HEAD_COACH or SUB_COACH.
boolean
Parsed with z.coerce.boolean(). When absent, the list returns active members only. See the warning below.
Case-insensitive match on name or email.
number
default:"1"
Positive integer.
number
default:"20"
Positive integer, maximum 500.
z.coerce.boolean() turns any non-empty string into true, including the string false. A query of ?active=false therefore lists active members. Only an empty value (?active=) coerces to false and lists dismissed members.
Response:
The item is shortened. Each item is the full coach object. Errors: VALIDATION, UNAUTHORIZED.

POST /v1/web/coaches

Adds a team member and sends them an invitation email. Responds with status 201. Auth: web lane, role OWNER or HEAD_COACH.
string
required
At least 1 character.
string
required
At least 1 character.
string
required
Valid email.
string
required
At least 1 character.
string | null
Job title shown on the team page. At least 1 character when set.
string | null
Permission group name. At least 1 character when set.
object
The permissions blob described above. Missing fields get their defaults.
string
default:"SUB_COACH"
HEAD_COACH or SUB_COACH.
string
Auth user id to link the row to. At least 1 character.
number
Integer.
What the service does:
  1. Inserts the Coach row with name built from first and last name. When billing plan limits apply, the insert runs inside the studio’s seat lock (planLimits.withSeat), so the seat count and the insert cannot race.
  2. Calls the team invite mailer with the studio id, email, name, job title and the inviting user’s id. The call is fire-and-forget: the response does not wait for it, and a failure is only logged (“team invitation: failed”).
Response: the created coach object. Errors:
  • VALIDATION: body fails the schema.
  • FORBIDDEN: “insufficient role”, or the plan has no free seat (details.reason is PLAN_LIMIT_SEATS, with limit, count and planId).
  • CONFLICT: a Coach row with the same externalUserId already exists in the studio (Prisma P2002 on the studioId and externalUserId unique index).

GET /v1/web/coaches/:id

Returns one team member. Auth: web lane, any role.
string
required
Coach id.
Response: the coach object. Errors: NOT_FOUND (“coach not found”).

GET /v1/web/coaches/:id/impact

Counts what dismissing this member would affect. The web app shows it before the dismissal dialog. Auth: web lane, role OWNER or HEAD_COACH.
string
required
Coach id.
Response:
  • openTasks: InboxItem rows with status OPEN or SNOOZED whose coachIds include this coach.
  • clients: trainees (with deletedAt null) that have a ClientCoach assignment to this coach.
Errors: NOT_FOUND (“coach not found”). FORBIDDEN (“insufficient role”).

PATCH /v1/web/coaches/:id

Updates a team member. Every field is optional. Auth: web lane, role OWNER or HEAD_COACH, plus the owner protection rules above.
string
required
Coach id.
string
At least 1 character. name is recomposed when first or last name changes.
string
At least 1 character.
string
Valid email.
string
At least 1 character.
string | null
Job title.
string | null
Permission group name.
object
The permissions blob. It replaces the stored blob, and missing fields get their defaults.
string
HEAD_COACH or SUB_COACH.
number
Integer.
boolean
Setting true on a dismissed member brings them back.
What the service does:
  1. Loads the row and runs the owner protection check.
  2. When active: true is sent for an inactive member, calls planLimits.assertCanAddCoach, because the member takes a seat again.
  3. In one transaction, updates the Coach row. If the row has an externalUserId and the studio is linked to an organization, it also updates that user’s member row role to organizationRoleForStaff(permissionGroup, role). A member row with role owner is never changed.
Reactivating through active: true only flips the flag on the Coach row. Dismissal deletes the user’s member row, and this endpoint does not recreate it (the role sync uses updateMany, which matches nothing when the row is gone).
Response: the updated coach object. Errors:
  • NOT_FOUND: “coach not found”.
  • FORBIDDEN: “insufficient role”, “only the studio owner can change their own profile”, “cannot change another admin on this team”, or no free seat on reactivation (PLAN_LIMIT_SEATS).
  • VALIDATION: body fails the schema.
  • CONFLICT: a unique constraint failed.

DELETE /v1/web/coaches/:id

Dismisses a team member. The row is kept for history with active: false. It is not deleted. Auth: web lane, role OWNER or HEAD_COACH, plus the owner protection rules above.
string
required
Coach id.
object
What happens to the member’s open tasks. Defaults to discard. Either { "mode": "reassign", "coachIds": [...] } or { "mode": "discard" }.
object
What happens to the member’s trainees. Defaults to unassign. Either { "mode": "reassign", "coachIds": [...] } or { "mode": "unassign" }.
coachIds holds 1 to 50 coach ids in both objects. The whole body is optional.
What the service does:
  1. Loads the row and runs the owner protection check.
  2. Resolves the replacement ids for each reassign mode. Duplicates and the dismissed coach’s own id are removed. Every remaining id must be an active coach of the studio.
  3. Loads the member’s open tasks (InboxItem with status OPEN or SNOOZED and this coach in coachIds) and assigned trainees, both oldest first.
  4. Plans the changes (coaches.dismissal.ts):
    • Tasks. In each task’s coachIds, the dismissed coach is swapped for a replacement. Replacements rotate through the list in order (round-robin by task index). With discard, the coach is removed. A shared task keeps its other assignees. A task left with no assignee is discarded.
    • Trainees. The same swap on each trainee’s coach list. The primary coach (Client.coachId) is kept when still on the list. Otherwise the first remaining coach becomes primary, or null.
  5. Applies everything in one transaction (applyDismissal, timeout 30 seconds):
    • Discarded tasks get status DISMISSED, resolvedAt set to now, and no assignees.
    • Reassigned tasks get the new coachIds and coachId.
    • The dismissed coach’s ClientCoach rows are deleted, the replacement rows are created, and Client.coachId is updated.
    • The coach is removed from task automations: TaskAutomation.assigneeCoachId, TaskAutomation.assigneeCoachIds and TaskAutomationTask.assigneeCoachIds.
    • The Coach row is set to active: false.
    • If the row has an externalUserId and the studio is linked to an organization, the user’s member row is deleted, unless its role is owner.
No email or push is sent. Response:
coach is shortened here. It is the full coach object. tasksReassigned counts tasks that still have an assignee after the change, including shared tasks that only lost the dismissed coach. Errors:
  • NOT_FOUND: “coach not found”.
  • FORBIDDEN: “insufficient role”, “only the studio owner can change their own profile”, “cannot change another admin on this team”.
  • VALIDATION: body fails the schema. “select at least one other team member” when a reassign list is empty after removing the dismissed coach. “unknown team member selected” when an id is not an active coach of the studio.

Common errors