Dentolize · Daily Notifications Cron Walkthrough
On this pageBusiness viewTechnical view

Daily Summary Notification

Business view

Once a day, at the moment the clinic's clock ticks over to 23:30, owners and managers with analytics access get a single push notification summarizing the day that's about to end:

Daily Summary — Sandbox Dental Today: invoices 4,250.00 SAR (remaining 1,200.00 SAR), patient payments 3,050.00 SAR (7 payments) and 12 of 20 appointment(s) completed.

It's meant to be a "close the day out" glance — the kind of thing an owner who isn't in the clinic checks right before bed. It intentionally rolls several existing analytics screens (invoices, payments, appointment completion) into one line instead of requiring a login.

Who gets it: a user needs both of these to be true:

  • Full access (All Permissions, i.e. the Owner role) — or a permission

group with all three of: view invoice analytics, view payment analytics, and view all branches.

  • Their own Daily Summary toggle turned on (default: on).

A doctor with no analytics permissions, or an accountant who's missing "view all branches," will never receive this message regardless of their toggle — the permission check comes first.

Technical view

Recipient filter

const hasSummaryPermission = permissions =>
  permissions.includes('DO_ALL') ||
  (permissions.includes('VIEW_ANALYTICS_INVOICES') &&
    permissions.includes('VIEW_ANALYTICS_PAYMENTS') &&
    permissions.includes('VIEW_BRANCHES'))

packages/server/src/cronJobs/companies/dailyNotificationsCron.js:9-13

Applied per company, per user:

const summaryRecipients = users.filter(
  u => u.pushNotifications.length && u.group && hasSummaryPermission(u.group.permissions) && u.alertSettings?.summaryCron
)

dailyNotificationsCron.js:149-151

Note the u.alertSettings?.summaryCron optional chain: a user with no UserAlertSettings row at all is excluded (undefined is falsy), rather than defaulting to the column's true default — the Prisma default only applies when a row is actually created. In practice every user gets an UserAlertSettings row on creation via updateUserDetails's alertSettings.create block, so this only matters for any pre-existing account whose settings row was never created.

The numbers, and where they come from

Two aggregate queries scoped to the company's local "today" (today = { gte: startOfLocalDay, lte: endOfLocalDay }, computed from moment().tz(company.timeZone) — see The Cron for how "today" is derived):

prisma.invoice.aggregate({
  where: { companyId: company.id, createdAt: today, balanceInvoice: false },
  _sum: { total: true, paid: true }
})
prisma.payment.aggregate({
  where: { companyId: company.id, createdAt: today, refunded: false, type: { not: 'BALANCE' } },
  _sum: { amount: true }, _count: true
})

dailyNotificationsCron.js:154-168

  • balanceInvoice: false excludes patient-balance top-up invoices from the

"invoices" total — only real treatment invoices count.

  • refunded: false and type: { not: 'BALANCE' } on the payments side

exclude refunded payments and payments made from patient balance (those were already counted as revenue when the balance was topped up; counting them again here would double-count).

  • remaining is max(0, invoicesTotal - invoicesPaid) — clamped so a

quirky invoice with paid > total can't show a negative "remaining" (dailyNotificationsCron.js:175).

  • The completed/total appointment counts reuse the same aggregation the

Appointments Summary message computes per doctor (companyCompletedTotal, totalAppointments, dailyNotificationsCron.js:146,178-179) — totalAppointments counts everything booked for today except NOTE entries (cancellations and no-shows are included, since the point of "12 of 20" is to surface the ones that didn't happen).

  • currency comes straight from company.currency (no per-invoice

currency handling — the message assumes one currency per clinic, which matches how Company.currency is modeled).

Message text

Rendered through the cron's own tiny i18n helper (see The Cron for how this differs from the app's normal translation system):

title: t(user.language, 'summaryTitle'),
body: t(user.language, 'summaryBody', summaryVars),
data: { type: 'dailySummary', companyId: company.id }

dailyNotificationsCron.js:189-195

The English template:

Today: invoices {invoices} {currency} (remaining {remaining} {currency}),
patient payments {patientPayments} {currency} ({payments} payments) and
{completed} of {totalAppointments} appointment(s) completed.

packages/server/src/cronJobs/companies/dailyCronTranslations.js:274-281

Tapping the notification does not navigate anywhere. The app's shared push handler only acts when the payload has both id and type (if (id && type) { ... }, packages/clinic-mobile/src/components/dashboard/dashboardHome/DashboardHomeStack.js:53); this message's data is { type: 'dailySummary', companyId } with no id, so that check fails and the tap just opens the app to wherever it was left — same as the appointments message below.