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 fromrequireRole(...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
memberrow with roleownerfor the studio’sexternalOrgId. If the target row is the owner’s and the caller is someone else, the request fails withFORBIDDEN(“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
OWNERmay change aHEAD_COACHother than themselves. Others getFORBIDDEN(“cannot change another admin on this team”).
Related behaviour in the web lane
webUserContext (middleware/web-context.ts) touches Coach rows on every web request:
- For a caller with role
OWNER, it upserts aHEAD_COACHrow for that user, using thex-user-nameandx-user-emailheaders. - It updates
lastActiveAton 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 rawCoach 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_COACHalways holds everything, whatever is stored. - A
SUB_COACHwith 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_COACHwith a valid blob holds what the blob says. - A row with
active: falsegrants nothing. The clients router answers such a caller withFORBIDDEN.
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.string
Case-insensitive match on
name or email.number
default:"1"
Positive integer.
number
default:"20"
Positive integer, maximum 500.
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.
- Inserts the
Coachrow withnamebuilt 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. - 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”).
VALIDATION: body fails the schema.FORBIDDEN: “insufficient role”, or the plan has no free seat (details.reasonisPLAN_LIMIT_SEATS, withlimit,countandplanId).CONFLICT: aCoachrow with the sameexternalUserIdalready exists in the studio (PrismaP2002on thestudioIdandexternalUserIdunique index).
GET /v1/web/coaches/:id
Returns one team member.
Auth: web lane, any role.
string
required
Coach id.
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.
openTasks:InboxItemrows with statusOPENorSNOOZEDwhosecoachIdsinclude this coach.clients: trainees (withdeletedAtnull) that have aClientCoachassignment to this coach.
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.- Loads the row and runs the owner protection check.
- When
active: trueis sent for an inactive member, callsplanLimits.assertCanAddCoach, because the member takes a seat again. - In one transaction, updates the
Coachrow. If the row has anexternalUserIdand the studio is linked to an organization, it also updates that user’smemberrow role toorganizationRoleForStaff(permissionGroup, role). Amemberrow with roleowneris 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).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.
- Loads the row and runs the owner protection check.
- Resolves the replacement ids for each
reassignmode. Duplicates and the dismissed coach’s own id are removed. Every remaining id must be an active coach of the studio. - Loads the member’s open tasks (
InboxItemwith statusOPENorSNOOZEDand this coach incoachIds) and assigned trainees, both oldest first. - 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). Withdiscard, 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, ornull.
- Tasks. In each task’s
- Applies everything in one transaction (
applyDismissal, timeout 30 seconds):- Discarded tasks get status
DISMISSED,resolvedAtset to now, and no assignees. - Reassigned tasks get the new
coachIdsandcoachId. - The dismissed coach’s
ClientCoachrows are deleted, the replacement rows are created, andClient.coachIdis updated. - The coach is removed from task automations:
TaskAutomation.assigneeCoachId,TaskAutomation.assigneeCoachIdsandTaskAutomationTask.assigneeCoachIds. - The
Coachrow is set toactive: false. - If the row has an
externalUserIdand the studio is linked to an organization, the user’smemberrow is deleted, unless its role isowner.
- Discarded tasks get status
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 areassignlist is empty after removing the dismissed coach. “unknown team member selected” when an id is not an active coach of the studio.