Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,24 @@ DESKBELL_BASE_URL=http://localhost:5678
# IANA timezone. Drives quiet hours and every formatted date the customer sees.
DESKBELL_TIMEZONE=UTC

# ─────────────────────────────────────────────────────────────
# WHO THE CUSTOMER SEES
# ─────────────────────────────────────────────────────────────
# There are four sender identities below — Twilio, WhatsApp, VAPI and email —
# and deskbell will happily use all four to chase one person about one
# appointment. Make them line up before you go live:
#
# * Use ONE phone number for TWILIO_FROM_NUMBER, WHATSAPP_PHONE_NUMBER_ID and
# VAPI_PHONE_NUMBER_ID. Meta lets you register a number you already own on
# Twilio, and VAPI lets you set the outbound caller ID. It is a few minutes
# of setup and it is the difference between a ladder that reads as one
# business and three strangers texting about the same booking.
# * If they must differ, put the number a customer would recognise in
# `business.supportPhone` so the message body names it.
# * A reply only reaches deskbell if it comes back to a number pointed at the
# inbound webhook. A fallback sent from an unmonitored number cannot be
# confirmed, cancelled or STOPped — it is a dead end that still costs money.

# ─────────────────────────────────────────────────────────────
# SMS and voice — Twilio
# ─────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -70,6 +88,9 @@ DESKBELL_FROM_EMAIL=noreply@yourbusiness.com

VAPI_API_KEY=
VAPI_ASSISTANT_ID=
# Set this to the same number as TWILIO_FROM_NUMBER wherever you can. A voice
# call from an unknown number, after two texts from a different one, is how an
# escalation gets read as a scam call.
VAPI_PHONE_NUMBER_ID=

# ─────────────────────────────────────────────────────────────
Expand Down
46 changes: 35 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Reduce no-shows, text back every missed call, and fill cancelled slots — self-

[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![n8n](https://img.shields.io/badge/n8n-%3E%3D1.40-ea4b71)](https://n8n.io)
[![Tests](https://img.shields.io/badge/tests-63%20passing-brightgreen)](tests/core.test.mjs)
[![Tests](https://img.shields.io/badge/tests-78%20passing-brightgreen)](tests/core.test.mjs)
[![CI](https://github.com/kapoordeepanshu/DeskBell/actions/workflows/ci.yml/badge.svg)](https://github.com/kapoordeepanshu/DeskBell/actions)

[Quickstart](#quickstart-10-minutes) · [How it works](#how-it-works) · [Compare](#how-deskbell-compares) · [FAQ](#faq) · [Docs](docs/setup.md)
Expand Down Expand Up @@ -37,6 +37,7 @@ Commercial tools do this for **$40–300/month**, per location, with your custom
- 📅 **Multi-stage appointment reminders** — T-7d → T-24h → T-3h, over WhatsApp, SMS or email
- ✅ **Two-way confirmations** — customers reply `YES` / `NO` / `CANCEL` and your calendar updates itself
- 📞 **Missed-call text-back** — an unanswered call gets an SMS within seconds, with your booking link
- 📬 **Delivery receipts** — knows whether the message reached the phone, and re-sends on another channel when it didn't
- 🎙️ **AI voice reminders** — [VAPI](https://vapi.ai) calls high-value bookings that never replied
- 🔁 **Waitlist gap-fill** — a cancellation is instantly offered to people waiting
- ⭐ **Review requests & recall** — after the visit, and again when they're due back
Expand All @@ -56,16 +57,17 @@ Commercial tools do this for **$40–300/month**, per location, with your custom
│ Idempotency keys │ │
┌────────────────┐ │ State machine │◀────────────────┘
│ Missed call │───────▶│ Fallback ladder │ replies: CONFIRM / CANCEL
│ (Twilio hook) │ └──────────┬───────────┘ STOP / free text
└────────────────┘ │
│ (Twilio hook) │ │ Delivery receipts │ STOP / free text
└────────────────┘ └──────────┬───────────┘ receipts: delivered / read
│ / undelivered
┌────────────────────────────────┐
│ Waitlist gap-fill · Review │
│ requests · Recall · ROI digest │
└────────────────────────────────┘
```

Eleven n8n workflows, driven by **one config file**. Details in [`docs/architecture.md`](docs/architecture.md).
Twelve n8n workflows, driven by **one config file**. Details in [`docs/architecture.md`](docs/architecture.md).

## How DeskBell compares

Expand All @@ -79,11 +81,12 @@ I searched GitHub before building this. Here is the honest landscape.
| STOP / consent / quiet hours | ❌ | ✅ | ✅ |
| Channel fallback (WhatsApp→SMS→voice) | ❌ | Partial | ✅ |
| Missed-call text-back | ❌ | ✅ | ✅ |
| Knows if the message actually arrived | ❌ | ✅ | ✅ |
| Waitlist gap-fill | ❌ | Sometimes | ✅ |
| Works for any vertical | ❌ hardcoded | Per-industry pricing | ✅ config presets |
| Error handling & alerting | ❌ | ✅ | ✅ |
| Shows you the ROI | ❌ | ✅ | ✅ |
| Unit-tested logic | ❌ | n/a | ✅ 63 tests |
| Unit-tested logic | ❌ | n/a | ✅ 78 tests |

Every appointment-reminder repo I found on GitHub has **0–3 stars** and is a single-workflow demo hardcoded to one clinic. The template *collections* have 24k stars but ship no product thinking at all.

Expand All @@ -96,7 +99,7 @@ git clone https://github.com/kapoordeepanshu/DeskBell.git
cd DeskBell
cp .env.example .env # add your provider keys
docker compose up -d # n8n + Postgres on http://localhost:5678
npm run import # create and cross-link all 11 workflows
npm run import # create and cross-link all 12 workflows
```

Then:
Expand Down Expand Up @@ -148,16 +151,17 @@ Switching a dental clinic to a barbershop is a preset swap: the ladder shortens
| 08 | Waitlist Gap-fill | Sub-workflow | Cancellation → offers the slot, first confirm wins |
| 09 | Error Handler | Error trigger | Dead-letters failures, alerts you, suppresses storms |
| 10 | Daily Digest | Daily 07:30 | Today's unconfirmed list + revenue protected |
| 11 | Delivery Receipts | Webhook + sub | What actually reached the phone, and what didn't |

## Verified, not just published

```
$ npm test
# tests 63 · pass 63 · fail 0
# tests 78 · pass 78 · fail 0

$ npm run validate
11 workflows, 150 nodes, 32 Code nodes validated — 0 error(s), 0 warning(s).
12 tables, 3 views in schema; 34 SQL nodes checked — 0 error(s).
12 workflows, 162 nodes, 35 Code nodes validated — 0 error(s), 0 warning(s).
12 tables, 4 views, 1 functions in schema; 37 SQL nodes checked — 0 error(s).
```

The engine lives in [`lib/core.js`](lib/core.js) as pure functions and is **inlined into the n8n Code nodes at build time**, so the tested code and the shipped code are the same code. CI fails if they drift, and applies the schema to a real Postgres twice to prove it's idempotent.
Expand Down Expand Up @@ -186,6 +190,26 @@ If you ever see a duplicate, [open an issue](https://github.com/kapoordeepanshu/

</details>

<details>
<summary><b>Does it know whether the message actually arrived?</b></summary>
<br>

Yes, and it acts on the answer.

`sent` only ever meant "Twilio or Meta accepted the payload". A no-show engine that stops there cannot tell **"she read it and didn't reply"** apart from **"it never arrived"** — and those are opposite problems with opposite fixes.

So DeskBell collects delivery receipts. Twilio posts them to workflow 11; WhatsApp Cloud allows one webhook URL per app, so its receipts come in with the replies and workflow 04 hands them over. Three things change as a result:

- A message a receipt reports as **undelivered** un-does the "already sent" mark, and the stage is retried **on a different channel** rather than being silently written off.
- The daily digest splits its unconfirmed list into *reached, no reply* — a customer to chase — and *never reached*, which is a wrong number in your booking system and no amount of chasing will fix it.
- `SELECT * FROM deskbell.v_undelivered;` is your fault list of numbers to correct at the source.

Receipts arrive out of order and more than once — Twilio's `sent` and `delivered` webhooks routinely land the wrong way round — so every status write is compared through `deskbell.delivery_rank()`. A late receipt can't walk a delivered message backwards.

Channels differ in what they'll tell you: SMS reports delivered/undelivered, WhatsApp adds read, email reports nothing. A message on a channel with no receipts stays `sent`, and DeskBell reads that as **unknown** everywhere — not as delivered.

</details>

<details>
<summary><b>What does it actually cost to run?</b></summary>
<br>
Expand Down Expand Up @@ -269,9 +293,9 @@ DeskBell never guesses at a cancellation. Wrongly cancelling a booking costs far
<summary><b>What if a message fails to send?</b></summary>
<br>

The channel ladder tries the next channel — WhatsApp → SMS → voice — within the same run.
The channel ladder tries the next channel — WhatsApp → SMS → voice — within the same run, so most failures are recovered before the scheduler ever sees a result. Permanent failures (invalid number, landline) stop the ladder immediately rather than burning three channels to learn the same thing.

A failed send deliberately does **not** mark the reminder stage as done, so the next 15-minute tick retries it. Permanent failures (invalid number, landline) stop the ladder immediately rather than burning three channels to learn the same thing.
What's left over — every channel refused, or the message accepted and then reported undelivered — gets a later attempt. That one needs its own idempotency key, since the first attempt already holds `appointment|stage`: the retry claims `appointment|stage|r1`, goes out on a channel that hasn't failed yet, and stops at `reliability.maxRedeliveryAttempts` (default 1). Past that the stage reports `redelivery_exhausted` rather than quietly looking due forever.

Everything lands in `dead_letters` classified as retryable or permanent, and you get one email per workflow per 30 minutes — not 200.

Expand Down
3 changes: 2 additions & 1 deletion config/config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,8 @@
1800
],
"deadLetterAfterAttempts": 3,
"alertChannel": "email"
"alertChannel": "email",
"maxRedeliveryAttempts": 1
},
"ai": {
"enabled": false,
Expand Down
3 changes: 2 additions & 1 deletion config/presets/auto-repair.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion config/presets/default.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion config/presets/dental.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion config/presets/home-services.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion config/presets/physio.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion config/presets/salon.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion config/presets/tutoring.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion config/presets/veterinary.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

82 changes: 79 additions & 3 deletions data/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,41 @@
-- idempotent rather than creating duplicates every 15 minutes.
-- * State lives in `status`, driven by the state machine in lib/core.js.
-- Do not write arbitrary values into it.
-- * message_log.status separates `sent` (a provider accepted the payload)
-- from `delivered`/`read` (a receipt says it reached the handset) and
-- `undelivered` (a receipt says it did not). Those are different facts and
-- the whole product depends on telling them apart. Receipts arrive out of
-- order, so every update goes through deskbell.delivery_rank().
--
-- Re-running this file on an existing database is safe and is how you upgrade;
-- the ALTERs below carry older installs forward. CI applies it twice to prove it.

CREATE SCHEMA IF NOT EXISTS deskbell;
SET search_path TO deskbell, public;

-- ------------------------------------------------------------ delivery rank
--
-- How much a message status tells us, lowest to highest certainty. Providers
-- do not order their callbacks: Twilio's `sent` and `delivered` webhooks
-- routinely arrive the wrong way round. Every status write compares ranks, so
-- a late receipt can never walk a message backwards.
--
-- A failure ranks above `sent` (it is newer information) but below `delivered`
-- (a message that reached the handset stays reached).
--
-- Mirrors DELIVERY_RANK in lib/core.js. Change both or neither.
CREATE OR REPLACE FUNCTION deskbell.delivery_rank(status TEXT)
RETURNS INT LANGUAGE sql IMMUTABLE AS $$
SELECT CASE lower(coalesce(status, ''))
WHEN 'read' THEN 4
WHEN 'delivered' THEN 3
WHEN 'undelivered' THEN 2
WHEN 'failed' THEN 2
WHEN 'sent' THEN 1
ELSE 0
END;
$$;

-- ---------------------------------------------------------------- contacts

CREATE TABLE IF NOT EXISTS deskbell.contacts (
Expand Down Expand Up @@ -93,24 +124,57 @@ CREATE TABLE IF NOT EXISTS deskbell.message_log (
kind TEXT NOT NULL DEFAULT 'transactional',
channel TEXT,
direction TEXT NOT NULL DEFAULT 'outbound' CHECK (direction IN ('outbound','inbound')),
status TEXT NOT NULL DEFAULT 'claimed'
CHECK (status IN ('claimed','sent','failed','blocked')),
status TEXT NOT NULL DEFAULT 'claimed',
body TEXT,
-- The provider's handle for this message. It is the only thing a delivery
-- receipt arrives carrying, so every send must record it or the receipt has
-- nothing to match against.
provider_message_id TEXT,
-- The provider's own word for the last receipt, kept verbatim next to our
-- normalized status so an unfamiliar vocabulary is debuggable rather than lost.
provider_status TEXT,
provider_status_at TIMESTAMPTZ,
error_code TEXT,
error_message TEXT,
attempt INT NOT NULL DEFAULT 1,
cost NUMERIC(10,4) NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- sent_at: a provider accepted it. delivered_at: a receipt says it landed.
-- settled_at: the dispatcher finished with the row. Three different facts.
sent_at TIMESTAMPTZ,
settled_at TIMESTAMPTZ
delivered_at TIMESTAMPTZ,
settled_at TIMESTAMPTZ,
CONSTRAINT message_log_status_check CHECK (
status IN ('claimed','sent','delivered','read','undelivered','failed','blocked')
)
);

-- Upgrade path for databases created before delivery receipts existed. Named
-- explicitly so the drop-and-recreate below is idempotent on every run.
ALTER TABLE deskbell.message_log ADD COLUMN IF NOT EXISTS provider_status TEXT;
ALTER TABLE deskbell.message_log ADD COLUMN IF NOT EXISTS provider_status_at TIMESTAMPTZ;
ALTER TABLE deskbell.message_log ADD COLUMN IF NOT EXISTS delivered_at TIMESTAMPTZ;
ALTER TABLE deskbell.message_log DROP CONSTRAINT IF EXISTS message_log_status_check;
ALTER TABLE deskbell.message_log ADD CONSTRAINT message_log_status_check CHECK (
status IN ('claimed','sent','delivered','read','undelivered','failed','blocked')
);

CREATE INDEX IF NOT EXISTS message_log_appointment_idx ON deskbell.message_log (appointment_id);
CREATE INDEX IF NOT EXISTS message_log_contact_stage_idx ON deskbell.message_log (contact_id, stage, created_at DESC);
-- Finds sends that were claimed but never settled (a crash mid-dispatch).
CREATE INDEX IF NOT EXISTS message_log_stuck_idx ON deskbell.message_log (created_at)
WHERE status = 'claimed';
-- Delivery receipts arrive keyed on the provider's id and nothing else. Every
-- receipt is one lookup on this index; without it they are a sequential scan
-- of every message ever sent, several times per outbound message.
CREATE INDEX IF NOT EXISTS message_log_provider_msg_idx
ON deskbell.message_log (provider_message_id)
WHERE provider_message_id IS NOT NULL;
-- Drives the scheduler's per-stage delivery lookup and the digest's split of
-- "never reached" out of "unconfirmed".
CREATE INDEX IF NOT EXISTS message_log_appointment_stage_idx
ON deskbell.message_log (appointment_id, stage)
WHERE direction = 'outbound';

-- --------------------------------------------------------- inbound messages

Expand Down Expand Up @@ -289,3 +353,15 @@ SELECT id, idempotency_key, appointment_id, stage, created_at,
FROM deskbell.message_log
WHERE status = 'claimed' AND created_at < now() - interval '1 hour'
ORDER BY created_at;

-- Messages a provider accepted and then told us never arrived. This is an
-- operational fault list, not a customer list: a number that keeps appearing
-- here is wrong in the booking system and no amount of resending will fix it.
CREATE OR REPLACE VIEW deskbell.v_undelivered AS
SELECT m.id, m.appointment_id, m.contact_id, m.stage, m.channel,
m.provider_status, m.error_code, m.error_message,
m.provider_status_at, c.phone, c.name
FROM deskbell.message_log m
LEFT JOIN deskbell.contacts c ON c.id = m.contact_id
WHERE m.direction = 'outbound' AND m.status = 'undelivered'
ORDER BY m.provider_status_at DESC NULLS LAST;
Loading