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

  1. The hourly sweep. startTaskScheduler(ctx) in workers/task-scheduler.ts is started from server.ts. It registers a BullMQ repeatable job on the automation-evaluate queue (PerformQueue.AUTOMATION_EVALUATE) with job id task-generator-hourly, repeating every hour, plus one boot job on every process start. The worker has concurrency 1 and a 10 minute lock. Each run calls runTaskGenerator(ctx.prisma, flowRunner, ctx.logger).
  2. 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’s thresholdValue, thresholdUnit, assigneeMode and planIds. Stored task blocks are ignored. For FORM_RESPONSE the form scope comes from flowFormScope(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 to MAX_TASKS_PER_AUTOMATION = 5 blocks.
The cutoff for time based triggers is 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 in task-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 status ACTIVE. Metadata written by the sweep:
  • TECHNIQUE_VIDEO: videoId, videoUrl, and exerciseId and exerciseName when known.
  • The two *_PLAN_STALE types: programId of the newest updated ACTIVE program of that type.
A legacy task block with ordinal above 0 appends :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.
FORM_FILLED_OVERDUE has a match function (matchOverdueFormResponses), a CONTENT entry and a key format (FORM_FILLED_OVERDUE:<formResponseId>), but it is not in AUTOMATION_DEFAULTS. The schema comment says it is parked. The sweep therefore never evaluates 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_FILLED and FORM_FEEDBACK_SENT: formResponseId, formTemplateId, formName.
  • FORM_SENT: assignmentId, formTemplateId, formName.
  • FORM_RATING_BELOW: the same as FORM_FILLED plus ratingFieldKey, ratingLabel and ratingValue of the lowest hit, and ratingSummary listing every hit. Its detail is built by ratingDetail(). ratingValue() accepts whole numbers of 1 or more only. MIN_RATING_THRESHOLD is 2 and MAX_RATING_THRESHOLD is 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.ts assignForm, when the assignment is visible immediately.
  • workers/update-form-scheduler.ts, on the hourly check-in-reminder queue job: from the update form dispatcher, from notifyPendingForms through its onVisible callback, and from listDueFormSentEvents.
  • trainee.routes.ts, when a trainee registers a push token and notifyPendingForms reports 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:
  1. Return early for FORM_FILLED, FORM_SENT, FORM_FEEDBACK_SENT, FORM_RATING_BELOW and SUBSCRIPTION_ASSIGNED.
  2. Match clients. Plain predicates page through clients 250 at a time (MATCH_PAGE) with a cursor on id.
  3. Resolve owners with resolveCoaches and build the planned rows.
  4. Look up which planned autoKey values already exist (existingAutoKeys, in chunks of 500).
  5. Flow mode: build one FlowRunSeed per planned row whose key does not exist yet and pass them to the flowRunner. The scheduler’s runner is startFlowRuns(ctx, args, ...). No legacy row and no legacy trainee message is written.
  6. Legacy mode: insert the rows with createMany and skipDuplicates, in chunks of 500 (INSERT_CHUNK). When the block has notify on and a non empty notifyBody, call notifyTrainees for the clients whose row is new.
  7. 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

  • InboxItem has a unique index on (studioId, autoKey). Every automatic row carries an autoKey, and every insert uses skipDuplicates. 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) of AutomationFlowRun. The entity key is the suffix free autoKey. 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 setting status 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 its metadata.videoId is missing, when its key no longer points at its own video, or when the video is no longer PENDING.
  • 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 MILESTONE re-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 an INACTIVE task closes once the trainee opens the app.

Keeping owners in sync

Three paths re-point open automatic tasks:
  • Every sweep: syncResponsibleAssignees runs for every effective automation. In legacy mode it only acts when assigneeMode is RESPONSIBLE, and sets owners to the trainee’s current trainers plus the block’s assigneeCoachIds. In flow mode it sets rows written by task nodes with assign set to coach to the trainee’s current trainers only. It covers OPEN and SNOOZED rows and writes only rows whose owner set changed.
  • On a trainer change: syncClientCoachTasks(prisma, studioId, clientId) is wired as onCoachesChanged in clients.routes.ts and automation-api.routes.ts. It sets that trainee’s OPEN and SNOOZED automatic rows to the trainee’s current trainers, for every legacy trigger whose parent row has assigneeMode RESPONSIBLE and for every flow task node assigned to coach. It reads the parent row only and does not add a block’s extra assigneeCoachIds. 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.

Limits