The inbox module stores and serves the coach task board. Each task is an InboxItem row. Rows are written by coaches (manual tasks), by the task generator and its event triggers (see Tasks), and by flow task nodes (see Task automations). Source: backend/apps/core-api/src/modules/inbox/.

Mounting and auth

inboxRouter(ctx) is mounted in modules/index.ts at /v1/web/inbox behind the web lane middleware: serviceAuth (HMAC service auth from @perform/security), webUserContext(ctx.prisma) and webLimiter. webUserContext requires the x-studio-id and x-user-id headers. Without either it fails with UNAUTHORIZED and the message missing web user context. It resolves the studio and sets req.auth to studioId, userId and role. An unknown or missing x-user-role is read as SUB_COACH. The routes file adds no requireRole, withCoachAccess or requireAssignedClient guard. Every authenticated web lane caller can reach every endpoint here, and every query is scoped by req.auth.studioId only. The list is not narrowed to the caller’s own trainees or tasks on the server. The coachId query parameter is a filter the caller chooses. Responses use the standard envelope from http/respond.ts. A Zod parse failure returns HTTP 422 with code VALIDATION. See API overview.

The InboxItem row

Task types accepted by the schema (inboxItemType in inbox.schema.ts): MESSAGE, TECHNIQUE_VIDEO, FORM_FILLED, FORM_FILLED_OVERDUE, FORM_SENT, FORM_FEEDBACK_SENT, FORM_RATING_BELOW, FORM_RESPONSE, CHURN_RISK, MISSED_CHECK_IN, NO_WORKOUT, INACTIVE, PLAN_EXPIRING, MILESTONE, MISSED_STREAK, NEW_CLIENT_IN_PLAN, SUBSCRIPTION_ASSIGNED, CALORIE_UNDER, CALORIE_OVER, AEROBIC_UNDER, PAUSED_DURATION, TRAINING_PLAN_STALE, NUTRITION_PLAN_STALE, MANUAL.

Endpoints

GET /v1/web/inbox

Lists the studio’s tasks, sorted by priority descending and then createdAt descending. Auth: web lane, any role.
string or string[]
One of OPEN, SNOOZED, DONE, DISMISSED, or several values as a repeated query key. Omit for every status.
string
One task type from the list above.
string
Returns tasks visible to that coach: coachIds contains the id, or coachId equals it, or the task is unassigned (coachIds empty and coachId null). This is coachVisibilityWhere in inbox.repository.ts.
Case-insensitive contains match on title, detail or the trainee’s name.
number
default:"1"
Positive integer.
number
default:"20"
Positive integer, maximum 500.
Response: data holds items, total, page and pageSize. Each item is the full InboxItem row plus two relations. client carries id, name, coachId, phone, endsOn and a computed coverageEndsOn. coverageEndsOn comes from coverageEndsOn() in client-subscriptions/coverage.ts, fed with Client.endsOn and the trainee’s live subscriptions, so the panel shows the day the trainee stops being a member and not the end of the plan running now. coach carries id and name of the row’s coachId.
Errors: UNAUTHORIZED (401) when req.auth has no studio. VALIDATION (422) for a bad query.

GET /v1/web/inbox/:id/context

Returns everything the task panel shows for one task, in one request. The shape depends on the task type. See How the context is built. Auth: web lane, any role.
string
required
The InboxItem id.
Response: a TaskContextResponse (context-types.ts). A task with no clientId returns only variant set to activity and activity set to null.
Errors: NOT_FOUND (404) inbox item not found when the id is not in the caller’s studio. A section loader that throws does not fail the request. It returns null for that key and its name is added to failedSections.

POST /v1/web/inbox

Creates one bare inbox row. This is the low level create. Manual tasks from the board use POST /v1/web/inbox/tasks. Auth: web lane, any role.
string
required
One task type from the list above.
string
When set, the row gets coachId and coachIds with that single id.
string
The trainee.
string
Free reference id.
integer
default:"0"
Sort priority.
The repository writes the row as given. It does not check that coachId or clientId belong to the caller’s studio, and it sets no title, detail, source or autoKey, so source stays at the column default manual. Response: HTTP 201 with the created InboxItem row (no client or coach relation).
Errors: VALIDATION (422). A foreign key that does not exist surfaces through the Prisma error mapping in @perform/errors.

POST /v1/web/inbox/tasks

Creates one MANUAL task per trainee, with a title, optional reminder text, assignees and due date. Auth: web lane, any role.
string[]
required
At least one trainee id. Ids that are not in the caller’s studio are dropped silently.
string
required
Task title, at least one character.
string
default:"RESPONSIBLE"
OWNER, RESPONSIBLE, SPECIFIC or UNASSIGNED.
string
A single specific coach. Used only when assigneeCoachIds is absent.
string[]
Specific coaches. Wins over assigneeCoachId.
integer
default:"1"
Coerced from a string when needed.
string
Stored in detail.
date
Coerced to a date. An invalid date is rejected.
How coachIds is resolved per trainee:
  • UNASSIGNED: always empty, even when specific ids are sent.
  • RESPONSIBLE: the trainee’s trainers from assignedCoachIds(): the primary Client.coachId plus every active coach in coachAssignments, plus any specific ids.
  • OWNER: every active coach with role HEAD_COACH in the studio, plus any specific ids.
  • SPECIFIC: only the specific ids.
The list is de-duplicated and coachId is set to its first entry. Rows are written with createMany, type MANUAL and source manual. Specific ids are not checked against the studio’s coaches on this endpoint. No push, WhatsApp message or job is sent. Response: HTTP 201.
created is 0 when none of the clientIds belong to the studio. Errors: VALIDATION (422).

POST /v1/web/inbox/bulk

Sets the status of many tasks at once. Auth: web lane, any role.
string[]
required
At least one task id.
string
required
OPEN, SNOOZED, DONE or DISMISSED.
date
Written when present.
Runs one updateMany scoped to the studio. resolvedAt is set to now for DONE and DISMISSED and cleared to null for OPEN and SNOOZED. Ids from another studio are ignored. Bulk close never writes a handling note. Response:
Errors: VALIDATION (422).

PATCH /v1/web/inbox/:id

Updates one task: status, snooze, priority, owners, and the handling note for form tasks. Auth: web lane, any role.
string
required
The InboxItem id.
string
OPEN, SNOOZED, DONE or DISMISSED. resolvedAt follows the same rule as the bulk endpoint.
date
Stored as given. It does not change status on its own.
integer
New priority.
string
Trimmed, 10 to 4000 characters. What the coach changed after reading a form.
string[]
Up to 50 ids. Replaces the whole owner list. An empty array returns the task to the unassigned pool. coachId becomes the first id or null.
Side effects:
  • When coachIds is not empty, the service counts active coaches of this studio among the ids. If the count differs from the number of distinct ids, the request is refused.
  • handledNote is saved only for FORM_FILLED, FORM_FILLED_OVERDUE and FORM_RATING_BELOW tasks. The response id is read from metadata.responseId, then metadata.formResponseId, then refId. The note is written to the FormResponse row (handledNote, handledById, handledByName, handledAt), not to the task, through an updateMany scoped to the studio by the form template. A stale response id is a no-op. The actor is the Coach row whose externalUserId matches req.auth.userId. When there is no such row, the id and name are stored as null.
  • For every other task type the note is dropped without an error.
Response: the updated InboxItem row, without relations. Errors: NOT_FOUND (404) inbox item not found. BAD_REQUEST (400) assignee is not an active coach here. VALIDATION (422).

How the context is built

service.context() in inbox.service.ts loads the task, reads its metadata, picks the sections for its type, runs them in parallel and composes one TaskContextResponse. The pure shaping code lives in the context-*.ts files. The queries live in inbox.repository.ts.

Sections per task type

CONTEXT_SECTIONS in context-sections.ts is the source. A type that is not listed gets the default set. The four forms* sections all land under the single forms key. variant is the older panel selector and is kept so an older workboard keeps working: video for TECHNIQUE_VIDEO, form for FORM_RESPONSE, FORM_FILLED, FORM_FILLED_OVERDUE, FORM_RATING_BELOW and FORM_FEEDBACK_SENT, birthday for MILESTONE, and activity for everything else. A form variant task always carries a form key, null when the section is not loaded or the response is gone.

Shared queries and failure handling

Loaders share queries through once(), a per-request memo. The shared ones are the client row (contextClient), the resolved timezone (resolveTimeZone(client.timezone, studio.timezone)), live programs (livePrograms: ACTIVE and PAUSED, with content, newest update first, up to 30), the program list (programsFor: every non DRAFT program without content, up to 30), form history (formHistoryFor: newest 30 submissions), the automation threshold, the focused form assignment and its response, the tenure start, and the focused subscription. Each top level section runs through settle(). A loader that throws is logged as inbox context: section failed, returns null, and its name is pushed to failedSections. Inner parts of the form and video sections use the same wrapper under names such as form.summary and video.exercise, and those only log.

activity

loadActivity calls repo.activityFor() and passes the rows to shapeActivity().
  • The chart covers TASK_CONTEXT_DAYS = 14 civil days in the trainee’s timezone, oldest first.
  • series marks each day with level 2 for a logged workout, 1 for other activity (a meal log or a daily metric) and 0 for a quiet day.
  • appActivity uses appActivityLevels() from client-tracking: full for a workout or meal log, light for an app open (ClientAppDay) or a plan tick day (NutritionDayLog), otherwise none.
  • weeklyStreak counts consecutive 7 day windows with at least one workout, looking back at most STREAK_WEEK_LOOKBACK = 12 weeks.
  • weeklyWorkouts is the number of distinct workout days in the rolling last 7 days.
  • weekToDate counts completed workouts since the start of the current week against the weekly target. The target is meta.workoutsPerWeek from the newest ACTIVE non nutrition program, or its number of days. It is null for a file plan.
  • plan comes from that same program. totalPlanned is weeks between startsOn and endsOn times the number of split days. A file plan (isFilePlan) returns filePlan true with every target null.
  • subscription is the live subscription (ACTIVE, FROZEN or SCHEDULED) that ends furthest out. With none live it falls back to the newest subscription, and then to Client.planName and Client.endsOn.
  • reach.platform comes from appPlatform(): Client.lastCheckInPlatform, then the newest push token. pushReachable is true when at least one push token is registered.
  • facts.firstStartedOn is firstRanStart(): the earliest subscription that started by today and was not cancelled on or before its first day. It is null when none ran or when that lookup failed.
  • alert is the task’s own detail.

form

loadForm resolves the response from metadata.responseId, metadata.formResponseId or refId. repo.formResponseFor() matches the id against either FormResponse.id or FormResponse.assignmentId. For FORM_SENT it uses the response of the focused assignment, and only when that assignment is COMPLETED.
  • schemaSnapshot is the response’s own snapshot, falling back to the template’s current schema.
  • summary is the cached AI summary only. It is read from the task’s own metadata.aiSummary, then from any task of the same submission (repo.cachedSummaryFor). This endpoint never calls the model. The summary endpoint on Forms fills the cache.
  • handled is the handling note written by PATCH /v1/web/inbox/:id.
  • Form variant tasks also get the legacy history, programs and mbpEnabled fields.
  • FORM_RATING_BELOW adds ratingTrend (every rating field across up to 8 submissions of the same template, oldest first), ratingThreshold and ratingKeys (the rating keys below the threshold, recomputed at view time with ratingsBelowThreshold).
  • FORM_FILLED and FORM_FILLED_OVERDUE add submissionNo (count of submissions of that template up to this one) and, for CHECK_IN and INTAKE forms, weighIn with kg, prevKg and prevAt from WeightEntry rows linked to this response and the previous submission of the same template.

forms

loadForms fills one object from up to four sections:
  • focus (formsFocus): the assignment in metadata.assignmentId, as a FormAssignmentView. visibleAt is the later of createdAt and dueAt. signingUrl is built with signingUrlFor(APP_WEB_URL, signToken) and is null for forms without a sign token.
  • focusMissing: true when the task carries an assignmentId but the row is gone. Cancelling a pending assignment deletes it.
  • pending (formsPending): PENDING assignments, oldest first, up to 20.
  • history (formsHistory): the newest 30 submissions.
  • readiness (formsReadiness): for NEW_CLIENT_IN_PLAN one row for the client’s onboarding form. For SUBSCRIPTION_ASSIGNED two rows, onboarding and update, taken from the product (metadata.productId, or the focused subscription’s product) with the client’s own form ids as fallback. Each row carries the template name plus the latest assignment and the latest response of that template for this trainee.

plans

programViews() turns the program list into ProgramView rows. Content is read only for ACTIVE and PAUSED programs. A nutrition program reports the first day’s targets (kcal, protein, carbs, fat, the three mbp* values) and its meal count. A training program reports each day’s label, exercise count and cardio minutes. File plans and archived rows return nutrition and days as null. focusProgramId() picks the program the task is about: for the two *_PLAN_STALE types, metadata.programId while it is still ACTIVE, else the newest ACTIVE program of that type. For CALORIE_* the newest ACTIVE nutrition program. For NO_WORKOUT, MISSED_STREAK and AEROBIC_UNDER the newest ACTIVE training program. Otherwise null.

nutrition and aerobic

These reuse the matcher code from Tasks, so the numbers shown are computed the same way as the numbers that fired the task.
  • nutrition.evidence is built only for CALORIE_UNDER and CALORIE_OVER. It calls calorieDayEvidence() for yesterday in the trainee’s timezone against the newest ACTIVE nutrition program. It is null for a file plan or a plan with no positive kcal target. matches reports whether the trigger would fire now with the resolved threshold. planDayLogged reports whether the trainee ticked plan meals that day, which the trigger does not count. The evidence is relative to now, so a task opened days later shows current numbers.
  • aerobic scores AEROBIC_WEEKS = 4 complete Sunday to Saturday weeks with creditedCardioByLog(). goal, credited and deltaPct describe the last complete week. days lists its seven days with credited workouts, logged cardio minutes, cardio activities and steps. weeks lists all four with a missed flag. It is null when there is no ACTIVE training program or it is a file plan.
thresholdFor() resolves the percent threshold from the task’s autoKey and the TaskAutomation row. A key containing :fx: (a flow task) and any key of a trigger that has a flow use the parent row’s thresholdValue. A legacy key ending in :tN uses task N. A key with no suffix uses task 0, or the parent when there are no task rows. A task without an autoKey has no threshold.

video

loadVideo loads the TechniqueVideo from metadata.videoId or refId. When an exercise id is known it adds exercise:
  • prescriptions: rows prescribing this exercise in ACTIVE training programs, with rest converted to seconds by restSeconds().
  • sessions: up to EXERCISE_SESSION_LIMIT = 6 logged sessions from the last EXERCISE_SESSION_DAYS = 90 days.
  • previousVideos: up to 5 other uploads of the same exercise, newest first.
  • videoNumber: this upload’s position among the exercise’s uploads.
  • openRequestAt: an open technique request for the exercise from openTechniqueRequests(), or null.
programName is the first prescription as program name and day label, falling back to metadata.programName.

birthday, subscription, message

  • birthday: birthDate, startedOn, firstStartedOn, lastCheckInAt, totalWorkouts (completed workout logs ever) and weight with the first and latest weigh in plus goalKg. weight is null with fewer than two weigh ins. milestone echoes metadata.milestone, which no trigger writes.
  • subscription: focus is the ClientSubscription in metadata.subscriptionId (no price on this route) and priorCount is the number of non cancelled subscriptions that started before it.
  • message: the inbound WhatsappMessage in refId (id, body, createdAt) and openSiblings, the count of other OPEN or SNOOZED MESSAGE tasks for the same trainee. See WhatsApp.