Skip to content

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.

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.
  • emit hands the event to the queue (pg-boss in production, inline on a laptop and in tests), so no request waits on a mail relay. deliver is the job.
  • Composers (notify/compose/) turn an event into a Notice: the recipients, and a function per channel that renders the message for one recipient. A composer returns null when nobody should be told: the order was cancelled in between, the customer has no address, the round has no driver. The switch in compose/index.ts is 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/) implement deliver(event, recipient, notice) and return whether anything was sent. A channel that throws fails the job, and the queue retries it.
  • afterDelivery on 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.

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.

  1. Add the variant to NotificationEvent in notify/events.ts, and its subject to NOTIFICATION_EVENT_TOPICS (or to CUSTOMER_FACING if it goes to customers). The type check fails until you do.
  2. Write a composer in notify/compose/ that returns a Notice, and add it to the switch in compose/index.ts. Reuse managers() for owners and admins, person() for one driver. Email wording lives in mail/messages.ts; the in-app wording under inApp.* in @bottle/i18n.
  3. Call deps.notify.emit({...}) where the thing happens.
  4. Assert on t.sentEmails, t.push.sent and the notifications table in a test.

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.

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.

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.