Recalls — patient call-back workflow
Status: planned (issue #62). Module:
recalls. Spec last updated: 2026-05-01.
Why
When a patient leaves the clinic without booking the next visit (hygiene, check-up, ortho review, implant follow-up, post-op control), there is no structured way to bring them back. Receptionists rely on memory, sticky notes, or ad-hoc spreadsheets. Patients fall through the cracks → lost revenue and worse continuity of care.
The clinic needs a first-class recalls workflow:
- Mark a patient to be called in month X with a reason and an assigned professional.
- Work a reliable monthly call list with filters and inline actions.
- Track every contact attempt so a patient is not called five times in two days — or forgotten for a year.
- Auto-link the booked appointment back to the recall so reception can see the loop closed at a glance.
- Reduce manual entry: many recalls should be auto-suggested from completed treatments (hygiene → +6 months, post-op → +1 week).
The feature is operational, not marketing — it does not run campaigns, it gives front desk a list to work.
Module placement
A new optional module recalls lives at backend/app/modules/recalls/ with a matching Nuxt layer. Placement decisions:
| Manifest field | Value |
|---|---|
depends | ["patients", "agenda"] |
installable | True |
auto_install | True |
removable | True |
category | "official" |
Sibling to agenda + patients, not embedded inside agenda. The recall state machine, monthly list workflow, and recall.* events are independent of the appointment lifecycle and deserve their own module. The future outreach module will subscribe to recall.* events without touching agenda.
What we build (in V1)
Data
recallstable (per-clinic, per-patient):due_month(day-1 of target month, indexed), optionaldue_date,reasonenum,reason_note,priority,status,recommended_by,assigned_professional_id,last_contact_attempt_at,contact_attempt_count,linked_appointment_id,linked_treatment_id(no FK — snapshot only),linked_treatment_category_key(snapshot string), timestamps.recall_contact_attemptstable: per-attempt log (channel,outcome,note,attempted_at,attempted_by).recall_settingstable (per-clinic, JSONB):reason_intervals,category_to_reason, two automation toggles.
Lifecycle (status transitions)
pending ───log_attempt(no_answer/voicemail/wrong_number)──► contacted_no_answer
│ │
├──link_appointment / log_attempt(scheduled)────► contacted_scheduled
│ │
│ appointment.completed
│ │
├──log_attempt(declined)────► contacted_declined ▼
│ done
├──cancel ───► cancelled
│
▼
needs_review (set by patient.archived or do_not_contact rules)Snooze bumps due_month forward N months, keeps status pending.
Entry points
- Patient record — "Set recall" action in summary hero (slot
patient.summary.actions). - Appointment close-out — when transitioning an appointment to
completed, AppointmentModal renders any components in theappointment.completed.followupslot. Recalls registers a "Schedule a recall?" prompt. - Treatment plan / odontogram per item — slot
odontogram.condition.actions(existing). Pre-fills reason from the treatment's category. - Auto-suggest — recalls listens to
treatment_plan.treatment_completed. If the clinic's mapping has the treatment's category → a reason, surfaces a non-blocking suggestion inpatient.summary.feed. Never auto-creates without user confirmation.
Monthly call list (/recalls)
- Default: current month, status
pending, sorted by priority + due date. - Filters: month, reason, professional, status, priority, overdue toggle, patient.
- Counters strip: due this week, overdue, scheduled this month, conversion rate.
- Per-row inline actions: click-to-call (
tel:on mobile), log attempt (one tap for "no answer"), book appointment (opens agenda composer pre-filled, links recall on save), snooze, cancel. - Bulk actions: export CSV, bulk snooze, bulk reassign professional.
- Mobile-first: collapses to single column, touch targets ≥44px.
Patient-side surfaces
- Recall pill in summary hero (next due month + reason).
- History card in summary feed (last 5 recalls, link to filtered
/recalls). - No new patient-detail tab — keeps the existing 5-tab layout.
Settings (per clinic)
A settings section Recordatorios registered into the existing settings.sections slot:
- Default intervals per reason (hygiene 6mo, checkup 12mo, ortho_review 1mo, implant_review 6mo, post_op 1wk, treatment_followup 3mo, other 3mo).
- Treatment-category → recall-reason map (preventivo→hygiene, ortodoncia→ortho_review, cirugia→post_op, …).
- Toggle: auto-suggest on treatment completion (default on).
- Toggle: auto-link on appointment scheduled (default on).
Permissions
| Permission | Default roles |
|---|---|
recalls.read | admin, dentist, hygienist, assistant, receptionist |
recalls.write | admin, dentist, hygienist, assistant, receptionist |
recalls.delete | admin |
Events published
| Event | When |
|---|---|
recall.created | new recall row inserted (duplicate-guard fired = no event) |
recall.due | reserved for future cron; not published in V1 |
recall.completed | recall transitions to done |
recall.snoozed | recall snoozed N months |
recall.cancelled | recall cancelled (manual or by patient.archived) |
These are the foundation for the future outreach module (WhatsApp/SMS/email automation) — that work is a separate issue.
Events consumed
| Event | Effect |
|---|---|
appointment.scheduled | Auto-link a pending recall if (patient, due_month) overlaps. Best-effort: agenda's treatment_type is free-text so reason match isn't reliable. |
appointment.completed | If linked to a recall in contacted_scheduled, transition to done. |
appointment.cancelled | Unlink recall, revert to pending, log synthetic attempt note. |
treatment_plan.treatment_completed | Look up reason mapping, surface non-blocking suggestion in patient feed. |
patient.archived | Active recalls for the patient → needs_review (not deleted). |
Module isolation contract
- Cross-module FKs only to
patients.idandappointments.id. linked_treatment_idstored without FK; treatment_plan is not independs. The treatment category arrives viatreatment_plan.treatment_completedpayload (enriched at publish-time in treatment_plan).- New slots added by this PR:
patient.summary.actions(host: patients module)appointment.completed.followup(host: agenda module)
- Slots reused from existing modules:
patient.summary.feed,odontogram.condition.actions,dashboard.attention,settings.sections. - Agenda gains a small
initialRecallIdprop onAppointmentModalso booking from a recall row links the resulting appointment back on save.
Copilot
The AI agent can work the call list conversationally: list due/overdue recalls, open a recall's detail (notes + attempt history), create recalls, log contact attempts (auto-linking the booked appointment with outcome=scheduled), snooze and complete. Same RBAC strings as the HTTP routes; do-not-contact and archived patients stay excluded. See backend/app/modules/recalls/CLAUDE.md § Tools exposed.
What we don't build (out of scope)
- Outbound automation (WhatsApp / SMS / email). A future module will subscribe to
recall.*events. - Online self-booking from a recall link.
- Marketing-style mass recalls.
- Patient
deceasedstatus + GDPR erasure markers — separate issue. In V1 we filterPatient.status = "archived"and the newPatient.do_not_contact = trueflag. - Cron-driven
recall.dueevent. The enum value is reserved. - Backup-on-uninstall data dump. Uninstall drops the three tables.
Acceptance criteria (mirrors issue #62)
- [ ] Recall created from patient record, appointment close-out, and treatment plan / odontogram, with reason + month + optional note.
- [ ]
/recallslists current month's pending recalls with filters and inline actions. - [ ] Click-to-call works on mobile; logging "no answer" is one tap.
- [ ] Booking an appointment from a recall row pre-fills agenda composer and links the appointment back on save.
- [ ] Completing the linked appointment auto-closes the recall and surfaces the next-recall suggestion when settings map to one.
- [ ] Duplicate-recall guard updates the existing pending recall instead of creating a new row for the same
(patient, reason). - [ ] Patients with
status = archivedordo_not_contact = trueare excluded from the active call list and surface inneeds_review. - [ ] Permissions enforced on every endpoint.
- [ ] Module is installable and removable cleanly (round-trip uninstall test passes).
- [ ] Events
recall.{created,completed,snoozed,cancelled}published. - [ ] Dashboard widget shows due / overdue / conversion counters.
- [ ] Mobile-first responsive on the call list.
Related
- Issue: https://github.com/martinezsalmeron/dentalpin/issues/62
- ADRs:
docs/adr/0001-modular-plugin-architecture.md,docs/adr/0003-event-bus-over-direct-imports.md,docs/adr/0005-relative-permissions.md. - Glossary:
docs/glossary.md— recall ↔ recordatorio, call list ↔ lista de llamadas, snooze ↔ posponer, recall reason ↔ motivo.