Daily Appointments Summary Notification
Business view
Every doctor who had appointments today, or has any tomorrow, gets a personal end-of-day recap at 23:30 clinic time:
Appointments Summary — Sandbox Dental You completed 5 appointment(s) today (total time 3h 45m). Tomorrow you have 4 appointment(s) starting at 08:30 am.
Unlike the Daily Summary, this one needs no special permission — it's built entirely from appointments where the recipient is the assigned doctor, so it's inherently scoped to what's already theirs. The only gate is the doctor's own Daily Appointments Summary toggle (default: on).
The message adapts to three situations:
- Completed some today and have appointments tomorrow → the full
message above.
- Completed some today, nothing scheduled tomorrow → "No appointments
scheduled for tomorrow" instead of a start time.
- Nothing completed today, but appointments tomorrow → leads with "No
completed appointments today" instead of a count.
Technical view
Who counts as "a doctor" here
There's no role === 'DOCTOR' check. Instead, the set of recipients is derived from who actually appears as doctorId on an appointment in the relevant windows:
const [completedByDoctor, upcomingByDoctor, totalAppointments] = await Promise.all([
prisma.appointment.groupBy({
by: ['doctorId'],
where: { companyId: company.id, start: today, status: 'COMPLETED' },
_count: true, _sum: { duration: true }
}),
prisma.appointment.groupBy({
by: ['doctorId'],
where: { companyId: company.id, start: tomorrow, status: { notIn: ['CANCELED', 'NOSHOW', 'NOTE'] } },
_count: true, _min: { start: true }
}),
...
])
packages/server/src/cronJobs/companies/dailyNotificationsCron.js:99-129
Both queries group by doctorId and filter on the appointment's start time — which day an appointment counts toward is which day it's booked for, not which day it was created (the money side of the cron, by contrast, filters on createdAt — see Daily Summary).
- Today's completed:
status: 'COMPLETED', grouped with_countand
_sum: { duration } for the total minutes worked.
- Tomorrow's upcoming: any status except
CANCELED,NOSHOW, and
NOTE, grouped with _count and _min: { start } to find the first start time.
The two result sets are merged into a perDoctor map keyed by doctorId (dailyNotificationsCron.js:131-144), then matched back against the company's user list: doctors = users.filter(s => doctorIds.includes(s.id)) (dailyNotificationsCron.js:201). A doctorId with no matching enabled user (e.g. a disabled account still referenced by old appointments) is silently dropped here, since users was already queried with disabled: false.
The opt-out check has a different shape than the summary's
if (!doctor.pushNotifications.length) continue
if (doctor.alertSettings && !doctor.alertSettings.appointmentsCron) continue
dailyNotificationsCron.js:204-205
Compare this to the summary recipient filter's u.alertSettings?.summaryCron (true only when the settings row exists and is on). Here, a doctor with no alertSettings row at all is not skipped — doctor.alertSettings is falsy, so the && short-circuits and the continue never fires. In other words: missing settings default to "sent" for the appointments message, but default to "not sent" for the summary message. Both converge on the same practical outcome for real accounts (which always get an alertSettings row on creation — see Notification Settings Toggles), but the two checks are not the same guard, and would diverge for any account missing that row.
Message variants
let bodyKey
if (stats.completed && stats.upcoming) bodyKey = 'appointmentsBody'
else if (stats.completed) bodyKey = 'appointmentsBodyNoUpcoming'
else bodyKey = 'appointmentsBodyNoCompleted'
dailyNotificationsCron.js:222-225
firstTime is formatted in a fixed hh:mm a pattern with the locale forced to 'en' regardless of the doctor's own language (moment(stats.firstStart).tz(timeZone).locale('en').format('hh:mm a'), dailyNotificationsCron.js:208-213) — so "08:30 am" appears the same way inside an Arabic or Kurdish message body; only the surrounding sentence is translated. totalTime uses a small local helper:
export const formatDuration = seconds => {
const total = Math.max(0, Math.round(Number(seconds) || 0))
const hours = Math.floor(total / 3600)
const minutes = Math.floor((total % 3600) / 60)
if (!hours) return `${minutes}m`
return `${hours}h ${minutes}m`
}
packages/server/src/cronJobs/companies/dailyCronTranslations.js:384-390
Appointment.duration is stored in seconds; a 0-hour result collapses to just "45m" instead of "0h 45m".