Skip to content

Add appointment-reminders extension - #419

Draft
meerao-cm wants to merge 24 commits into
mainfrom
add-appointment-reminders
Draft

Add appointment-reminders extension#419
meerao-cm wants to merge 24 commits into
mainfrom
add-appointment-reminders

Conversation

@meerao-cm

Copy link
Copy Markdown

Appointment reminders and confirmations over SMS (Twilio) and email (SendGrid), with per-business-line branding and routing and a two-way Y/N confirm flow.

What it does

Canvas's native appointment reminders are a single global setting: one cadence, one wording, no per-visit-type variation, and a confirm loop that cannot be extended. This extension replaces them with campaigns you configure per visit type and per business line, and turns patient replies into real state — Y confirms the appointment on the schedule, N opens a follow-up Task for staff.

Five campaigns: confirmation, reminder, cancellation, no-show, and telehealth join. Reminders support multiple intervals (default 3 days plus 45 minutes), each with its own SMS and email template and channel selection.

Who it's for

Practices that need more than one reminder policy: multiple business lines each needing their own sender number and patient-facing name, telehealth visits needing a join link separate from the day-before reminder, compliance-approved copy that must not be reworded by hand, or two-way confirmation that updates the schedule rather than landing in an inbox.

Practices needing one reminder at one interval with one wording should use Canvas's native setting instead; the README says so explicitly.

Safety defaults

  • All five campaigns ship disabled. Installing sends nothing until one is switched on.
  • TESTING_MODE is a fail-closed allowlist requiring both the patient and the destination address to match before anything is delivered.
  • LOCK_MESSAGE_TEMPLATES freezes approved copy. Enforcement is server-side: the send endpoint re-renders from the stored template and discards any client-supplied body.
  • The inbound webhook is signature-gated fail-closed, with constant-time comparison and MessageSid replay protection.

The README opens with a warning that this replaces Canvas's native reminders and cannot detect them, plus a pre-install checklist, since leaving the native setting enabled would double-message patients.

Components

7 handlers: an appointment event handler, a reminder cron, an admin/history SimpleAPI, a signature-gated Twilio inbound webhook, a timeline filter, and two applications (provider-menu admin, patient chart panel).

Test plan

  • 235 unit tests pass (uv run pytest tests/)
  • mypy clean across all source files
  • All 7 handlers load in the RestrictedPython sandbox (canvas validate)
  • Live end-to-end run on a Canvas instance: confirmations on booking, 15-minute reminders, and telehealth-join messages delivered over both SMS and email, no errors and no duplicate sends across scans
  • Two-way inbound confirm: valid Y/N, bad-signature rejection, replay dedup
  • Reviewer: verify against a clean instance with no pre-existing configuration

Known gaps

Documented in the README: no runtime detection of native reminders (a deployment-checklist step), no send retry queue, email uses implicit opt-out consent with no built-in unsubscribe, and no consolidated confirmation-status roll-up.

Author: Meera Rao meera.rao@canvasmedical.com

Appointment reminders and confirmations over SMS (Twilio) and email
(SendGrid), with per-business-line branding and routing and a two-way Y/N
confirm flow.

Canvas's native appointment reminders are a single global setting: one
cadence, one wording, no per-visit-type variation, and a confirm loop that
cannot be extended. This plugin replaces them with campaigns configured per
visit type and per business line, and turns patient replies into state: Y
confirms the appointment on the schedule, N opens a follow-up Task.

Five campaigns (confirmation, reminder, cancellation, no-show, telehealth
join), all shipping disabled so an install sends nothing until a campaign is
switched on. A fail-closed TESTING_MODE allowlist restricts sending to named
patients and recipients during setup, and LOCK_MESSAGE_TEMPLATES freezes
approved copy so a manual send cannot reword it.

Author: Meera Rao <meera.rao@canvasmedical.com>
@meerao-cm
meerao-cm marked this pull request as draft July 25, 2026 22:03
@meerao-cm
meerao-cm requested a review from kristenoneill July 25, 2026 22:04
meerao-cm and others added 5 commits July 29, 2026 18:16
The patient bell panel lets a staff member send against a standalone note
as well as an appointment, and "Telehealth Join" was the only real campaign
offered for a note. That path hand-built a 9-key variables dict instead of
using the shared renderer, so it omitted telehealth_link,
business_line_attribution, location_phone and the org variables. Unmatched
placeholders survive render_template, so the patient received literal
"{{telehealth_link}}" text. The same branch never converted to local time
and dropped the zone label, rendering a 3:40 PM ET visit as "07:40 PM"
while the picker directly above it showed 3:40 PM.

- Route notes through a shared _build_variables() so notes and appointments
  expose the same placeholder set in the same timezone, falling back to the
  provider's personal meeting room for telehealth_link since a note has no
  appointment-level link. Tolerates a missing datetime_of_service.
- Match the appointment branch's prefetches on the note query, since the
  shared renderer reads provider roles and the location's address/telecom.
- Refuse a manual send whose rendered body still contains {{placeholder}},
  on the channels actually being sent, so template syntax cannot reach a
  patient. Covers client-supplied copy too, which an unlocked panel posts
  straight through.
- Drop the dead "Custom" dropdown entry. The campaign was removed from the
  product and the send endpoint rejects it while copy is locked.
- Stop zero-padding rendered dates and times: "August 5, 2026" and
  "3:40 PM" rather than "August 05, 2026" and "03:40 PM", matching the
  wording Canvas core uses.

10 new tests, 245 passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`canvas install` (CLI 0.182.0) now fails the plugin on
services/twilio_inbound.py:19 with [bytearray-blocked]: bytearray is not
available in the plugin sandbox. This blocked deploying v0.7.6 to
plugin-testing even though v0.7.5 was already running there, so the
validator appears to have tightened since that install.

Accumulate the decoded bytes in a list[int] and wrap with bytes() before
decoding. Behavior is identical; all 245 tests pass unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_form_params sourced params from the SDK's Request.form_data(), which builds
its MultiDict from parse_qsl(body) without keep_blank_values and so drops any
key whose value is empty.

Twilio signs url + "".join(key + value for key in sorted(params)), including
key + "" for every empty-valued param. Reconstructing the signing string from
form_data() therefore omitted those pairs, the recomputed HMAC differed, and
_signature_ok failed closed with 401. Carriers routinely omit the geo fields,
which Twilio then sends blank (FromCity, FromState, FromZip, ToCity, ToZip),
so this rejected essentially every genuine inbound SMS -- the two-way Y/N
confirm flow had never worked against live Twilio.

Parse the raw body instead. The sibling helper parse_form_body already
preserves blanks for exactly this reason ("Blank values are kept so signature
computation matches Twilio's exactly"), so the fallback path was correct all
along and only the preferred path was wrong. form_data() is now consulted only
when the raw body is unavailable.

Confirmed against the deployed endpoint: 20 non-empty params verified 200,
the same payload plus one empty-valued param signed over all of them returned
401, and signing over only the non-empty subset returned 200 -- proving the
handler was reconstructing without the blanks. Reproduced locally: the old
path kept 8 of 13 params and failed verification, the new path keeps all 13
and passes.

Every existing inbound test built a fully-populated payload, which is the case
that already passed. Added an end-to-end test driving inbound() with a
realistic 13-param payload carrying five blank geo fields, unit coverage on
parse_form_body and valid_twilio_signature for blanks, and inverted the two
_form_params tests whose asserted source precedence this reverses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
STOP writes back to the chart.

Twilio blocks an opted-out number on its own side and forwards the keyword to
the webhook, but nothing carried that decision into Canvas. The native
incoming-SMS handler used to record it; pointing the number at this plugin for
two-way confirm retires that, so delivery.py kept seeing has_consent=True and
later sends would fail with Twilio 21610. The stale claim in
_get_patient_contacts' docstring -- that the webhook sets opted_out -- has been
corrected accordingly.

services/consent.py now clears has_consent on the contact point the message
came from, and START/UNSTOP/YES restore it.

Only has_consent is writable: the Patient effect's contact-point payload has no
opted_out field. That is the better field regardless, since it gates SMS alone
where opted_out would also suppress email, and STOP is an SMS carrier keyword.

Twilio's keyword sets overlap this plugin's, so consent is classified on its own
axis in services/twilio_inbound.py: CANCEL both opts out and opens the decline
Task, YES both opts in and confirms the appointment. Consent writes resend every
live contact point, because Canvas documents addresses updates as replace-based,
says nothing about contact_points, and the payload carries no row ids -- correct
under either reading, and flagged in the README for UAT confirmation.

Admin console gated by role.

StaffSessionAuthMixin proves only that the caller is some logged-in staff
member -- its docstring says it "only cares that they are a staff, with no
regard to roles" -- so any staff account could POST /admin/config and rewrite
every campaign template or switch live sending on.

NotificationAPI.authenticate now also requires a role listed in the new
ADMIN_ROLE_NAMES secret for /admin* routes, matched against each StaffRole's
name and internal_code. Fails closed: unset denies everyone. The per-patient
chart panel routes stay open to any logged-in staff member.

The provider-menu item itself cannot be hidden per user -- visible() is defined
only on EmbeddedApplication, and ProviderMenuConfiguration explicitly does not
apply to plugin-provided menu items -- so NotifyAdminApp shows a refusal page
instead. The API check is the real boundary, since the URLs are reachable
without ever opening the menu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
'secrets' is deprecated in the manifest schema in favour of 'variables', whose
entries carry a per-name sensitive flag; canvas validate had been warning about
it on every run. Migrating also lets each value be classified rather than
treating all fourteen the same.

Sensitive values are write-only -- masked in the Admin UI and reported by
canvas config list only as [set] / [not set]. Every credential is marked
sensitive, as are TESTING_MODE_PATIENTS and TESTING_MODE_RECIPIENTS: those are
not configuration but patient identifiers and contact details, and there is no
reason for them to be readable.

The rest stay readable on purpose. twilio-phone-number, the inbound webhook URL,
sendgrid-from-email and ADMIN_ROLE_NAMES are the values an operator has to check
when something is misrouted, and a masked variable can only be confirmed as
[set] -- which would make diagnosing a signature mismatch or a wrong sender
number needlessly hard.

No code change: self.secrets[...] still resolves variables regardless of how
they are declared.

Scaffold tests now assert the classification rather than just the presence of
namespace_read_write_access_key, so a credential added later without a
sensitive flag fails the suite instead of shipping readable. The helper reads
both manifest keys so the invariants hold either side of the migration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@meerao-cm
meerao-cm force-pushed the add-appointment-reminders branch from 6e26975 to 9801ba7 Compare August 22, 2026 23:59
meerao-cm and others added 18 commits August 24, 2026 07:12
_resolve_patient filtered contact points on the last 4 digits with __contains
and then sliced [:50] off an unordered queryset. Two compounding faults:
__contains matched those digits anywhere in the value rather than at the end,
and Patient declares no Meta.ordering, so the slice was LIMIT 50 with no
ORDER BY and kept arbitrary rows.

At production scale that returned roughly 200 candidates per lookup and threw
away three quarters of them before the exact-match loop ran, so most inbound
replies never resolved. Not flaky either: the slice is arbitrary but stable, so
a given patient tended to fail consistently rather than intermittently. And it
failed silently -- a miss returns 200 with no audit row, which ops reads as
patient non-response.

Match the last 10 digits as a suffix instead, uncapped. Both sides are already
canonical: the caller normalizes Twilio's From to E.164 via _normalize_phone,
and stored contact-point values are bare digits. Verified that normalization
yields the same 10-digit suffix for every input format, E.164 and punctuated
alike. So the 4-digit prefilter was defending against variation that occurs on
neither side, and the precision it gave up was the bug.

Left uncapped deliberately: after a 10-digit suffix match, more than one hit
means genuinely duplicated patient records, which the caller should see rather
than have silently truncated. The Python exact-match pass stays, so a suffix
collision cannot resolve wrongly.

Tests: the cap regression is the one that matters -- it places the target at
index 150 of 201 candidates and fails against the old implementation while
passing against the new. Existing fixtures never caught this because each uses
a phone number unique in its dataset. Also pins the query's lookup kwargs, the
absence of a slice, and the raised length guard.

Known gaps records that __endswith compiles to LIKE '%...' and cannot use a
B-tree index, so this is a sequential scan -- acceptable for a webhook, and
cheaper than what it replaced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
….8.2)

Two changes to inbound reply handling.

Audit replies from unmatched numbers.

A verified reply whose sender resolved to no patient returned 200 and wrote
nothing. The appointment stayed unconfirmed, which is indistinguishable from
the patient never replying -- so a misconfigured number, a patient texting from
a phone not on their chart, and a genuine wrong number all looked like silence.
Now recorded as a NotificationDelivery row with campaign_type=inbound_response
and status=unresolved_sender, and logged at warning rather than info.

NotificationDelivery.patient becomes nullable, for this case only. No migration:
Canvas does not create `not null` constraints on CustomModels ("Unsupported
constraints: not null"), nor database-level `references`, so the column has
always been nullable in Postgres and the declaration only aligns the ORM with
what the table already allows. Confirmed the model still loads in the sandbox.

Added GET /admin/unresolved-senders to read them back. Not scope creep: the only
existing history query filters on patient__id, so a patient-less row would have
been unreadable by anything, and a write-only audit row would not have met the
goal. Placed under /admin because these rows belong to no chart, which also
means they inherit the ADMIN_ROLE_NAMES gate.

The write sits after the signature check and after the MessageSid replay guard,
so unverified requests cannot inject arbitrary numbers and bodies into the log
and a replay cannot inflate it. Both are pinned by tests, along with the two
writers being mutually exclusive for any single reply.

The rows retain a phone number and message body belonging to someone with no
patient record. That is required to follow up, but it is a new category of
retained data, so the README calls it out and asks that the endpoint be treated
as a patient-data surface. There is no admin-UI surface yet; Known gaps says so.

Remove CANCEL from the decline set.

Twilio publishes CANCEL as an unsubscribe synonym alongside STOP, so a patient
texting it means "stop texting me", not "cancel my appointment". Consent still
clears -- that branch fires from _OPT_OUT and is untouched -- but the reschedule
Task no longer opens, because inferring an intent about the visit from an
unsubscribe keyword attributes something the patient never said.

YES remains in both sets, so the two-axis classification still earns its keep:
it is genuinely an opt-in and a confirm, and both halves are actioned.

Also covers log_inbound_response, which had no direct tests -- history.py goes
from 84% to 100%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Testing mode was three plugin variables: TESTING_MODE, TESTING_MODE_PATIENTS
and TESTING_MODE_RECIPIENTS. It is now part of CampaignConfig, edited under
"Testing mode" in the admin app.

Plugin config takes instance-level access to change, which is the right bar for
credentials and for who may administer the plugin, and the wrong bar for the
person running a test send. It also bought no safety: anyone who can enable a
campaign in the admin app can already cause live sending, so the setting that
restrains that sending belongs beside the switch that arms it. Plugin config is
now credentials and staff permissions only, an invariant test_scaffold pins.

Defaults to ON with both allowlists empty, which means nothing sends at all.
Deliberate in both directions. A fresh install cannot message anyone until
someone opens it up, matching the "do the first run behind testing mode"
guidance the README already gave. And an instance upgrading from 0.8.x lands
closed rather than silently broadcasting to every patient the moment the old
secret stops being read -- the failure mode that would otherwise be a TCPA
incident rather than an inconvenience. CampaignConfig.from_dict applies the
default to config rows written before the field existed, so the upgrade path
is covered rather than just the fresh-install one. Allowlists do not carry
over from the secrets; the README says so.

deliver_to_patient takes the config rather than reading it. All four call sites
already had it loaded outside their loops, so this adds no queries -- omitting
it would have meant a config read per patient inside the reminder cron. The
fallback load is still there for safety and is itself fail-closed. Two tests
pin both halves of that contract.

manual_send had no config in scope; it was calling load_config() inline for the
business-line from-number. It now loads once and uses it for both.

The admin card mirrors the server's fail-closed rule: it warns when the gate is
on with an empty list, since "silently sending nothing" is the failure mode the
banner exists to prevent. Testing mode also stops being reported through
/admin/integration-status as the browser's source of truth -- the checkbox is
live and that response would go stale the moment it is toggled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The toggle's visible control was <span class="toggle-slider">, but the CSS
class is `slider` -- toggle-slider has no rule anywhere in the file. Since
.toggle input is opacity:0 and the styled span *is* the control, an undefined
class rendered nothing: the switch was invisible and unclickable, so an
install had no way to leave testing mode from the UI. The card body also used
`campaign-body` where the defined class is `nt-card-body`.

Both are the same failure mode. The admin page is one large HTML string, so a
class name no rule defines fails silently rather than erroring, and the
existing tests only asserted the page returns 200.

Three tests now cover it, all of which fail against the markup as shipped:
every `class="toggle"` label must contain <span class="slider">; every class on
the testing-mode card must have a CSS rule behind it; and the testing-mode
controls must be present and wired to both updateTestingModeUI and the save
payload. The class audit is scoped to the card rather than the whole page --
a page-wide sweep picks up class names assembled inside JS template strings
and drowns in false positives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both settings here are set once and rarely revisited, so neither belongs at the
top of the Campaigns tab. They now live on their own Settings tab as collapsed
cards that open on click, the same pattern the campaign cards use.

Testing mode moves there unchanged. Because the card starts collapsed, the
header carries an ON badge while the gate is closed -- a gate that silently
suppresses every send should not be invisible from the tab.

Task assignment is new. The follow-up task raised when a patient declines by
SMS was always created unassigned, so it landed in no team's queue and someone
had to go looking for it. A new decline_task_team_id picks the receiving team
from those configured on the instance, read through a new GET /admin/teams.
Unassigned remains the default, matching the previous behavior for an existing
install.

The team id is verified before use. A team chosen here can be deleted in Canvas
long afterwards, and handing AddTask a dangling id risks the effect failing --
which would lose the task, the one artifact telling staff this patient wants to
reschedule. A missing team logs a warning and the task is created unassigned
instead. The dropdown likewise flags a configured team it cannot find, so a
stale setting does not silently read as "Unassigned" and get dropped on the
next save. The config and team lookups are lazy, on the decline branch only, so
they stay off the path of every other inbound reply -- one test pins that.

The markup tests added in 0.9.1 earned their keep immediately: they caught
`badge` on the new header, which is only defined under .card-header in the
patient-view page's CSS and so would have rendered unstyled on the admin page.
The class audit now covers both cards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…oading note bodies (v0.10.1)

Three findings from a database-performance pass. The fourth and largest -- the
reminder cron hydrating a three-day appointment window every five minutes to act
on a seven-minute band -- is recorded in Known gaps and left for its own change.

Cron no longer writes the config.

execute() called save_config() on every tick to "refresh the config TTL". That
was real when config lived in the cache; it moved to a CampaignConfigRecord row
with no expiry, so the call rewrote the whole config blob -- every campaign's
SMS and email templates, every per-visit-type and per-business-line override,
and now the testing-mode allowlists -- 288 times a day to change nothing. It
also opened a window, narrow but pointless, where a tick could clobber an
admin's concurrent save with its own stale copy. Removed, along with the import,
so a future caller has to reach for it deliberately.

Indexed the unresolved-sender query.

get_unresolved_senders filters campaign_type + status and orders by
-created_at, but NotificationDelivery only had (patient, -created_at) and
(-created_at). The query therefore walked the log in created_at order filtering
as it went, and the worst case was the healthy one: no unresolved senders means
scanning every row to return an empty list, degrading as the log grows. Added
the composite index.

Stopped loading note bodies on chart open.

get_patient_appointments pulled full Note rows -- including `body` and
`related_data`, both JSONFields holding the clinical content -- for up to 20
notes, while the serializer reads only id, title, datetime_of_service and two
scalars off related rows. `location` was also joined on both the appointments
and the notes queryset and read by neither.

Measured against real field resolution rather than assumed: the notes query goes
from 109 selected columns and 4 joins to 88 and 3, with both JSONFields gone.

Tests assert on generated SQL rather than mocked querysets. A mock never
resolves a field name, so a reintroduced join or a wrong column would pass
against mocks and only fail against a live database -- the same blind spot that
lets Count("id") through on a custom-data model.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…send times (v0.11.0)

Two changes to the reminder cron, sized against the numbers on a test instance
rather than the estimate in the earlier review.

Skip the scan when no interval can fire.

A reminder interval of a day or more is date-relative: it fires at a configured
send time on the target date, so it can only fire inside one GRACE_MINUTES
window per day. A reminder interval under a day, and every telehealth interval
whatever its size, is time-relative and can fire on any tick. execute() now
asks which of those is configured before querying anything, and returns early
when only day-out intervals exist and no send window is open.

Measured against the live config there (reminder_intervals=[1440], send_time
00:50 America/New_York, telehealth off): 2 of 288 daily ticks now query, 286
return immediately. Row hydrations drop from 11,232/day to 78.

The instance numbers also corrected the earlier plan. The window holds 39
appointments, not the ~3.4k a code comment cited from a different instance, so
the per-scan cost was never the problem -- the wasted scans were. And all 10
overrides there turned out to be pure on/off records with no intervals or send
times, so the band-union complexity that made the fuller fix look risky does
not exist on this config. Narrowing the surviving scan's window is left open and
recorded in Known gaps.

Interval collection now keeps reminder and telehealth intervals apart, because
classifying by size alone would treat a day-sized telehealth interval as
date-relative and gate away scans it needs. Send-time sources are the global
setting plus per-visit-type overrides; business-line overrides provably cannot
set one, so that pair is complete. A missed source would silently stop a visit
type's reminders, so both are tested.

Fix send times in the last GRACE_MINUTES of the local day.

_is_day_out_window anchored its scheduled instant to *now*'s local date and then
required now.date() == target_date. With send_time 23:58 the only ticks inside
the grace window fall on the following date, fail that check, and the reminder is
never sent -- not late, lost. Anchoring the instant to the target date instead
lets the window span midnight and makes the date check redundant, since being
within grace of one specific instant already implies the date. Verified against
the old implementation: it returns False at every tick for 23:58, where the new
one fires once and is still bounded by grace.

Send-time parsing also no longer raises. "9" with no colon was an IndexError
inside the per-appointment loop, which would have taken down the whole scan and
every patient's reminders over one bad config string; it now falls back to 09:00
with a warning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Post-deploy verification on a test instance showed notificationdelivery carrying a
single index, on (patient_id, created_at DESC). The other two declared in
Meta.indexes -- (-created_at), which predates this work, and the
(campaign_type, status, -created_at) added in 0.10.1 for get_unresolved_senders
-- did not exist, despite schema_version recording a completed sync.

Cause is identifier truncation. Auto-generated names are built from schema plus
table and then cut to Postgres's 63-byte limit.
"canvas__appointment_reminders_notificationdelivery_" is 51 bytes, leaving 12
for the discriminator, which the literal "notificatio_" fills exactly. All three
names truncated to one identical identifier and only the first was created. The
sibling canvas__patient_comms schema is 8 bytes shorter, had room for
"_created_" and "_patient_" to differ, and has both of its indexes -- which is
what confirmed the mechanism rather than a sync failure.

So the 0.10.1 index fix was a no-op on a real instance while passing every test,
because the tests asserted on the declaration rather than on what the database
ended up with.

Each index now carries an explicit short name. A test asserts every index has
one, that Django's 30-character cap is respected, and that the names stay
distinct after being prefixed and truncated to 63 bytes -- so the same collision
cannot return silently.

Whether the existing auto-named index is dropped or left orphaned alongside the
new one is up to Canvas's schema sync; a duplicate on the same columns would be
wasteful but harmless. Re-verify against pg_indexes after deploying.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…1.2)

end_window sized the scan by treating every reminder interval as a duration:
now + max(all_intervals) + GRACE. A day-out interval does not fire on a
duration. It fires at send_time on (appointment's local date - interval_days),
and the appointment can sit anywhere within that date, so its real lead time
runs from just over interval_days days to a full day more. Everything past
interval_minutes + GRACE never reached the loop -- no log line, no error.

Reproduced on a test instance at the 09:00 ET window on 2026-08-25 with
reminder_intervals [1440]. Three appointments on 2026-08-26, all date-eligible
for the 1-day interval:

  dbid 16728, 01:30 ET, 16.5h out -> 2 delivery rows, SMS and email
  dbid 16727, 14:00 ET, 29.0h out -> zero rows
  dbid 16729, 20:00 ET, 35.0h out -> zero rows

The old horizon was 24.1h, which admits only the first. Note the lead times are
16.5/29/35 hours, not the 28/41/47 in the report -- measured against the actual
09:00 ET window. The mechanism and the conclusion are unaffected.

Day-out reminder intervals are now padded to cover their whole target date, so
1440 gives a 49h horizon and 4320 gives 97h. Telehealth intervals are never
padded whatever their size, because that branch is genuinely time-relative and
ignores send_time -- the same distinction the scan gate draws. Sub-day reminder
intervals are left alone for the same reason.

Two deliberate deviations from the specced fix, both flagged rather than quiet:

The padding adds an hour beyond the whole-day bound. A DST fall-back day is 25
hours long locally, which would otherwise put the last hour of the target date
out of reach once a year. Over-inclusion costs nothing here, since
_is_day_out_window still makes the exact per-appointment decision.

The classification runs over reminder_intervals rather than all_intervals, so a
day-sized telehealth interval is not mistaken for date-relative. Padding it
would have been harmless but wrong in kind.

max_interval_minutes is untouched and still feeds the three dedup cache TTLs,
which want the raw interval. Those compute max_interval_minutes + 1440, which
equals the new horizon for a whole-day interval -- suggesting the padding was
the original intent and the window sizing is where it was lost.

The scan gate is untouched: it decides whether to scan, this decides how far the
scan reaches.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lookup (v0.11.3)

_patient_allowlisted compared only against id and dbid, so an MRN pasted into
the allowlist matched nothing. The failure was silent: every send was skipped
with skipped:testing_mode, which is indistinguishable from the gate working as
configured. MRN is the identifier staff actually see and quote, so it is the
likeliest thing someone puts there.

Verified against the real identifiers on a test instance -- patient
00000000000000000000000000000000, dbid 100001, MRN 900000001. Before the change
the id and dbid matched and the MRN did not; now all three do, and an unrelated
number still does not.

Also removed the `key` lookup. Patient.id is declared
CharField(db_column="key"), so the attribute is `id` and `.key` never existed --
that branch has been dead since it was written. The test that covered it passed
only because a bare MagicMock answers any getattr, so p.key returned a value the
real model cannot produce. Allowlist test doubles now use spec= to constrain
their attributes, and one test asserts a `key` attribute is specifically not
consulted, so the same false positive cannot come back.

Help text in the admin app and the README named "id, key, or dbid", which was
wrong in both directions. Both now name MRN and the chart-URL id. The README
also notes that a non-matching entry fails silently, so a test send should be
confirmed to arrive rather than inferred from the absence of errors.

Not addressed: validating allowlist entries on save so a wrong or typo'd
identifier is reported rather than silently inert. That is the underlying
problem this fix only narrows, and it is a bigger change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Customer feedback: the decline task routes to a team correctly, but with no due
date it sorts nowhere and gets lost in a large task queue. They asked for a due
date of the current date.

New Settings -> Task assignment toggle, "Give the task a due date", off by
default so an existing install keeps creating undated tasks. Stored as
decline_task_due_end_of_day.

Two choices in how the date is computed, both load-bearing.

End of day, not the reply moment. AddTask.due is a timestamp; setting it to
"now" would render the task as already overdue the instant it appears, which is
a different signal from "handle this today".

Anchored to the instance's own timezone via the environment's
INSTALLATION_TIME_ZONE, which the SDK documents for exactly this ("scheduling
work in their local day"). Because due is a timestamp and not a date, computing
end-of-day in UTC renders as the *previous* date for any instance behind UTC --
midnight UTC is the evening before in Eastern. Neither Organization nor
PracticeLocation carries a timezone, and patient last_known_timezone is empty
across this instance, so the environment is the only correct source. Falls back
to UTC with a warning if absent, which keeps the date right for US instances and
merely shifts the hour, rather than silently dropping a setting the admin
switched on.

The decline branch now takes one config read shared between the team lookup and
the due date, so _decline_task_team_id receives the config instead of loading its
own. Every other reply path still reads no config at all, which a test pins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
0.12.0 shipped a plain on/off flag that hardcoded "end of the day the patient
replied". The rule is now set in the admin app: an offset in business days from
the reply day (same day, +1, +2, +3, +5) and a time of day, defaulting to 23:59.

decline_task_due_end_of_day is replaced by decline_task_due_days (None meaning
no due date) and decline_task_due_time. from_dict migrates the old boolean --
True meant 0 days at 23:59 -- and an explicit new-style value wins over it. A
malformed offset resolves to None rather than 0, so a bad string cannot silently
start dating every task; a malformed time falls back to 23:59.

Business days, per the product decision. Weekends are skipped, so a Friday reply
with +1 is due Monday. A reply arriving on a weekend rolls forward before
counting, so "same day" on a Saturday is Monday rather than a day nobody is
working -- an offset of 0 is not a no-op. Public holidays are NOT skipped:
Canvas exposes no holiday calendar a plugin can read, so guessing at one would
be worse than the gap. Called out in the README and in the admin help text
rather than left to be discovered.

The help text under the checkbox now spells the rule out as it is edited,
because "+1 business day" reads as "tomorrow" and is wrong when the patient
replies on a Friday.

Still anchored to INSTALLATION_TIME_ZONE for the reason 0.12.0 established: due
is a timestamp, so computing the day in UTC renders as the previous date for any
instance behind it. Verified America/New_York on a test instance.

parse_hhmm moves into services/config so the reminder send time and the task due
time share one defensive parser instead of two.

Three due-date tests asserted "the due date is today's local date", which broke
the moment weekend rolling landed -- today is a Sunday. They now compare against
the same business-day computation, so they no longer pass or fail depending on
the day of the week they run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
….14.0)

The Integration Status panel reported "Twilio SMS: Configured" on the strength
of credentials alone. On a test instance the number's inbound webhook had been
removed: outbound reminders kept sending perfectly and every Y, N and STOP was
discarded for five days with no error, no log line and no audit row, because the
request never reached the plugin at all. The panel said Configured throughout.

services/twilio_routing.py now asks Twilio where inbound messages actually go,
and the panel reports three states rather than a boolean:

  Configured                                    credentials + routed here
  Outbound only - patient replies are dropped   credentials, nothing routed here
  Not configured                                no credentials

Folding routing into the existing boolean would have been wrong in the other
direction: outbound-only is a legitimate install, since two-way confirm is
optional, and calling that "Not configured" would be false. Hence the middle
state instead.

The check is biased against crying wolf. NOT_ROUTED is returned only when
neither the number's own sms_url nor any Messaging Service's inbound_request_url
points here -- the latter being the arrangement the README recommends, and it
also honours use_inbound_webhook_on_number, which makes the number's webhook win
over the service's. Anything Twilio cannot settle reports UNKNOWN and renders as
"Configured (inbound routing unverified)": an unreachable API, a 401, a number
absent from the account (hosted numbers and short codes do not appear, so
absence is not evidence), or a TwiML app on the number, which makes Twilio
ignore every sms_*_url. A false "replies are being dropped" would teach people
to ignore the warning, which costs more than the warning is worth.

Cached for five minutes. The SDK's HTTP client enforces a fixed 30s timeout with
no per-request override and this panel loads on every admin page view, so the
result is cached and keyed on the webhook URL plus the from-number, meaning
re-pointing a webhook re-checks rather than serving the old verdict. The panel
already loads asynchronously, so a slow Twilio call delays that box and not the
page. Skipped entirely when credentials are absent.

Known limit, in the README: only the global twilio-phone-number is checked, not
per-business-line from-numbers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The warning worked -- it caught the removed webhook on a test instance the moment
it shipped -- but it was written for whoever wrote the plugin. It led with
"Inbound replies are not reaching this plugin", quoted a raw endpoint path, and
explained itself by saying the signature is computed over that string. None of
that helps the person who has to go and fix it.

Now it leads with what is happening to patients ("Patient replies aren't
reaching Canvas"), says outbound is unaffected before anything else so it does
not read as total failure, describes the symptom in terms of what a patient does
rather than which tokens the parser recognises, and gives the fix as an
instruction about Twilio's own UI.

It also shows the exact address to paste, read back from
twilio-inbound-webhook-url, rather than describing it. That setting is declared
non-sensitive precisely so it can be read back, and "must match exactly" is the
entire failure mode -- a described address invites the transcription error the
warning exists to prevent. Rendered through escapeHtml since it goes into
innerHTML. When no address is configured at all, the message says so and points
at whoever holds plugin config, since the admin cannot set it themselves.

"the signature is computed over that string" becomes "it has to match this
address exactly, character for character" -- same constraint, no HMAC required.
A test asserts the banner prose stays free of that jargon.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…(v0.15.0)

Both fixes come from one incident. A confirmation SMS was logged as "delivered"
and never arrived: Twilio accepted the API call, returned a MessageSid, and the
message died at the carrier -- almost certainly A2P registration on a newly
provisioned number. The plugin captured that SID and discarded it, so answering
"did it actually send?" meant hunting Twilio's console by timestamp, and the
audit row asserted a delivery that had not happened.

NotificationDelivery gains a message_id column, populated from the DeliveryResult
the senders already return -- Twilio's MessageSid, SendGrid's X-Message-Id. The
patient panel shows it as "Carrier reference" so a message can be looked up
directly. Django applies column defaults, not Postgres, so rows written before
this column read back NULL and are coalesced on read.

A successful send is now recorded as "accepted" rather than "delivered", in both
the audit row and the appointment metadata. The plugin consumes no status
callback, so it never learns what the carrier did; "delivered" asserted
knowledge it does not have. The activity log labels both the new value and
legacy "delivered" rows as "Sent" -- the old value never meant more than
accepted either -- via a server-side status_label, so wording lives in one place
rather than being spelled out in JavaScript. The badge carries a tooltip saying
Canvas is not told whether the message was finally delivered.

Two mistakes worth recording, both the same shape as earlier ones in this
plugin. The renderer changes belong to the patient-view page, not the admin page
-- separate HTML strings with separate stylesheets -- so tests asserting against
the admin page passed vacuously until retargeted. And .status-accepted was first
added to only the admin stylesheet, which would have rendered the badge
unstyled in the page that actually shows it, exactly the undefined-class failure
that hid the invisible toggle in 0.9.1. Both are now asserted against the page
that renders them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two things visible in the patient panel.

"Call 8005550199 to reschedule" is what patients were actually receiving.
location_phone and organization_phone come straight off the contact point, which
stores bare digits, and nothing formatted them on the way into the message. North
American numbers now render as (800) 555-0199. Anything else passes through
untouched -- an unformatted international number beats a mangled one.

The panel showed "Inbound_response" as a campaign name, because the JS
capitalized the raw key. Labels now come from the server alongside status_label,
so wording lives in one place, and they match the admin app's campaign cards:
a patient reply reads "Patient reply", not the storage key. Unrecognised types
are tidied rather than shown with underscores.

Both were on the known-limits list from UAT rather than newly discovered; the
screenshot just made them concrete.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
….16.0)

Two reported bugs. Both are real in the code; the severity of each differs from
the report on the affected instances, noted below.

Bug 1 -- the reminder scan crashed on patient-less appointments.

Appointment.patient is null=True. Admin blocks, provider availability blocks and
imported calendar holds are real rows with patient_id NULL, they carry a real
note_type so they resolve to the global config and come back enabled, and the
send path then reads patient.first_name. There was no guard and no try/except
anywhere in the scheduler, so one such row raised AttributeError and killed the
whole tick, losing every appointment after it in iteration order. The sibling
patient_communications plugins all carry the guard; it was dropped when this
plugin was split out of that one.

Restored the guard, and added per-appointment isolation, which is the more
valuable half: the guard closes the one hole, the try/except closes the class. A
day-out interval gets one grace window per day, so losing the rest of a tick
costs a whole day of reminders. This is a deliberate departure from CLAUDE.md's
"don't wrap handler logic in a bare except" -- right for a request handler, wrong
for a batch loop where the alternative is losing every subsequent row.
log.exception preserves the traceback, so nothing is hidden from Sentry.

Verified the crash directly rather than by reading: get_template_variables(None,
...) raises AttributeError: 'NoneType' object has no attribute 'first_name'. The
three new tests fail against the unguarded code, confirmed by reverting only the
scheduler and re-running them. The one that matters puts the bad row *before* a
valid one, since a test that only asserted "does not raise" would pass against
broken code once a guard existed.

Severity correction: latent on these instances, not active. a live instance has
zero patient-less appointments across all 63,802 rows, ever, and its day-out
interval has fired (delivery leads 1050-2871 minutes, consistent with the 2880
interval). The report's 370 patient-less rows and "day-out never fired in 26
days" are from a different deployment. The fix is still right -- a nullable FK
dereferenced without a guard is a defect whether or not today's data trips it.

Bug 2 -- the admin app could not persist "inherit".

The resolver is three-state and correct: absent and true both mean inherit, only
an explicit false opts a visit type out. The save path always wrote a hard true
or false, so inherit was a state the UI could display and never store, and
because Save gathers this tab whichever tab you were on, it rewrote records
nobody had opened. Now only false is persisted; an absent key means inherit.
Backward compatible with no migration, since existing explicit true values
already resolve as inherit.

Severity correction: the mass-silencing path the report describes cannot occur on
this copy. The global-off branch was already guarded -- when a campaign is
globally off the per-type toggles are forced off visually, and the code already
preserved the stored value rather than reading the DOM. a live instance bears that
out: every visit type carries explicit true, none false, so pinning is confirmed
and harm is not. What remained was illegible stored state, which is what this
fixes.

The save logic is JavaScript inside a Python string, so the Python suite could
only assert its shape -- which is precisely how this got through, since the shape
was right and the values were wrong. tests/js/test_note_type_save.mjs extracts
the decision from the shipped source and executes it under node, including that
an untouched visit type round-trips byte-identically and that no input writes a
hard true. Wired into pytest via tests/test_admin_js.py, skipped when node is
absent.

Not addressed: the gtm-extensions copy needs the same patch, and
CampaignConfigRecord still has no audit trail -- both recorded in the README.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reported: appointment acknowledgements showed one timezone for every patient
rather than the patient's own, while the customer's prior reminders had been
patient-local.

The resolver already walked patient -> clinic default -> Eastern, and every
message path used it, so acknowledgements and reminders behaved identically.
What failed was step one. Patient.last_known_timezone is the only patient
timezone column in the schema and nothing in routine charting writes it; it is
populated by the FHIR Patient tz-code extension. Across the production fleet it
holds a value on five patient rows in total and is NULL everywhere else, so
every message fell through to the single configured zone.

services/timezones.py resolves a US address to an IANA zone: a state table plus
three-digit ZIP prefixes for the states split across two zones (Florida
panhandle, northwest and southwest Indiana, western Kentucky, east Tennessee,
El Paso, western Dakotas, Malheur County, northern Idaho). Arizona resolves to
America/Phoenix rather than a Denver that is an hour off for half the year.
Prefixes are keyed by state, so a mistyped ZIP degrades to the state's zone
instead of jumping to whichever state owns that prefix. A non-US or
unrecognized address resolves to nothing and falls through, rather than being
placed on a US zone.

resolve_timezone_name now goes explicit -> address -> configured default ->
Eastern, lazily, so the address lookup is skipped when a zone is already set.
Candidates are type-checked before ZoneInfo sees them, since last_known_timezone
is free text and a non-string raises a TypeError the existing guard did not
catch. Acknowledgement, reminders, telehealth, cancellation, no-show, manual
send and preview all pick this up; the four patient queries prefetch addresses.

Day-out reminders now fire at their send time in the patient's zone, so 09:00
means 09:00 where the patient is. The pre-query gate therefore has to admit any
tick that is a send time in any resolvable zone, about nine US zones instead of
one: a single daily reminder opens roughly nine scans a day rather than one,
with the rest still returning without touching the appointment table. The
configured zone keeps its old name and becomes the fallback for patients whose
own zone is unknown; both admin hints say so.

Verified against live data, not only tests. Replaying every distinct address
shape behind one instance's next 30 days of appointments, all resolve, spread
across six zones where there was one: Eastern 414, Phoenix 146, Pacific 143,
Central 125, Denver 64, Boise 1. That sweep caught a gap in the first table, TN
prefix 374 (Chattanooga), now covered. The three scheduler tests were confirmed
to fail against the old behavior before passing against the new.

Separately, and unrelated to the feature: this repo is public and several test
and comment sites carried values copied off a live instance. A customer name in
six places, a Twilio sender, a clinic's toll-free line, and a test patient's id,
MRN and phone number. None of it is PHI, the patient being a test record and the
phone matching no patient on the instance, but none of it belongs in a public
reference plugin either. All replaced with synthetic values in the reserved
555-01xx range and a generic instance reference. The customer name also appears
in nine earlier commit messages on this branch, which this commit does not
change.

Not addressed: address resolution is a state and ZIP lookup rather than a
geocode, so a patient in a county a prefix straddles can be an hour off. The
per-patient remedy is writing the real zone to last_known_timezone, which
outranks the table. Recorded in the README.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@meerao-cm
meerao-cm force-pushed the add-appointment-reminders branch from c3e1e1f to daed1f9 Compare September 3, 2026 22:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant