The update-forms module has no routes, controller or service. It holds three files of scheduling logic that a BullMQ worker calls on a timer. “Update form” is the code’s name for a trainee’s recurring check-in form (Client.updateFormId). Source: backend/apps/core-api/src/modules/update-forms.

Who calls it

The only caller is startUpdateFormScheduler in backend/apps/core-api/src/workers/update-form-scheduler.ts, started from server.ts at boot. No other module imports the dispatcher or the reminder. The schedule file is imported only by those two. The worker runs on the queue PerformQueue.CHECK_IN_REMINDER (check-in-reminder, from packages/queue) with concurrency: 1 and a 10 minute lock. It registers three jobs: The full pass runs these steps in order:
  1. runUpdateFormDispatcher, with raiseFormSent as its onSent callback.
  2. runFormReminderDispatcher.
  3. listDueFormSentEvents from tasks/form-sent.ts, raising FORM_SENT for assignments whose dueAt has now passed.
  4. notifyPendingForms from forms/pending-form-notifier.ts, which announces assignments that were never pushed.
Transient infrastructure failures (Prisma codes P1001, P1002, P1008, P1017, connection drops and lost BullMQ locks) are logged as warnings. Other failures are logged as errors. The next tick retries either way.

Schedule math

update-forms.schedule.ts has no database access. The inputs come from the Client row. dueConfigFor(cadence, client, studioTimeZone) turns those into a DueConfig:
  • anchorIso: the local date of updateAnchorOn, or of the subscription start when there is no anchor.
  • notBeforeIso: the first allowed check-in date, from firstCheckInIso. It is 7 days after the start for WEEKLY, 14 days for BIWEEKLY, and the same day in the next month (clamped to the month length) for MONTHLY. It is null when updateAnchorOn is set.
  • nextOnIso and skipOnIso: the calendar dates of updateNextOn and updateSkipOn.
isUpdateFormDueOn(config, clientToday) applies these rules in order:
  1. nextOnIso equal to today returns true. This override wins over everything else.
  2. skipOnIso equal to today, or today before anchorIso, returns false.
  3. Today before notBeforeIso returns false.
  4. MONTHLY: due when today is the resolved send date of this month or of the previous month. The resolved date is the chosen day clamped to the month length, moved to Sunday when it lands on a Saturday. Checking the previous month covers a Saturday at month end that spills into the next month.
  5. WEEKLY and BIWEEKLY: today’s weekday must equal effectiveWeekday(weekday), which turns Saturday into Sunday.
  6. WEEKLY is then due. BIWEEKLY is due only when the number of whole weeks since anchorIso is even.
Forms never go out on a Saturday. SCHEDULE_TIMEZONE is Asia/Jerusalem and is the default for the helper functions, but both callers pass the resolved trainee or studio time zone.

Check-in dispatcher

runUpdateFormDispatcher(prisma, now, logger, onSent) returns evaluated, due and sent counts.

Who is a candidate

A client is loaded when all of these hold:
  • status is ACTIVE or CHURN_RISK
  • planFrozenOn is null
  • the studio is not deleted
  • updateFormId and updateCadence are set
  • the linked form has active: true and type: "CHECK_IN"
  • startedOn is null or in the past, and endsOn is null or in the future
  • lastUpdateFormSentAt is null or older than 6 hours (SEND_HOUR minus a 2 hour DST margin)

Per client

  1. Resolve the time zone with resolveTimeZone(client.timezone, studio.timezone) and compute the local date.
  2. Skip when the local hour is before SEND_HOUR (8). Forms go out from 08:00 local time, on the first hourly tick after that.
  3. Skip when lastUpdateFormSentAt is on or after the start of the local day. This guard makes repeated runs within one day idempotent.
  4. Skip unless isUpdateFormDueOn is true.
  5. In one transaction, create a FormAssignment (status: "PENDING", formVersion from the template, no assignedById, no dueAt) and set Client.lastUpdateFormSentAt to now.

Side effects per sent form

  • FORM_SENT trigger. onSent calls raiseFormSent (tasks/form-sent.ts), which starts the studio’s FORM_SENT automation flows, or creates FORM_SENT tasks when no flow handles the event. A failure is logged and does not stop the run. See Task automations and Tasks.
  • Push. The dispatcher claims the assignment by setting notifiedAt, then calls sendTraineeNotification with type FORM_SENT, vars.form set to the trainee-facing form name and data holding assignmentId and formId. If the notification type is not disabled for the studio and the trainee was neither reached, muted nor skipped, notifiedAt is reset to null so notifyPendingForms retries on a later tick. The studio-side settings for this push are covered in Notification configs.
The dispatcher does not write a ClientActivity row, unlike a manual assign from the Forms endpoints.

Form reminders

runFormReminderDispatcher(prisma, now, logger) sends the reminder messages a studio configured under the FORM_REMINDERS notification type. It returns evaluated, due, sent, noDevice, disabled, muted and failed counts. Each message in the resolved config has an id, enabled, offsetDays, time, title, subtitle and optionally formType, onlyIfNotFilled and opens. The config is loaded once per studio per run through resolveNotificationConfig.

Reminders before the send day

Messages with a negative offsetDays. These only apply to scheduled check-in forms, because only those have a known future send day.
  • Candidates are the same clients as the dispatcher, without the lastUpdateFormSentAt condition.
  • For each message, the reminder is due when the check-in is due -offsetDays days from now, according to isUpdateFormDueOn.
  • It fires once the trainee’s local time reaches the message time.
  • The push opens the home screen (target: "home").
  • Ledger key: check-in:<clientId>:<sendDayIso>.

Reminders from the send day on

Messages with offsetDays of 0 or more. These apply to real assignments of any form type the message targets.
  • Candidates are FormAssignment rows that are PENDING or COMPLETED, became visible in the last 5 days (createdAt, or dueAt when set), and whose client has no frozen plan, is not deleted and has at least one push token.
  • message.formType must be ALL (the default) or equal the assignment’s form type.
  • The reminder day is the local date the form became visible plus offsetDays.
  • With offsetDays 0, a message whose time is at or before the minute the form was sent is skipped, so a same-day reminder never fires for a time that had already passed.
  • onlyIfNotFilled skips assignments that are no longer PENDING.
  • When the assignment is still PENDING and opens is not home, the push opens the form (target: "form", with assignmentId and formId in data). Otherwise it opens the home screen.
  • Ledger key: assignment:<assignmentId>.

Timing

  • time is HH:MM in the trainee’s local time zone. A missing time falls back to 19:00.
  • The dispatcher runs every 5 minutes, so a time later than 23:55 is treated as 23:55.
  • A reminder fires on the first tick at or after its time on the reminder day.

Sending once

Before sending, every pending reminder is inserted into FormReminderSend with createManyAndReturn and skipDuplicates. The table is unique on instanceKey plus messageId, so only rows inserted by this run are sent. This makes a reminder fire at most once even when the 5 minute job and the hourly pass overlap. The ledger row is written before the push and is not removed when the push fails. A reminder counted under failed or noDevice is not retried. Claimed reminders are grouped by studio and message and sent with sendTraineeNotification type FORM_REMINDERS. When the studio has the type disabled, the whole group is counted as disabled. Two steps of the hourly pass live in other modules but complete the picture:
  • notifyPendingForms (forms/pending-form-notifier.ts) finds up to 200 PENDING assignments with notifiedAt null that became visible in the last 14 days, for trainees with a push token and no frozen plan, excluding PDF_SIGNATURE. It claims each one, raises FORM_SENT through its onVisible callback, sends the FORM_SENT push grouped by studio and releases the claim for trainees it could not reach. The trainee router also calls it for a single client when that trainee registers a push token (onPushToken in trainee/trainee.routes.ts).
  • listDueFormSentEvents (tasks/form-sent.ts) finds scheduled assignments whose dueAt passed in the last 14 days, in studios with an enabled FORM_SENT task automation, that have no FORM_SENT task or flow run yet. The worker raises FORM_SENT for each.

Environment

None of the three files reads environment variables. The worker depends on the shared Prisma client and the BullMQ connection from the app context.