A task automation is one studio’s configuration for one trigger type: whether it is on, its threshold, who gets the task, and optionally a flow, a tree of action nodes that runs when the trigger fires. This module serves and saves that configuration and contains the flow executor. Source: backend/apps/core-api/src/modules/task-automations/. The matching side lives in Tasks engine. The rows a flow writes are served by Inbox.

Mounting and auth

taskAutomationsRouter(ctx) is mounted in modules/index.ts at /v1/web/task-automations behind serviceAuth (HMAC service auth), webUserContext(ctx.prisma) and webLimiter. The routes file adds no requireRole or coach access guard. Any authenticated web lane caller of the studio can read and change the automations. Every query is scoped by req.auth.studioId. A missing studio on req.auth returns UNAUTHORIZED (401). Responses use the standard envelope. A Zod failure returns VALIDATION (422) with the issues in details. See API overview.

Storage

A studio with no row for a trigger gets the catalogue default from AUTOMATION_DEFAULTS.

Triggers

TASK_TRIGGER_TYPES lists 19 values. AUTOMATION_DEFAULTS holds 18 of them. FORM_FILLED_OVERDUE is in the type list but not in the catalogue, so GET never returns it and the generator never evaluates it. INACTIVE and MILESTONE default notify to true. Every other trigger defaults it to false. The rule each trigger matches on is documented in Tasks engine.

Endpoints

GET /v1/web/task-automations

Returns one entry per catalogue trigger, merged with the studio’s stored rows. Auth: web lane, any role. No parameters. Response: data.items is an array in catalogue order. Each item:
string
The trigger type.
string
The built-in task title from CONTENT in tasks/task-content.ts, used when a task has no name.
boolean
Stored value or the catalogue default.
number
Stored value or the catalogue default.
string
minutes, hours, days, percent or score.
string
OWNER, RESPONSIBLE, SPECIFIC or UNASSIGNED. Defaults to RESPONSIBLE.
string or null
Legacy single assignee.
string[]
Falls back to a one item list built from assigneeCoachId.
boolean
Whether the legacy task also sends the trainee a message.
string or null
Stored as given.
string or null
The trainee message text.
string[]
Product ids the trigger is limited to.
object
Days per weekly workout band, keys 3 to 7, normalised by normalizeFrequencyThresholds. Missing bands fall back to the stored thresholdValue, or to the defaults 5, 4, 3, 3, 2 when there is no row. Values are clamped to 1 to 60.
object[]
The stored TaskAutomationTask rows sorted by ordinal, returned as full rows (so they also carry id, automationId, createdAt and updatedAt). When there are none, one synthesised task at ordinal 0 built from the parent row, with formTypes set to all four form types and formIds empty.
object
The stored flow when there is one. Otherwise a flow lifted from the stored task blocks with legacyTasksToFlow, or the default flow with a single task node when the trigger has no blocks.
A synthesised flow gets fresh node ids on every read, because legacyTasksToFlow and defaultFlow generate ids at call time. Ids become stable once a flow is saved.
Errors: UNAUTHORIZED (401).

PATCH /v1/web/task-automations

Upserts the automation for one trigger type and returns the full list again. Auth: web lane, any role.
string
required
One of TASK_TRIGGER_TYPES.
boolean
Turns the trigger on or off.
integer
Non negative, coerced from a string.
string
minutes, hours, days, percent or score.
string
OWNER, RESPONSIBLE, SPECIFIC or UNASSIGNED.
string or null
Legacy single assignee. Ignored when assigneeCoachIds is sent, in which case the first id is stored.
string[]
Coach ids.
boolean
Send the trainee a message when a legacy task is created.
string or null
Stored on the row.
string or null
Message text. The token {שם פרטי} is replaced with the trainee’s first name at send time.
string[]
Product ids.
object
Record with keys 3, 4, 5, 6, 7 and integer values from 1 to 60.
object[]
1 to 5 legacy task blocks. Replaces the whole list. The fields of one block are in the table below.
object
The node tree. See Flow schema. It is parsed with flowBody and then checked by validateFlow.
Fields of one entry in tasks (automationTaskBody): What the service does:
  1. When flow is sent, flowTriggerColumns projects the flow trigger onto the legacy columns, overriding whatever the body sent for them. A percent trigger stores flow.trigger.pct, a score trigger stores flow.trigger.score, and every other trigger stores flow.trigger.time and flow.trigger.unit. planIds becomes flow.trigger.programs. The matchers read these columns.
  2. The repository upserts the row in a transaction. On create, the catalogue threshold is the base. When tasks is sent, task 0 is mirrored onto the parent row (threshold, assignee, notify, notifyBody), the existing blocks are deleted, and the new ones are inserted with their array index as ordinal.
  3. When tasks is sent, open tasks are brought in line per ordinal. If a block’s name changed, renameOpenTasks sets the new title on that ordinal’s OPEN and SNOOZED automatic rows. If a block’s assignee changed, resyncOpenTasks reassigns them: UNASSIGNED clears owners, SPECIFIC sets the given ids, OWNER sets every active HEAD_COACH plus the given ids, and RESPONSIBLE sets each trainee’s trainers plus the given ids.
  4. When tasks is not sent but assigneeMode or assigneeCoachIds is, ordinal 0 rows are reassigned from the saved row.
No push, WhatsApp message or job is sent by a save. A newly enabled trigger is picked up by the next hourly sweep or the next event. Response: the same shape as GET, read after the write. Errors: VALIDATION (422) for schema failures, including these validateFlow messages:
  • flow has N nodes; the limit is 60
  • conditions nest deeper than 5 levels
  • node ids must be unique
  • a create-task action needs at least one staff member
  • a condition needs exactly one fallback branch
  • the fallback branch must be last
  • webhook url must be https and public
UNAUTHORIZED (401) without a studio.

GET /v1/web/task-automations/wa-templates

Lists the studio’s own approved WhatsApp templates from its connected SmartSend account. The builder’s WhatsApp node uses it for the studio template picker. Auth: web lane, any role. No parameters. The handler reads the SmartSend key from Studio.settings.smartsend.apiKey and calls ctx.smartsend.listTemplates. Response: when no key is stored, connected is false and templates is empty. This is a normal 200 answer. Otherwise each template row has id (the template name that the send call expects) and value (a display label).
Errors: SERVICE_UNAVAILABLE (503) smartsend is not answering when the SmartSend call fails. UNAUTHORIZED (401) missing web user context.

GET /v1/web/task-automations/wa-templates/params

Returns the parameters of one studio template, in send order. Auth: web lane, any role.
string
required
Template name, trimmed.
string
Optional language code, trimmed.
Response:
Each row has name, label (falls back to name) and type when SmartSend returns one. Errors: BAD_REQUEST (400) templateName is required. BAD_REQUEST (400) smartsend is not connected when the studio has no key. SERVICE_UNAVAILABLE (503) smartsend is not answering. UNAUTHORIZED (401).

Flow schema

flow.schema.ts owns the wire shape. FLOW_SCHEMA_VERSION is 1.

Trigger

flowFormScope(trigger) returns all four form types and no ids when formTypes is not an array. feedbackFormKinds(trigger) narrows that to CHECK_IN and INTAKE for the feedback trigger.

Node kinds

Every node has an id (trimmed string, 1 to 40 characters) and a kind. A webhook url may be empty (the node saves but does nothing). A non empty URL must be https and must not point at a private host: localhost, 127., 0., 10., 192.168., 169.254., 172.16 to 172.31, an IPv6 literal, or a .local or .internal name. assign maps to the task assignee mode through FLOW_ASSIGN_TO_MODE: none to UNASSIGNED, coach to RESPONSIBLE, specific to SPECIFIC, manager to OWNER, and all to SPECIFIC with no ids.

Branches and rules

A branch has id, label (up to 60), fallback, a rule and its own steps. A rule has field, op (gt, lt, eq), value (up to 200), fieldName (up to 120, used by formField) and an optional filter record (used by trainee). For every field except trainee, an empty value is always true, and a non empty one is false when the run has no client. A trainee rule that does not parse, or that the filter engine turns into no conditions, is true. Otherwise it is false without a client.

Structural limits

validateFlow enforces what Zod cannot express per node:
  • At most MAX_FLOW_NODES = 60 nodes, counting nodes inside branches.
  • Conditions nest at most MAX_CONDITION_DEPTH = 5 levels.
  • At most MAX_BRANCHES_PER_CONDITION = 6 rule branches plus the fallback.
  • Node ids and branch ids are unique across the whole flow.
  • A task node with assign specific needs at least one member.
  • Each paths node has exactly one fallback branch, and it is the last branch.

Legacy conversion

legacyTasksToFlow lifts stored task blocks into a flow: blocks are sorted by delay, the first block’s delay becomes the trigger timing, the gap to each later block becomes a wait node, each block becomes a task node, and a block with notify on and a message also adds a push node. SPECIFIC with no ids becomes all. Percent and score triggers put their number on the trigger and add no waits. defaultFlow is a single unassigned task node. resolveStoredFlow returns the stored flow when it parses, else the lifted or default one.

Flow runtime

flow-runtime.ts executes flows. One AutomationFlowRun row exists per (studioId, type, entityKey), created once. An entity that ever ran a trigger’s flow never runs it again.

How a run starts

startFlowRuns(ctx, args, deps) creates one run per seed. A unique violation (P2002) counts as skipped. The run’s context stores triggeredAt, a snapshot of the flow, the seed metadata, and the client name when given. With startDelayMs above 0 the run is created WAITING with resumeAt set and a delayed job is queued. Otherwise it is created RUNNING and advanced inline, in the calling request or job. The four form starters return true when the flow owned the event, including the cases where the flow exists but the form was out of scope or no rating was low. The caller then skips the legacy task writer.

How a run advances

advanceRun(ctx, runId, deps) walks the run’s position, a stack of frames. A frame is a loc (the chain of branch ids from the root) and an index (the next node in that steps list).
  • Entry guard. Only a RUNNING run, or a WAITING run whose resumeAt has passed, proceeds. A duplicate job returns at once. If the studio has deletedAt set, the run is marked FAILED with error studio deleted.
  • Current flow. The flow is re-read from the TaskAutomation row on every advance, so an edit applies to the remaining steps. When the row has no parseable flow, the snapshot in the run context is used, then the caller’s fallback. With none, the run is closed as DONE with a note. Disabling the trigger does not stop runs that already started.
  • Deleted branch. When a frame’s branch id no longer exists, the run closes as DONE with the note flow structure changed; remaining steps skipped.
  • Wait. The position is saved at the next node, the run becomes WAITING with resumeAt, and a delayed job is queued on flow-run. A failed enqueue is logged and left to the sweeper.
  • Paths. Every non fallback branch whose rule is true runs, in builder order. The fallback runs only when none matched, and only when it has steps.
  • Effects. executeNode inserts the FlowNodeExecution row before running the effect. A unique violation means the node already ran, and it is skipped. This is what makes retries and resumes safe.
  • Failure. A node that throws marks the run FAILED with the first 500 characters of the message and keeps the position at the failed node.
  • Guard. More than MAX_STEPS_PER_ADVANCE = 500 loop iterations marks the run FAILED with flow walk exceeded the step limit.

Node effects

The built-in catalogue FLOW_WA_TEMPLATES has four entries (w1 to w4) and every one has realName set to null. A wa node with source perform therefore records template pending approval and sends nothing. Studio templates and free text do send.
The webhook node enqueues a job with directUrl and event automation_flow. The delivery worker posts it without a signature. The payload:
flowNode is the node label, or its id when the label is empty. Delivery, retries and headers are described in Automation hooks. WhatsApp sending is described in WhatsApp.

Queues and workers

workers/flow-run-worker.ts, started from server.ts: The Postgres resumeAt column is the durable record of a wait. The delayed BullMQ job is a faster path. Whichever arrives first resumes the run and the other stops at the entry guard. There is no endpoint in this module that lists or retries flow runs.