Communications
Outbound messages to customers, and internal push notifications to staff.
Routes: /communications (reminder composer and queue) and /whatsapp (two-way inbox).
Both currently gate on customers.view; sending needs communications.send.
Rules: AUD-021, AUD-022 (secrets, consent), INT-003 (the system never sends on its own), UX-001 (confirm before sending).
1. Nothing leaves the company on its own
The system may draft, rank, pre-fill and warn. It does not send an external message by itself (INT-003). A person presses send, after a confirmation that shows who will receive it.
The exceptions a rule allows are purely internal reminders and escalations — the inventory deadline ladder (AIR §22) and incident notifications (INC-003), which are in-app alerts to staff — the e-mails that follow a step a person took (a booking created, a payment verified, a visa issued), and, since 30 Sep 2026, the booking reminder e-mails (COMM-010), which the owner asked for. Those go by themselves once an administrator has switched them on (below). Leave e-mails to staff follow each step of a leave application (LV-036 … LV-039; Leave → E-mails). A verified payment's receipt and a rejected payment claim are e-mailed by the database too (COMM-037, COMM-038).
One queue for every automatic e-mail
Every automatic e-mail is one mechanism (COMM-039):
a database trigger or function queues a CommunicationQueue row, the
communications-dispatcher sends it through the mailer, which renders the template. Each row
has a category — hr_leave, finance, booking, visa, general — filled from the message
type when the writer does not set it. An automatic e-mail also carries a key naming its event
(payment_receipt:<payment>, payment_claim_rejected:<payment>,
leave:<application>:<step>:<recipient>), and the same category and key are queued once
(notify_enqueue(), the unique index CommunicationQueue_notify_once). A key cannot be set from
the browser.
Each category the database queues has one switch: HR leave is Leave admin → Settings →
Send leave e-mails (HR, hr.leave.admin); Finance and Work (the e-mail copies of
work notes, WRK-009) are Admin → Reminders → Automatic
e-mails by category (admin.integrations.edit, audited). The Admin card lists them all and says
where the leave switch is. Off means nothing in that category is queued; rows already queued
still go. The booking and visa e-mails and the reminders keep their own rules and switches;
there is no category switch for them.
2. The WhatsApp 24-hour window
Meta allows free text only inside the customer-care window — the 24 hours after the contact
last wrote to us. Outside it, only an approved, active template can be sent. The
whatsapp-send edge function enforces this per recipient and reports why each one was
skipped:
no_phone · blocked · opted_out · no_opt_in · unknown_recipient_no_consent ·
outside_24h_window_template_required
Two more rules it applies:
- A broadcast is template-only. Free text to many people is refused outright.
- An unknown number — one with no customer record — is answerable only as a reply inside the care window, and never for marketing.
At most 100 recipients per call. A template that is not approved and active is refused by name.
The templates are copied from Meta, not typed: an administrator creates them in Meta and presses
Sync from Meta in Admin → Integrations → WhatsApp,
and a nightly job does the same
(AUD-024).
A template is kept once per language, and sent in exactly the language Meta holds it in. Only an
approved template that needs nothing but body text is offered; the boxes staff fill are its
variables in order, labelled with Meta's example ({{1}} — e.g. Ahmed). A variable called name
is filled with the customer's name. Named-parameter templates are sent with each parameter's name.
A template kept in more than one language is sent in the one picked on the inbox or in an
immediate send from /communications; a scheduled send does not carry the language, so it goes
in English (en_US, en, en_GB, in that order) or, with no English, the first by code.
Every send is written to both the WhatsApp message log and the communications log.
With the WhatsApp menu on, the menu's own replies are in the conversation too (with no sender), and a conversation the menu is handling is not counted unread; Talk to the office marks it unread and raises a Work item.
3. Consent
Contact preference is per channel (email, WhatsApp, SMS), per customer (AUD-022):
| Purpose | Default when nothing is recorded |
|---|---|
| transactional | allowed — unless the customer opted out |
| marketing / broadcast | refused — explicit opt-in required |
This is the conservative reading; the privacy notice, the consent capture point and the retention period are still management decisions under the DPDP Act 2023 and the 2025 Rules. Change the default in one function once they are decided.
A WhatsApp "STOP" or "START" reply updates the preference. A bulk send shows a consent summary — total, allowed, opted out, no record — and sends only to the allowed list.
Nobody writes the preference table directly. set_contact_preference requires a source of at
least three characters: how the consent or opt-out was given.
4. Secrets are not in the browser
Provider credentials — Resend, Mailjet, WhatsApp access token and app secret, MSG91 — live
in IntegrationSecret, readable only by the service role, written through
set_integration_secret (admin.integrations.edit). Staff see a configured / not
configured flag and a timestamp, never a value
(AUD-021).
Storing one of those keys in the ordinary settings table is blocked by a trigger — they were migrated out and deleted.
Sending and integration testing both run in edge functions, so the browser never holds a token:
| Function | Job |
|---|---|
whatsapp-send |
send a message or template, consent- and window-checked |
whatsapp-webhook |
inbound messages and status callbacks |
mailer |
transactional email |
communications-dispatcher |
runs the queue; authenticates on a dispatch key or the service role |
integration-test |
"send me a test" from Admin → Integrations, admin.integrations.test |
whatsapp-templates-sync |
copies the WhatsApp templates from Meta, admin.integrations.edit or the dispatch key (nightly job) |
whatsapp-booking-notice |
the automatic booking notices — booking confirmed, payment received with the receipt PDF (COMM-020 … COMM-025); service role only, called by the dispatcher |
push-send, push-subscribe |
push notifications to staff and travellers; push-send also keeps every notification as an AppNotification row per login (TRV-010). It takes a push in the sender's own words only from the service role or staff holding a sending right; a partner or traveller may only ring a notification the database already kept (COMM-042) |
5. Screens
/communications — the reminder composer (pick a segment, a channel and a template,
preview the consent summary, confirm, send), the staff push card, and the scheduled queue.
The staff push card's Send Notification needs communications.send, and its link must be a
page of the app (starting with /).
/whatsapp — the two-way inbox: conversations, inbound messages, and replies inside the
care window.
Alerts on a phone
The Android app cannot receive Web Push (no Push API in a WebView), so it registers with
Firebase Cloud Messaging and push-send delivers to it through FCM when the
FCM_SERVICE_ACCOUNT_JSON secret is set — the same events, the same recipients, one
extra delivery path. Browsers keep Web Push. See
Phone app → Notifications and
Android app → Notifications.
Notification inbox
A push is not the record. Every notification push-send sends is also kept: one
AppNotification row per recipient login (title, body, the URL it opens, a kind —
approval, booking, group, notice, system — and the record it is about), written
with the service role before delivery, so a person with no device, a phone that was off,
or an alert swiped away still finds it. A group notice is kept for everyone on the group by
the database itself when it is posted (trv_post_notice), whether or not the push goes
out. The person reads their own rows only, marks them read (ntf_mark_read,
ntf_mark_all_read) and sees the unread count (ntf_unread_count) — in the
native app under Notifications and on the web phone layout at
/m/notifications. Read rows are dropped after 90 days by ntf_prune (service role;
there is no scheduler yet, so it is run by hand or from a deploy step); unread rows are
never dropped. Staff code that wants to keep a notification without a push can call
ntf_notify(p_user_ids, p_title, p_body, p_url, p_kind, p_source_ref); it refuses a caller
who holds none of the permissions that already send pushes. Rule:
TRV-010. Test: supabase/tests/a_notification_is_kept.sql.
On the desktop the bell at the top of every page lists the person's latest eight notes
under For you, unread in bold, above what waits for their action (bookings to approve,
journals, visas, tickets). Its count adds the two. Opening a note marks it read and goes to
its page; Mark all read clears them. The list is fetched when the bell is opened; the count
is asked once a minute, or comes with the dashboard's own answer. There is no full-page
notification list on the desktop yet: older notes are on the phone app and at
/m/notifications.
A note the database writes can ring the phone (COMM-041).
A note marked for the phone ("pushWanted") is rung by the communications-dispatcher on
its next run (every five minutes), through push-send with store:false, once: the note is
stamped ("pushedAt") before it is sent, and a failure is written on it ("pushError") and
not retried. Right after a person's own act the web and the app ask the dispatcher to send at
once; that "kick" rings only the notes the person's act raised ("raisedBy"). Work notes —
a job given to you, a job you gave finished, due soon, overdue (Work inbox)
— are the ones marked today; their e-mail copies are the category work.
The announcement of a new booking
A booking that now exists is announced by the database, once, whichever door it came through
— the desktop wizard, the native app's staff wizard, the partner's screen in the app or the
partner's web route: booking_created_notify(p_booking_id)
(LC-007, 20260929110000_a_booking_created_is_announced.sql).
It keeps one AppNotification per sales approver (everyone active who holds
approvals.approve, an explicit deny respected) with the title "New booking awaiting sales
approval", the booking's URL and kind approval — only once the booking is PENDING_OPS, since
a draft has nothing to approve — and queues the customer's booking_created e-mail and a
partner's b2b_booking e-mail in CommunicationQueue (payload.templateData carries the
mailer's template data; the dispatcher passes it to the mailer, which renders the branded
e-mail server-side and checks the customer's consent). It returns the approver ids and the push
text; the caller hands them to push-send with store: false so the phones ring without a
second inbox row. A second call for the same booking writes nothing and says so. Only the
booking's creator (holding bookings.create) or the partner whose agency owns the booking may
call it; the anonymous key cannot. The browser no longer calls the mailer for a new booking.
The queued e-mail leaves at once: right after the announcement the browser or the app
kicks the dispatcher with the person's own session. The communications-dispatcher accepts a
signed-in JWT in a narrow kick mode — it sends only the pending rows that person queued
(createdBy), at most 25, never the whole queue; the full run stays with the dispatch key or
the service role. And it leaves on a schedule: the pg_cron job
alhuda-communications-dispatcher calls comms_dispatcher_run() every five minutes, which
posts to the dispatcher through pg_net with the URL and key it reads from Supabase Vault
(communications_dispatcher_url, communications_dispatcher_key — stored once by the owner,
deploy runbook → Secrets; missing → the job logs
"not configured" and sends nothing). On a database without pg_cron the migration says so and
still succeeds. Tests: supabase/tests/a_booking_created_is_announced.sql,
supabase/tests/the_dispatcher_runs_every_five_minutes.sql.
A visa issued or refused
The same pattern tells a visa customer. change_visa_status queues the visa_status_change
e-mail to the customer (and the booking's partner) when a case moves to ISSUED or
REJECTED, whichever screen made the change — the desktop or the phone
(VISA-005). Once per case, status and
recipient; never with the reason staff typed (REQ-002); nothing for a customer who opted out.
The screen kicks the dispatcher afterwards. The browser no longer calls the mailer for it.
Details: Visa → Telling the customer. Test:
supabase/tests/every_door_tells_the_visa_customer.sql.
Booking e-mails name the booking; reminders
Every e-mail about a booking carries the same booking block — booking number (also in the
subject), lead customer, every live traveller, departure, dates, package, the partner, and money
when it is about money — read by the mailer from booking_email_summary()
(COMM-001 … COMM-003). Senders pass the booking id (and a
receipt's payment id); the dispatcher passes the queue row's payload.bookingId. A message staff
send from a booking carries it too.
The system also sends reminder e-mails: payment due, payment overdue, departure and missing
documents (COMM-010 … COMM-014). The pg_cron job
alhuda-booking-reminders queues them at 09:00 IST as booking_reminder rows, once per booking,
kind and date; the dispatcher sends them. A customer who opted out gets none. They ship switched
off; Admin → Reminders switches them on.
When the mailer sends nothing on purpose — an opted-out customer, a reminder whose balance was
paid meanwhile — the dispatcher now closes the queue row as cancelled with Skipped: <reason>
instead of sent.
The full list of automatic e-mails, with samples: Booking e-mails and reminders. Running the reminders: Booking reminder e-mails.
6. Not built
- A scheduler for anything but the dispatcher, the WhatsApp template sync, the booking reminders
and leave.
pg_cronnow runs the communications dispatcher every five minutes (above), the booking reminders daily at 09:00 IST (COMM-010), the WhatsApp template sync nightly (AUD-024) and the leave accruals nightly (LV-062); no other job is scheduled —ntf_prune, the inventory deadline ladder and the work-inbox sweep still run when invoked. See Holds, deadlines and releases. - Creating or editing WhatsApp templates here. They are made and approved in Meta and copied in by the sync; there is no versioning beyond Meta's own. Templates with a header picture, a header variable or a link button that takes a value are listed but cannot be sent from chat.
- Customer-facing notifications from the portal — Wave 3.
- A privacy notice and a consent capture point at enquiry or booking (AUD-022), and the customer's right to request access, correction or erasure.
- Breach response — who reports a personal-data breach, to whom, and how fast (AUD-023).
7. Where to look
| Concern | Path |
|---|---|
| Consent, secrets, functions | supabase/migrations/20260918130000_tickets_visa_comms.sql |
| Edge functions | supabase/functions/whatsapp-send/, whatsapp-webhook/, mailer/, communications-dispatcher/, integration-test/, whatsapp-templates-sync/, whatsapp-booking-notice/ |
| Booking block, reminders | supabase/migrations/20261003220000_every_booking_email_names_the_booking.sql, 20261003220100_booking_reminder_emails.sql; supabase/functions/_shared/bookingSummary.ts, supabase/functions/mailer/templates.ts; tests supabase/tests/every_booking_email_names_the_booking.sql, supabase/tests/booking_reminder_emails.sql, src/services/bookingEmailSummary.test.ts, src/components/admin/BookingRemindersCard.test.tsx |
| Leave e-mails | supabase/migrations/20261005183000_leave_is_emailed_to_the_reporting_officer.sql, supabase/functions/mailer/templates.ts (leave_notice); tests supabase/tests/leave_emails.sql, src/services/leaveEmails.test.ts |
| The one queue, categories, payment e-mails | supabase/migrations/20261006130000_notifications_share_one_queue.sql, supabase/functions/mailer/templates.ts (payment_receipt, payment_claim_rejected), src/components/admin/NotificationCategoriesCard.tsx; tests supabase/tests/notifications_share_one_queue.sql, src/components/admin/NotificationCategoriesCard.test.tsx |
| Template sync | supabase/migrations/20261003110000_whatsapp_templates_come_from_meta.sql, supabase/functions/_shared/whatsappTemplates.ts; tests supabase/tests/whatsapp_templates_come_from_meta.sql, src/services/whatsappTemplateSync.test.ts |
| Screens | src/pages/communications/ |
| Tests | supabase/tests/tickets_visa_comms.sql |