Skip to content

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.

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_cron now 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