Dentolize · Daily Notifications Cron Walkthrough
On this pageBusiness viewTechnical view

The Cron: Scheduling, Locking & Delivery

Business view

Every clinic on Dentolize is in its own time zone — a clinic in Riyadh closes at a different UTC instant than one in Cairo or Amsterdam. This feature is designed so each clinic's staff get their "end of day" message at their end of day, not at some fixed UTC time that would land at 2am for one clinic and 6pm for another.

To do that without running a separate scheduled job per clinic, the system checks, once an hour, every clinic's current local time, and only acts on the ones where it's currently 23:30. Over a full day, every clinic gets exactly one shot at matching, at whatever UTC hour that corresponds to for them.

There's also an internal, staff-only screen (used by Dentolize's own engineering/support team, not clinic customers) that lists every cron job in the system, whether it's currently running, when it last ran, and whether it errored — and lets someone re-run a job on demand. This job appears there like any other.

Technical view

Registration and the schedule expression

{
  job: (prisma, redisClient, twilioClient, expo) =>
    new CronJob(
      '30 * * * *', // Every hour at :30; processes companies whose local time is 23:30
      wrapCronJob('dailyNotificationsCron', () =>
        dailyNotificationsCron({ prisma, redisClient, expo, name: 'dailyNotificationsCron' })
      ),
      null, true, ''
    ),
  name: 'dailyNotificationsCron'
}

packages/server/src/cronJobs/cronJobs.js:131-142

30 * * * * fires every hour, on the half hour, server-wide (server time / UTC, whatever the process's own clock is — this is just when the check runs, not when messages go out). wrapCronJob wires it into the same observability stack every other cron uses: a scoped logger, an OpenTelemetry span, and a Sentry scope tagged with the job name, plus (on clean completion) a cron-heartbeat metric (packages/server/src/utils/observability/jobContext.js:76-78).

Per-company local-time match

const localNow = moment().tz(timeZone)
if (!ignoreHour && (localNow.hour() !== TARGET_HOUR || localNow.minute() !== TARGET_MINUTE)) continue

packages/server/src/cronJobs/companies/dailyNotificationsCron.js:53-54, with TARGET_HOUR = 23, TARGET_MINUTE = 30 (dailyNotificationsCron.js:6-7).

Because the outer schedule already only fires on the half hour, this check is really just "is it 23:xx" narrowed to "is it 23:30" — the minute check is mostly a safety net in case the job runs late. Every enabled company (where: { disabled: false }, dailyNotificationsCron.js:41-44) is checked on every tick; the ones that don't match just continue to the next company at no cost beyond the moment().tz() call.

A gap worth knowing about: the code comments that a company "with no time zone set... fall[s] back to UTC" (dailyNotificationsCron.js:49-51), but the code right below it is just const timeZone = company.timeZone — there's no || 'UTC' fallback implemented. Company.timeZone is nullable in the schema (timeZone String?). Tested directly:

moment().tz(null)  // → undefined, not a moment instance

moment-timezone's .tz() only sets the zone when given a truthy name; called with null/undefined it takes the getter branch and returns the zone name (or undefined) instead of a moment object. So for a company with no time zone configured, localNow would be undefined, and localNow.hour() on the next line would throw. That throw happens inside the single try { ... } catch (e) { ... } that wraps the entire company loop (dailyNotificationsCron.js:35-255), so it wouldn't just skip that one company — it would abort the whole run for every company not yet processed that hour, recording the error message on the job's Redis status (visible on the internal Cron Jobs screen) rather than sending any of the remaining companies' notifications for that tick.

This isn't unique to this feature — expensesCron.js, messagesCron.js, and appointmentsCron.js all pass company.timeZone straight into .tz() the same way, with no fallback either. It's a pre-existing, latent condition across the crons module; this PR just happens to be the one that documents (incorrectly) that it's handled. In practice this only matters for a company whose timeZone was never set — worth confirming that path is unreachable from clinic setup before treating it as theoretical (see For Quality).

The redis lock and job status record

const cachedRedisJob = await redisClient.get(name)
if (cachedRedisJob && JSON.parse(cachedRedisJob).running) return

await redisClient.set(name, JSON.stringify({ running: true }))
...
await redisClient.set(name, JSON.stringify({ running: false, total, started, ended: new Date(), error: null }))

dailyNotificationsCron.js:36-39, 252

Same pattern as the other crons: a single Redis key (the job name) doubles as a mutual-exclusion lock (skip this tick if the previous run is still running: true) and as the payload the internal Cron Jobs admin screen reads via the getCronJobs query to show running state, start/end time, total (companies processed), and any error message (packages/clinic-web/src/components/admin/cronJobs/CronJobsStats.js). There's no per-hour dedup beyond this — if the lock is somehow left running: true (e.g. a crash that skips the catch), every subsequent tick is silently skipped until it's manually cleared.

Manual execution

The internal admin screen's Execute button (packages/clinic-web/src/components/admin/cronJobs/ExecuteCronJobButton.js) calls the executeCronJob(cronJob: String!) mutation, gated by isAdmin (packages/server/src/permissions/permissions.js:4101). Its dispatch table runs this job with ignoreHour: true:

dailyNotificationsCron: () =>
  dailyNotificationsCron({ prisma, redisClient, name: 'dailyNotificationsCron', expo, ignoreHour: true })

packages/server/src/resolvers/mutations/actions/cronJobs/executeCronJob.js:39-40

With ignoreHour: true, every enabled company is processed regardless of its local time — useful for testing or for manually re-running a missed night, but note it means every company gets a summary for "today" at the moment the button is pressed, not for their most recent closed day. This mutation is internal-only (Dentolize staff via the admin panel), not exposed to clinic customers.

Sending the pushes

const chunks = expo.chunkPushNotifications(messages)
for (const chunk of chunks) {
  expo.sendPushNotificationsAsync(chunk).then().catch()
}

dailyNotificationsCron.js:243-249

This is fire-and-forget: the .then().catch() with no handlers means a failed chunk (e.g. Expo's push service rejecting the whole batch) is silently swallowed — none of those users get notified, and nothing is logged or surfaced beyond what Expo's SDK does internally. Each user only gets sent to on their last 20 registered push tokens (user.pushNotifications.slice(-20)), and only tokens that pass Expo.isExpoPushToken(pushToken) are included — both messages (dailyNotificationsCron.js:184-185, 227-228) apply this identically.

The translation table

dailyCronTranslations.js is a standalone dictionary, not part of the app's react-i18next setup — this code runs server-side, in a cron, with no access to the client i18n instance. It covers the same 9 language codes the app ships (en, ar, fr, it, el, nl, pl, ku, ckb, dailyCronTranslations.js:1), and its lookup helper falls back to English for anything else:

export const t = (lang, key, vars = {}) => {
  const dict = dictionaries[SUPPORTED_LANGUAGES.includes(lang) ? lang : 'en']
  const template = (dict && dict[key]) || dictionaries.en[key] || ''
  return formatTemplate(template, vars)
}

dailyCronTranslations.js:392-396

formatTemplate does simple {placeholder} substitution via repeated split/join — no escaping, no pluralization rules; each language's template was hand-written to read naturally for whatever count it's given (e.g. no singular/plural branching for "1 appointment" vs "2 appointments"). User.language is a free-form String? with no enum constraint in the schema, so any value outside the 9 supported codes (or null) resolves to English here — independent of whatever the mobile app does with that same field for its own UI strings.