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.string
Case-insensitive
contains match on title, detail or the trainee’s name.number
default:"1"
Positive integer.
number
default:"20"
Positive integer, maximum 500.
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.
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.TaskContextResponse (context-types.ts). A task with no clientId returns only variant set to activity and activity set to null.
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.
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).
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.
coachIds is resolved per trainee:
UNASSIGNED: always empty, even when specific ids are sent.RESPONSIBLE: the trainee’s trainers fromassignedCoachIds(): the primaryClient.coachIdplus every active coach incoachAssignments, plus any specific ids.OWNER: every active coach with roleHEAD_COACHin the studio, plus any specific ids.SPECIFIC: only the specific ids.
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.
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:
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.- When
coachIdsis 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. handledNoteis saved only forFORM_FILLED,FORM_FILLED_OVERDUEandFORM_RATING_BELOWtasks. The response id is read frommetadata.responseId, thenmetadata.formResponseId, thenrefId. The note is written to theFormResponserow (handledNote,handledById,handledByName,handledAt), not to the task, through anupdateManyscoped to the studio by the form template. A stale response id is a no-op. The actor is theCoachrow whoseexternalUserIdmatchesreq.auth.userId. When there is no such row, the id and name are stored asnull.- For every other task type the note is dropped without an error.
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 throughonce(), 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. seriesmarks each day withlevel2 for a logged workout, 1 for other activity (a meal log or a daily metric) and 0 for a quiet day.appActivityusesappActivityLevels()fromclient-tracking:fullfor a workout or meal log,lightfor an app open (ClientAppDay) or a plan tick day (NutritionDayLog), otherwisenone.weeklyStreakcounts consecutive 7 day windows with at least one workout, looking back at mostSTREAK_WEEK_LOOKBACK= 12 weeks.weeklyWorkoutsis the number of distinct workout days in the rolling last 7 days.weekToDatecounts completed workouts since the start of the current week against the weekly target. The target ismeta.workoutsPerWeekfrom the newestACTIVEnon nutrition program, or its number of days. It isnullfor a file plan.plancomes from that same program.totalPlannedis weeks betweenstartsOnandendsOntimes the number of split days. A file plan (isFilePlan) returnsfilePlantrue with every targetnull.subscriptionis the live subscription (ACTIVE,FROZENorSCHEDULED) that ends furthest out. With none live it falls back to the newest subscription, and then toClient.planNameandClient.endsOn.reach.platformcomes fromappPlatform():Client.lastCheckInPlatform, then the newest push token.pushReachableis true when at least one push token is registered.facts.firstStartedOnisfirstRanStart(): the earliest subscription that started by today and was not cancelled on or before its first day. It isnullwhen none ran or when that lookup failed.alertis the task’s owndetail.
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.
schemaSnapshotis the response’s own snapshot, falling back to the template’s current schema.summaryis the cached AI summary only. It is read from the task’s ownmetadata.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.handledis the handling note written byPATCH /v1/web/inbox/:id.- Form variant tasks also get the legacy
history,programsandmbpEnabledfields. FORM_RATING_BELOWaddsratingTrend(every rating field across up to 8 submissions of the same template, oldest first),ratingThresholdandratingKeys(the rating keys below the threshold, recomputed at view time withratingsBelowThreshold).FORM_FILLEDandFORM_FILLED_OVERDUEaddsubmissionNo(count of submissions of that template up to this one) and, forCHECK_INandINTAKEforms,weighInwithkg,prevKgandprevAtfromWeightEntryrows 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 inmetadata.assignmentId, as aFormAssignmentView.visibleAtis the later ofcreatedAtanddueAt.signingUrlis built withsigningUrlFor(APP_WEB_URL, signToken)and isnullfor forms without a sign token.focusMissing: true when the task carries anassignmentIdbut the row is gone. Cancelling a pending assignment deletes it.pending(formsPending):PENDINGassignments, oldest first, up to 20.history(formsHistory): the newest 30 submissions.readiness(formsReadiness): forNEW_CLIENT_IN_PLANone row for the client’s onboarding form. ForSUBSCRIPTION_ASSIGNEDtwo 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.evidenceis built only forCALORIE_UNDERandCALORIE_OVER. It callscalorieDayEvidence()for yesterday in the trainee’s timezone against the newestACTIVEnutrition program. It isnullfor a file plan or a plan with no positive kcal target.matchesreports whether the trigger would fire now with the resolved threshold.planDayLoggedreports 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.aerobicscoresAEROBIC_WEEKS= 4 complete Sunday to Saturday weeks withcreditedCardioByLog().goal,creditedanddeltaPctdescribe the last complete week.dayslists its seven days with credited workouts, logged cardio minutes, cardio activities and steps.weekslists all four with amissedflag. It isnullwhen there is noACTIVEtraining 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 inACTIVEtraining programs, withrestconverted to seconds byrestSeconds().sessions: up toEXERCISE_SESSION_LIMIT= 6 logged sessions from the lastEXERCISE_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 fromopenTechniqueRequests(), ornull.
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) andweightwith the first and latest weigh in plusgoalKg.weightisnullwith fewer than two weigh ins.milestoneechoesmetadata.milestone, which no trigger writes.subscription:focusis theClientSubscriptioninmetadata.subscriptionId(no price on this route) andpriorCountis the number of non cancelled subscriptions that started before it.message: the inboundWhatsappMessageinrefId(id,body,createdAt) andopenSiblings, the count of otherOPENorSNOOZEDMESSAGEtasks for the same trainee. See WhatsApp.
Related pages
- Tasks: what creates automatic rows and when.
- Task automations: trigger settings and flows.
- Automation: the
/v1/web/automationrouter.