Notifications
Every message Bottle sends about a business’s work goes through one place:
the notification gateway in apps/api/src/notify/. Code that does something
raises an event. The gateway works out who should hear about it and
tells them through every channel they can be reached on.
Three channels exist today: email, the bell inside the app, and push to the driver apps. SMS would be one more channel. Nothing that raises an event changed when push arrived, and nothing would for SMS.
The shape
Section titled “The shape”routes, jobs, billing ──emit(event)──▶ gateway ──▶ queue ──deliver(event)──▶ compose ──▶ channels │ ├─ email │ ├─ in-app ▼ ├─ push recipients └─ sms (later)- Events (
notify/events.ts) are a typed union:order.taken,delivery.on_its_way,round.issued,stock.low,vehicle.due,invoice.raised, and so on. They carry ids, not objects. An event may be delivered a minute after it was raised, and should describe the world as it is then. emithands the event to the queue (pg-boss in production, inline on a laptop and in tests), so no request waits on a mail relay.deliveris the job.- Composers (
notify/compose/) turn an event into aNotice: the recipients, and a function per channel that renders the message for one recipient. A composer returnsnullwhen nobody should be told: the order was cancelled in between, the customer has no address, the round has no driver. The switch incompose/index.tsis exhaustive, so an event without a composer is a type error. - Recipients are a person with an account, a customer of the business, or a bare email address (somebody invited who has no account yet). Only people get the in-app channel. Customers get email, and will get SMS.
- Channels (
notify/channels/) implementdeliver(event, recipient, notice)and return whether anything was sent. A channel that throws fails the job, and the queue retries it. afterDeliveryon a notice runs once every channel has had its go. It is where a composer stamps “told” (round_stops.notice_sent_at,invoices.sent_at), so a retried job does not tell somebody twice.
The timing of a message is not the gateway’s business. Whether to warn about
a vehicle’s MOT thirty days out or seven, and whether it was already said, is
decided by the code that raises the event (notifications/vehicles.ts). The
gateway only decides how to say it.
Account email (sign-up, password reset, two-factor codes) does not go through the gateway. It is sent by better-auth through the plain mailer, because it is part of signing in rather than part of the business’s work, and it must never appear under a bell.
Subjects and preferences
Section titled “Subjects and preferences”Each staff-facing event belongs to a subject (NOTIFICATION_EVENT_TOPICS
in @bottle/schemas): rounds, deliveries, money, stock, vehicles,
team, timeOff, account. A person has, per business, an email switch, a
bell switch and a push switch per subject (notification_preferences), read through
GET /notification-preferences and written with PUT. The gateway checks
the switch for each user recipient before each channel. Subjects carry their
audience and defaults (NOTIFICATION_TOPIC_DETAILS); account has email
locked on. Customer-facing events have no subject and no switch.
Push starts on for rounds and timeOff (a driver’s round, and the answer to
a time-off request) and off for the office’s subjects. A PUT that leaves out
push keeps it as it was. SMS follows the bell switch until it has one of its
own.
The driver apps register the phone’s address with Apple or Google after
sign-in (POST /me/devices with platform and token), again whenever it
changes, and forget it on sign-out (DELETE /me/devices/{token}). Phones
belong to the person (push_devices), not a business; a token registered again
moves to whoever signed in last.
PushChannel sends a user recipient what the bell says, unless the notice has
a push rendering of its own, to each of their phones: through ApnsSender
(token auth over HTTP/2) or FcmSender (HTTP v1 with a service account). With
no keys for a platform the push is written to the log instead, which is what
development does. A phone Apple or Google say has gone is deleted. A phone that
cannot be reached this minute is logged and let go: throwing would fail the
event and send its emails again.
| Setting | What it is |
|---|---|
APNS_KEY_ID, APNS_TEAM_ID |
The key and team from Apple’s developer account. |
APNS_PRIVATE_KEY |
The .p8 key’s text (\n for newlines is fine). |
APNS_BUNDLE_ID |
app.getbottle.driver. |
APNS_SANDBOX |
true for development builds; false for TestFlight and the App Store. |
FCM_SERVICE_ACCOUNT |
The Firebase service account JSON. |
The Android app reads Firebase’s app settings at build time from
bottle.firebase.appId, apiKey, projectId and senderId in Gradle
properties (or FIREBASE_* in the environment). A build without them has no
push, and its Notifications screen says so.
round.changed is raised when the office changes a round its driver already
has (its drops, van or departure time). It reaches the driver in the app and by
push, not by email.
Adding an event
Section titled “Adding an event”- Add the variant to
NotificationEventinnotify/events.ts, and its subject toNOTIFICATION_EVENT_TOPICS(or toCUSTOMER_FACINGif it goes to customers). The type check fails until you do. - Write a composer in
notify/compose/that returns aNotice, and add it to the switch incompose/index.ts. Reusemanagers()for owners and admins,person()for one driver. Email wording lives inmail/messages.ts; the in-app wording underinApp.*in@bottle/i18n. - Call
deps.notify.emit({...})where the thing happens. - Assert on
t.sentEmails,t.push.sentand thenotificationstable in a test.
Adding a channel
Section titled “Adding a channel”Implement Channel from notify/types.ts, decide which recipient kinds it
applies to, and add it to the channel list in server.ts, jobs/daily.ts and
test/helpers.ts. If the channel needs its own rendering (a push title is not
an email subject), add an optional method to Notice and have composers fill
it in. Composers that do not fill it in simply do not reach that channel.
The bell
Section titled “The bell”GET /notifications lists the signed-in person’s notices in the current
business, newest first, with an unread count. POST /notifications/{id}/read
and POST /notifications/read-all mark them. Sessions only: an API key has
no bell. The web app polls once a minute.
Live updates
Section titled “Live updates”The office leaves the rounds page open all day, so the screens follow the
business without a refresh. GET /events?tenantId=… is a server-sent event
stream, sessions only: one hello, then a change event for every request
that changed something in that business, as { path, method, at }, with a
comment every 25 seconds to keep proxies from closing it. The API publishes
after any non-GET request that succeeded, from the path it was made to, and
by hand from the two places a change arrives without a request: a Stripe
webhook marking an order or invoice paid, and the gateway writing a bell
notice.
The web app’s useLive(tenantId, prefixes, onChange) opens the stream and
re-reads the screen’s own data when a change lands under one of its path
prefixes, settling a burst for 400ms first so five drops written up in a
row read once. There is no payload to trust and nothing to merge: the
screen fetches what it was already fetching. The bus is in-process
(apps/api/src/live.ts), which is right for one API replica; a second
would move it onto Postgres NOTIFY without changing the shape.