backend/apps/core-api/src/modules/tasks/ has no routes, controller or schema file and is not mounted in modules/index.ts. It is a library that other modules and workers call. It decides which trainees match each trigger, writes InboxItem rows with source set to auto, closes rows that stopped matching, keeps task owners in line with the trainee’s trainers, and hands matched entities to the flow runtime when a trigger has a flow.
The rows it writes are read and updated through Inbox. Trigger settings are edited through Task automations.
Files
Two ways a task is raised
- The hourly sweep.
startTaskScheduler(ctx)inworkers/task-scheduler.tsis started fromserver.ts. It registers a BullMQ repeatable job on theautomation-evaluatequeue (PerformQueue.AUTOMATION_EVALUATE) with job idtask-generator-hourly, repeating every hour, plus onebootjob on every process start. The worker has concurrency 1 and a 10 minute lock. Each run callsrunTaskGenerator(ctx.prisma, flowRunner, ctx.logger). - Event hooks. Form submissions, form sends, form feedback and subscription assignments call into this module at the moment they happen. The sweep returns early for those types.
runTaskGenerator loads every studio with deletedAt null and calls generateForStudio for each. A studio that throws is logged as task generator: studio failed and the loop continues. generateForStudio loads effectiveAutomations, and for each entry first calls syncResponsibleAssignees (even when the trigger is disabled), then generateForType when it is enabled.
Effective automations
effectiveAutomations(prisma, studioId) merges the studio’s TaskAutomation rows with the catalogue AUTOMATION_DEFAULTS from task-automations.schema.ts. A trigger type that is missing from the catalogue is never evaluated, even when the studio has a row for it.
- Flow mode. When the row has a
flow, the trigger evaluates once, from the parent row’sthresholdValue,thresholdUnit,assigneeModeandplanIds. Stored task blocks are ignored. ForFORM_RESPONSEthe form scope comes fromflowFormScope(flow.trigger). Every other type uses all four form types. - Legacy, no task blocks. One entry built from the parent row, or from the catalogue default when the studio has no row.
- Legacy, with task blocks. One entry per
TaskAutomationTask, each with its own threshold, assignee, name, trainee message and form scope. A trigger can hold up toMAX_TASKS_PER_AUTOMATION= 5 blocks.
now - thresholdValue in the block’s unit: minutes, hours, and anything else is read as days.
Task types and the rule that creates each
Titles and details are Hebrew strings intask-content.ts. A coach-authored name replaces the title. detail and priority always come from CONTENT, except FORM_RATING_BELOW, which writes its own detail.
Sweep triggers
All of these match clients of the studio. Unless noted, the client must have statusACTIVE.
Metadata written by the sweep:
TECHNIQUE_VIDEO:videoId,videoUrl, andexerciseIdandexerciseNamewhen known.- The two
*_PLAN_STALEtypes:programIdof the newest updatedACTIVEprogram of that type.
:t<ordinal> to its key, for example INACTIVE:<clientId>:t2. Ordinal 0 keeps the key without a suffix.
PLAN_EXPIRING always multiplies thresholdValue by one day for its horizon, whatever thresholdUnit says. NO_WORKOUT matches on the plain cutoff. effectiveAutomations also carries thresholdByFrequency (days per weekly workout band), but no matcher in task-generator.ts reads it.Calorie matching (calorie-day.ts)
matchCalorieClients reads the newest updated ACTIVE NUTRITION program and the trainee’s meal logs from the last CALORIE_LOOKBACK_MS (2 days). calorieDayEvidence() takes yesterday’s kcal target from the plan (nutritionTargetsForOffset(content, -1)), sums MealLog.calories consumed during yesterday in the trainee’s timezone (resolveTimeZone(client.timezone, studio.timezone)), and returns deltaPct as consumed minus goal, divided by goal, in percent. Plan ticks in NutritionDayLog are not counted. A file plan, or a plan with no positive target for yesterday, is skipped. calorieTriggerHits() fires CALORIE_UNDER when the negative delta reaches the threshold and CALORIE_OVER when the positive delta does.
Aerobic matching (aerobic-week.ts)
matchAerobicClients reads the newest updated ACTIVE TRAINING program and completed workout logs around the last complete Sunday to Saturday week. Logs are read from 9 days before to 2 days after the studio’s week (AEROBIC_LOOKBACK_MS, AEROBIC_LOOKAHEAD_MS) so a log lands on its civil day in the trainee’s own timezone. The weekly goal is weeklyCardioMinutes(requirement, workoutsPerWeek). Trainees with a goal of 0 or a file plan are skipped. creditedCardioByLog() gives each completed workout its split day’s cardio minutes when the log label matches a day label, otherwise an even share of the weekly goal. missesWeeklyAerobicGoal() fires when the shortfall percent reaches the threshold. Logged CardioLog rows are not part of the credit.
Event triggers
formInScope(scope, event) is true when the form’s type is in formTypes or its template id is in formIds.
The four form triggers share one legacy fan out: every enabled in-scope task block produces one row, all at once. Blocks beyond ordinal 0 key as <type>:<entityId>:t<ordinal>. All rows have priority 2.
Metadata:
FORM_FILLEDandFORM_FEEDBACK_SENT:formResponseId,formTemplateId,formName.FORM_SENT:assignmentId,formTemplateId,formName.FORM_RATING_BELOW: the same asFORM_FILLEDplusratingFieldKey,ratingLabelandratingValueof the lowest hit, andratingSummarylisting every hit. Itsdetailis built byratingDetail().ratingValue()accepts whole numbers of 1 or more only.MIN_RATING_THRESHOLDis 2 andMAX_RATING_THRESHOLDis 10.
Flow first, legacy second
For the form triggers, the caller asks the flow runtime first.startFormFilledFlowRuns, startFormRatingBelowFlowRuns, startFormSentFlowRuns and startFormFeedbackSentFlowRuns return true when the trigger is enabled and has a stored flow that parses. Only when they return false does the legacy create...Task function run. So a studio with a flow on a trigger never gets the legacy fan out for it.
SUBSCRIPTION_ASSIGNED and NEW_CLIENT_IN_PLAN have no legacy writer on the event path. startSubscriptionAssignedRuns uses the stored flow, or builds one from the legacy task blocks (legacyTasksToFlow) or the default flow, and starts the run after thresholdDelayMs(value, unit). startNewClientInPlanRuns uses resolveStoredFlow(row) and starts at once.
When a form counts as sent
FORM_SENT is raised from three places:
forms.service.tsassignForm, when the assignment is visible immediately.workers/update-form-scheduler.ts, on the hourlycheck-in-reminderqueue job: from the update form dispatcher, fromnotifyPendingFormsthrough itsonVisiblecallback, and fromlistDueFormSentEvents.trainee.routes.ts, when a trainee registers a push token andnotifyPendingFormsreports forms that became visible.
listDueFormSentEvents(prisma, now) catches scheduled assignments whose dueAt has passed. It looks at studios with an enabled FORM_SENT automation, takes PENDING assignments with dueAt in the last 14 days (DUE_WINDOW_MS), skips frozen and deleted clients, skips PDF_SIGNATURE forms (they raise the event when their link is made), and skips assignments that already have a FORM_SENT inbox row or flow run from the same window. It returns at most DUE_RAISE_CAP = 200 events per run, oldest dueAt first.
What a sweep does for one trigger
generateForType runs these steps for each effective automation:
- Return early for
FORM_FILLED,FORM_SENT,FORM_FEEDBACK_SENT,FORM_RATING_BELOWandSUBSCRIPTION_ASSIGNED. - Match clients. Plain predicates page through clients 250 at a time (
MATCH_PAGE) with a cursor onid. - Resolve owners with
resolveCoachesand build the planned rows. - Look up which planned
autoKeyvalues already exist (existingAutoKeys, in chunks of 500). - Flow mode: build one
FlowRunSeedper planned row whose key does not exist yet and pass them to theflowRunner. The scheduler’s runner isstartFlowRuns(ctx, args, ...). No legacy row and no legacy trainee message is written. - Legacy mode: insert the rows with
createManyandskipDuplicates, in chunks of 500 (INSERT_CHUNK). When the block hasnotifyon and a non emptynotifyBody, callnotifyTraineesfor the clients whose row is new. - Auto close rows that stopped matching.
Trainee message
notifyTrainees(prisma, studioId, clientIds, body, logger) sends a push through createTraineeNotifier with notification type TRAINER_MESSAGE and the coach’s text as bodyOverride. The token {שם פרטי} is replaced with the trainee’s first name. Deleted clients are skipped. Only newly created tasks trigger it. See Notification configs for the notification types.
Owner resolution
resolveCoaches(automation, clientCoachIds, ownerCoachIds):
The trainee’s trainers come from
assignedCoachIds(): the primary Client.coachId first, then every active coach in the ClientCoach join (coachAssignments), oldest assignment first, de-duplicated. InboxItem.coachId is the first owner.
Dedupe rules
InboxItemhas a unique index on(studioId, autoKey). Every automatic row carries anautoKey, and every insert usesskipDuplicates. A key that was ever used is never recreated, because closed rows are kept and still hold the key.- Flow runs dedupe on the unique
(studioId, type, entityKey)ofAutomationFlowRun. The entity key is the suffix freeautoKey. Task nodes inside a flow write rows keyed<entityKey>:fx:<nodeId>. - In flow mode the sweep seeds only entities whose legacy key does not exist, so a trainee who already had a legacy task for that trigger does not start a flow run for it.
taskKeyScope()keeps legacy and flow rows apart in every close and reassign sweep. Flow triggers own keys containing:fx:. Legacy triggers own their ordinal’s keys (ordinalAutoKeyScope) and exclude:fx:keys.- A re-submit or re-send of the same response or assignment is a no-op, and the trainee message is skipped for keys that already existed.
Auto close
Rows are closed by settingstatus to DONE and resolvedAt to now. Only rows with source auto and status OPEN are examined, so a SNOOZED row is left alone.
- Never auto closed (
EVENT_TYPES):NEW_CLIENT_IN_PLAN,SUBSCRIPTION_ASSIGNED,FORM_FILLED,FORM_FILLED_OVERDUE,FORM_SENT,FORM_FEEDBACK_SENT,FORM_RATING_BELOW. The coach closes these on the board. TECHNIQUE_VIDEO: a row closes when itsmetadata.videoIdis missing, when its key no longer points at its own video, or when the video is no longerPENDING.- Everything else: the open rows’ clients are re-checked against the same rule (the calorie and aerobic matchers are re-run for just those clients, and
MILESTONEre-checks the birthday). A row closes when its client no longer matches. So a birthday task closes on the first sweep after the day ends in UTC, and anINACTIVEtask closes once the trainee opens the app.
Keeping owners in sync
Three paths re-point open automatic tasks:- Every sweep:
syncResponsibleAssigneesruns for every effective automation. In legacy mode it only acts whenassigneeModeisRESPONSIBLE, and sets owners to the trainee’s current trainers plus the block’sassigneeCoachIds. In flow mode it sets rows written by task nodes withassignset tocoachto the trainee’s current trainers only. It coversOPENandSNOOZEDrows and writes only rows whose owner set changed. - On a trainer change:
syncClientCoachTasks(prisma, studioId, clientId)is wired asonCoachesChangedinclients.routes.tsandautomation-api.routes.ts. It sets that trainee’sOPENandSNOOZEDautomatic rows to the trainee’s current trainers, for every legacy trigger whose parent row hasassigneeModeRESPONSIBLEand for every flow task node assigned tocoach. It reads the parent row only and does not add a block’s extraassigneeCoachIds. The next sweep adds those back. - On a settings save: the task automations service renames and reassigns open rows. See Task automations.
Callers
subscriptionAssignedHook(ctx) runs startSubscriptionAssignedRuns and then startNewClientInPlanRuns, each in its own try block, so a failure in one is logged and does not stop the other.