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 isstartUpdateFormScheduler 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:
runUpdateFormDispatcher, withraiseFormSentas itsonSentcallback.runFormReminderDispatcher.listDueFormSentEventsfromtasks/form-sent.ts, raisingFORM_SENTfor assignments whosedueAthas now passed.notifyPendingFormsfromforms/pending-form-notifier.ts, which announces assignments that were never pushed.
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 ofupdateAnchorOn, or of the subscription start when there is no anchor.notBeforeIso: the first allowed check-in date, fromfirstCheckInIso. It is 7 days after the start forWEEKLY, 14 days forBIWEEKLY, and the same day in the next month (clamped to the month length) forMONTHLY. It isnullwhenupdateAnchorOnis set.nextOnIsoandskipOnIso: the calendar dates ofupdateNextOnandupdateSkipOn.
isUpdateFormDueOn(config, clientToday) applies these rules in order:
nextOnIsoequal to today returnstrue. This override wins over everything else.skipOnIsoequal to today, or today beforeanchorIso, returnsfalse.- Today before
notBeforeIsoreturnsfalse. 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.WEEKLYandBIWEEKLY: today’s weekday must equaleffectiveWeekday(weekday), which turns Saturday into Sunday.WEEKLYis then due.BIWEEKLYis due only when the number of whole weeks sinceanchorIsois even.
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:statusisACTIVEorCHURN_RISKplanFrozenOnis null- the studio is not deleted
updateFormIdandupdateCadenceare set- the linked form has
active: trueandtype: "CHECK_IN" startedOnis null or in the past, andendsOnis null or in the futurelastUpdateFormSentAtis null or older than 6 hours (SEND_HOURminus a 2 hour DST margin)
Per client
- Resolve the time zone with
resolveTimeZone(client.timezone, studio.timezone)and compute the local date. - 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. - Skip when
lastUpdateFormSentAtis on or after the start of the local day. This guard makes repeated runs within one day idempotent. - Skip unless
isUpdateFormDueOnis true. - In one transaction, create a
FormAssignment(status: "PENDING",formVersionfrom the template, noassignedById, nodueAt) and setClient.lastUpdateFormSentAttonow.
Side effects per sent form
FORM_SENTtrigger.onSentcallsraiseFormSent(tasks/form-sent.ts), which starts the studio’sFORM_SENTautomation flows, or createsFORM_SENTtasks 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 callssendTraineeNotificationwith typeFORM_SENT,vars.formset to the trainee-facing form name anddataholdingassignmentIdandformId. If the notification type is not disabled for the studio and the trainee was neither reached, muted nor skipped,notifiedAtis reset to null sonotifyPendingFormsretries on a later tick. The studio-side settings for this push are covered in Notification configs.
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 negativeoffsetDays. 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
lastUpdateFormSentAtcondition. - For each message, the reminder is due when the check-in is due
-offsetDaysdays from now, according toisUpdateFormDueOn. - 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 withoffsetDays of 0 or more. These apply to real assignments of any form type the message targets.
- Candidates are
FormAssignmentrows that arePENDINGorCOMPLETED, became visible in the last 5 days (createdAt, ordueAtwhen set), and whose client has no frozen plan, is not deleted and has at least one push token. message.formTypemust beALL(the default) or equal the assignment’s form type.- The reminder day is the local date the form became visible plus
offsetDays. - With
offsetDays0, 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. onlyIfNotFilledskips assignments that are no longerPENDING.- When the assignment is still
PENDINGandopensis nothome, the push opens the form (target: "form", withassignmentIdandformIdindata). Otherwise it opens the home screen. - Ledger key:
assignment:<assignmentId>.
Timing
timeisHH:MMin the trainee’s local time zone. A missing time falls back to19: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 intoFormReminderSend 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.
Related scheduler steps outside this module
Two steps of the hourly pass live in other modules but complete the picture:notifyPendingForms(forms/pending-form-notifier.ts) finds up to 200PENDINGassignments withnotifiedAtnull that became visible in the last 14 days, for trainees with a push token and no frozen plan, excludingPDF_SIGNATURE. It claims each one, raisesFORM_SENTthrough itsonVisiblecallback, sends theFORM_SENTpush 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 (onPushTokenintrainee/trainee.routes.ts).listDueFormSentEvents(tasks/form-sent.ts) finds scheduled assignments whosedueAtpassed in the last 14 days, in studios with an enabledFORM_SENTtask automation, that have noFORM_SENTtask or flow run yet. The worker raisesFORM_SENTfor each.