@xtremax/pigeon 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +40 -1
  3. package/bin/pigeon.mjs +92 -0
  4. package/dist/cjs/client.js +3 -0
  5. package/dist/cjs/inbox/client.js +4 -9
  6. package/dist/cjs/person.js +10 -0
  7. package/dist/cjs/preferences/browser-stub.js +10 -0
  8. package/dist/cjs/preferences/client.js +21 -0
  9. package/dist/cjs/preferences/handler.js +96 -0
  10. package/dist/cjs/preferences/index.js +8 -0
  11. package/dist/cjs/preferences-react/index.js +18 -0
  12. package/dist/cjs/preferences-react/labels.js +32 -0
  13. package/dist/cjs/preferences-react/notification-preferences.js +76 -0
  14. package/dist/cjs/preferences-react/preferences-client.js +72 -0
  15. package/dist/esm/client.js +3 -0
  16. package/dist/esm/inbox/client.js +2 -7
  17. package/dist/esm/person.js +7 -0
  18. package/dist/esm/preferences/browser-stub.js +7 -0
  19. package/dist/esm/preferences/client.js +18 -0
  20. package/dist/esm/preferences/handler.js +92 -0
  21. package/dist/esm/preferences/index.js +2 -0
  22. package/dist/esm/preferences-react/index.js +13 -0
  23. package/dist/esm/preferences-react/labels.js +28 -0
  24. package/dist/esm/preferences-react/notification-preferences.js +73 -0
  25. package/dist/esm/preferences-react/preferences-client.js +66 -0
  26. package/dist/types/client.d.ts +5 -10
  27. package/dist/types/contract.d.ts +27 -0
  28. package/dist/types/inbox/client.d.ts +2 -2
  29. package/dist/types/person.d.ts +2 -0
  30. package/dist/types/preferences/browser-stub.d.ts +3 -0
  31. package/dist/types/preferences/client.d.ts +18 -0
  32. package/dist/types/preferences/handler.d.ts +32 -0
  33. package/dist/types/preferences/index.d.ts +3 -0
  34. package/dist/types/preferences-react/index.d.ts +14 -0
  35. package/dist/types/preferences-react/labels.d.ts +32 -0
  36. package/dist/types/preferences-react/notification-preferences.d.ts +18 -0
  37. package/dist/types/preferences-react/preferences-client.d.ts +22 -0
  38. package/package.json +5 -2
  39. package/skills/add-pigeon-notifications/SKILL.md +337 -0
  40. package/skills/add-pigeon-notifications/references/consent.instructions.md +263 -0
  41. package/skills/add-pigeon-notifications/references/inbox-contract.instructions.md +210 -0
@@ -0,0 +1,337 @@
1
+ ---
2
+ name: add-pigeon-notifications
3
+ description: Add in-app notifications to an app with Pigeon (the Buildpad Communication Gateway) - a notification bell with an unread badge, a panel, a full list and an on/off preference from the @xtremax/pigeon npm package, plus sending notifications from server code. Use whenever the user asks for a notification bell, in-app notifications, a notification inbox or centre, an unread badge, "notify the user when X happens", or mentions Pigeon or the Communication connector, even if they do not name the package. Also use it for notification settings, opt-in and opt-out, unsubscribe and consent for messages sent through Pigeon. Do not build a custom bell, a notifications table, polling code or a local consent flag - this skill replaces all of that.
4
+ user-invokable: true
5
+ ---
6
+
7
+ # Add Pigeon Notifications
8
+
9
+ Gives an app a notification bell and the code to send notifications, using **Pigeon** and its npm package `@xtremax/pigeon`. Pigeon stores the notifications, the read state and each person's on/off choice. The app stores nothing.
10
+
11
+ This skill ships inside the package. `npx @xtremax/pigeon add-skill` copies it into the app's `.claude/skills/` (and `.kiro/skills/`); run that again after upgrading the package to get the newer skill. It is self-contained: it assumes you have none of Pigeon's own repository.
12
+
13
+ ## What you are building
14
+
15
+ ```
16
+ browser: the bell -> the app's own route: /api/communication/inbox/* -> Pigeon
17
+ (from the package) (one file, from the package's handler) (holds the notifications)
18
+
19
+ the app's server code -> communication.messages.send(...) -> Pigeon -> the person's bell
20
+ ```
21
+
22
+ Three small pieces, all from one package:
23
+
24
+ | Piece | Where | From |
25
+ |---|---|---|
26
+ | The route the bell calls | `app/api/communication/inbox/[...path]/route.ts` | `@xtremax/pigeon/inbox` |
27
+ | A settings page, if the app has one | a route and one component | `@xtremax/pigeon/preferences`, `@xtremax/pigeon/preferences-react` |
28
+ | The bell | the app's header | `@xtremax/pigeon/inbox-react` |
29
+ | Sending | wherever something happens that a person should know | `@xtremax/pigeon/client` |
30
+
31
+ A bell shows a person two things: what **this app** sent them, and the organisation's **announcements**. Another app's notifications never appear in it. There is nothing to configure for that.
32
+
33
+ ## The rules
34
+
35
+ These are what keeps the integration safe. Each has a reason; do not trade one away for convenience.
36
+
37
+ 1. **The key stays on the server.** `COMMUNICATION_API_KEY` is a secret that can read anybody's notifications. Never give it a `NEXT_PUBLIC_` name, never send it to the browser, never log it.
38
+ 2. **The browser never calls Pigeon.** It calls the app's own route, and that route decides who the person is. This is why a person can only ever see their own notifications.
39
+ 3. **The person comes from the app's own session only.** `resolvePerson` reads the signed-in user on the server. Never read a person id from the request's path, query or body: anyone could put another person's id there.
40
+ 4. **One id per person, everywhere.** The id you send to (`personId`) must be the exact string `resolvePerson` returns. Use one helper for both (Step 4). A mismatch is the most common mistake: the message is delivered and the bell stays empty.
41
+ 5. **Do not build what the package has.** No custom bell, panel, unread counter, polling hook, notifications collection or "mark as read" endpoint. No `CollectionList` for notifications: they are not a collection of this app.
42
+ 6. **A notification must never break the action that caused it.** If sending fails, log it and carry on (Step 7).
43
+ 7. **Pigeon decides who may be messaged, not the app.** Keep no opt-in or opt-out flag in the app and do not check one before sending (see "Consent" below).
44
+
45
+ The package's preference dialog uses Mantine's `Switch` internally. That is inside the package, not app code, so the Buildpad-First rule does not apply to it. Do not wrap or replace it.
46
+
47
+ ## Before you start
48
+
49
+ **1. The app must have** Next.js (App Router), React 19, Mantine 8 (`@mantine/core`, `@mantine/hooks`) and `@tabler/icons-react`. Every Buildpad starter has them. Check `package.json`. If the app is not on React and Mantine, read `references/inbox-contract.instructions.md`: the route still works, and you build the bell against its six operations.
50
+
51
+ **2. You need a key and Pigeon's address.** Look in this order, and stop at the first that has them:
52
+
53
+ - `get_project_detail` (the platform MCP tool): if `connectors[]` has `communication`, its variables are `COMMUNICATION_API_KEY` and `COMMUNICATION_URL`.
54
+ - `.env.local` already has both.
55
+ - Otherwise **ask the user for both**. Do not invent them and do not continue without them. Tell the user how an operator gets them, in Pigeon's console:
56
+ 1. Open the organisation's tenant and add a **sub-tenant** for this app (this is what gives the app a bell of its own).
57
+ 2. Switch to that sub-tenant, then **Delivery › API clients › New API client**. The key is shown once.
58
+ 3. Under **Channels**, the in-app channel must be switched on for the tenant.
59
+
60
+ **3. The package must be installable.** If `pnpm add @xtremax/pigeon` answers 404, the package is not published yet or the registry is wrong. Stop and tell the user. Do not recreate the bell by hand.
61
+
62
+ ## Step 1: Install
63
+
64
+ ```bash
65
+ pnpm add @xtremax/pigeon
66
+ ```
67
+
68
+ Version 0.4.0 or later. No component files are copied into the app.
69
+
70
+ If you are reading this skill from `node_modules/@xtremax/pigeon/skills/`, the package is already installed: run `npx @xtremax/pigeon add-skill` so the skill is in the project for the next session, and carry on.
71
+
72
+ ## Step 2: Environment
73
+
74
+ `.env.local`:
75
+
76
+ ```env
77
+ COMMUNICATION_API_KEY=<the key>
78
+ COMMUNICATION_URL=<Pigeon's address, for example https://pigeon.example.com>
79
+ ```
80
+
81
+ For a deployed app on AWS Amplify, do both of these, or the running app will not see the variables:
82
+
83
+ - Set the two variables on the Amplify app (the `amplify_set_env_vars` platform tool, or the Amplify console).
84
+ - Add them to `amplify.yml`, in the build commands, beside the other server-only variables:
85
+
86
+ ```yaml
87
+ - echo "COMMUNICATION_API_KEY=$COMMUNICATION_API_KEY" >> .env.production
88
+ - echo "COMMUNICATION_URL=$COMMUNICATION_URL" >> .env.production
89
+ ```
90
+
91
+ ## Step 3: Check the key works
92
+
93
+ Run this once, from the app's folder. It prints where the key sits and which channels it can use. It never prints the key.
94
+
95
+ ```bash
96
+ node --env-file=.env.local --input-type=module -e "import { createCommunicationClient } from '@xtremax/pigeon/client'; const c = createCommunicationClient(); console.log(await c.ping()); console.log(await c.channels.list());"
97
+ ```
98
+
99
+ Read the output:
100
+
101
+ - `scopes` must include `inbox:read` and `inbox:write`. If not, the key is an old one: ask the user for a new key.
102
+ - `subKey` is the app's sub-tenant, or `null` for a key at tenant level. Either works. With `null`, the app shares a bell with every other tenant-level app.
103
+ - In the channel list, find the entry with `medium: 'in_app'` and `ready: true`. Its `key` is the channel you send on, usually `in-app`. If there is none, the operator has to switch the in-app channel on; nothing can be sent until then.
104
+
105
+ ## Step 4: Who the person is
106
+
107
+ ```ts
108
+ // lib/communication/person.ts
109
+ /** The id Pigeon knows a person by: their email, in lower case. Used for reading and for sending. */
110
+ export const personIdOf = (email: string): string => email.trim().toLowerCase();
111
+ ```
112
+
113
+ Use the lower-cased email unless the user tells you otherwise. It is the same in every app of the organisation, so an announcement read in one app's bell is read in the others.
114
+
115
+ And one function that says who is signed in. Every route of this skill uses it:
116
+
117
+ ```ts
118
+ // lib/communication/session.ts
119
+ import { personIdOf } from './person';
120
+ import { createClient } from '@/lib/supabase/server';
121
+
122
+ /** Who is signed in, from this app's own session. null means nobody. Never read a person from the request. */
123
+ export async function resolvePerson(): Promise<string | null> {
124
+ const supabase = await createClient();
125
+ const { data } = await supabase.auth.getUser();
126
+ return data.user?.email ? personIdOf(data.user.email) : null;
127
+ }
128
+ ```
129
+
130
+ `createClient` from `@/lib/supabase/server` is how a Buildpad starter reads the signed-in user, on an Engine project and on a Supabase project alike. If the app signs people in another way, read the user from that session instead. The rule is only: from the server's session, never from the request.
131
+
132
+ ## Step 5: The route
133
+
134
+ One file. It needs no other code.
135
+
136
+ ```ts
137
+ // app/api/communication/inbox/[...path]/route.ts
138
+ import { createInboxHandler } from '@xtremax/pigeon/inbox';
139
+ import { resolvePerson } from '@/lib/communication/session';
140
+
141
+ export const dynamic = 'force-dynamic';
142
+
143
+ const handler = createInboxHandler({
144
+ resolvePerson,
145
+ // The browser is never told why something failed. This is where you see it.
146
+ onError: (error) => console.error('[communication-inbox]', error),
147
+ });
148
+
149
+ export { handler as GET, handler as POST, handler as PUT };
150
+ ```
151
+
152
+ - In an app with locale routes (`app/[lang]/...`), the route still lives at `app/api/...`, not under `[lang]`.
153
+ - **Check the middleware.** A signed-out call to this route must get the handler's own `401` JSON, not a redirect to a login page. If the app's middleware redirects `/api/*` requests, exclude `/api/communication/inbox`.
154
+ - The handler reads the key and the address on the first request. A missing one answers `503 unavailable` and never stops the build.
155
+
156
+ ## Step 6: The bell
157
+
158
+ Import the stylesheet once, in the root layout, **after** Mantine's:
159
+
160
+ ```tsx
161
+ // app/layout.tsx
162
+ import '@mantine/core/styles.css';
163
+ import '@xtremax/pigeon/inbox-react/styles.css';
164
+ ```
165
+
166
+ Put the bell in the header that signed-in people see. In a Buildpad starter that is `<AppShell.Header>` in `components/layout/AuthenticatedShell.tsx`:
167
+
168
+ ```tsx
169
+ import { InboxProvider, NotificationBell } from '@xtremax/pigeon/inbox-react';
170
+
171
+ <InboxProvider>
172
+ <NotificationBell />
173
+ </InboxProvider>
174
+ ```
175
+
176
+ - It must render inside `MantineProvider`, and only for signed-in people.
177
+ - It follows the app's Mantine theme and colour scheme. Do not restyle it.
178
+ - In an app with locale routes, pass the locale: `<InboxProvider locale={lang}>`. For a language other than English pass `labels` too; the list of labels is in `references/inbox-contract.instructions.md`.
179
+ - Render it once per page: one `InboxProvider` with one `NotificationBell`. Each provider asks for the count on its own, so two of them double the calls.
180
+
181
+ ## Step 7: Send notifications
182
+
183
+ One module makes the client, lazily, so a missing key cannot break the build:
184
+
185
+ ```ts
186
+ // lib/communication/client.ts
187
+ import { createCommunicationClient, type CommunicationClient } from '@xtremax/pigeon/client';
188
+
189
+ let client: CommunicationClient | undefined;
190
+ /** Server only. Reads COMMUNICATION_API_KEY and COMMUNICATION_URL. */
191
+ export const communication = (): CommunicationClient => (client ??= createCommunicationClient());
192
+ ```
193
+
194
+ One function sends, and never throws:
195
+
196
+ ```ts
197
+ // lib/communication/notify.ts
198
+ import { deriveIdempotencyKey } from '@xtremax/pigeon';
199
+ import { CommunicationError } from '@xtremax/pigeon/client';
200
+ import { communication } from './client';
201
+ import { personIdOf } from './person';
202
+
203
+ /** The in-app channel's key, as `channels.list()` named it in Step 3. */
204
+ const IN_APP_CHANNEL = 'in-app';
205
+
206
+ export interface Notification {
207
+ /** The recipient's email. */
208
+ to: string;
209
+ /** Plain text, at most 120 characters. */
210
+ title: string;
211
+ /** Plain text, at most 1800 characters. */
212
+ body: string;
213
+ /** What makes this notification unique: the event, the record and the recipient. Sent twice with the same parts, it arrives once. */
214
+ dedupe: Array<string | number>;
215
+ }
216
+
217
+ /** Sends one in-app notification. Answers whether Pigeon accepted it. Never throws: a notification must not break the action that caused it. */
218
+ export async function notify({ to, title, body, dedupe }: Notification): Promise<boolean> {
219
+ try {
220
+ const personId = personIdOf(to);
221
+ const message = await communication().messages.send({
222
+ templateKey: 'generic.notification',
223
+ channel: IN_APP_CHANNEL,
224
+ recipient: { personId },
225
+ variables: { title: title.slice(0, 120), body: body.slice(0, 1800) },
226
+ idempotencyKey: await deriveIdempotencyKey(...dedupe, personId),
227
+ });
228
+ // `failed` here is usually the person's own choice: they switched this app's notifications off.
229
+ return message.state !== 'failed';
230
+ } catch (error) {
231
+ console.error('[communication] notification not sent:', error instanceof CommunicationError ? error.code : error);
232
+ return false;
233
+ }
234
+ }
235
+ ```
236
+
237
+ Call it from server code only (an API route or a server action), after the action itself has succeeded:
238
+
239
+ ```ts
240
+ await notify({
241
+ to: member.email,
242
+ title: 'You were added to Finance',
243
+ body: 'Open Organisations to see your unit.',
244
+ dedupe: ['unit-member-added', unit.id],
245
+ });
246
+ ```
247
+
248
+ What to know when choosing what to send:
249
+
250
+ - **Ask the user which events should notify whom** if they have not said. Do not notify on everything.
251
+ - **Do not notify the person who did the action** about their own action.
252
+ - **`generic.notification`** is a template every tenant has: a title and a body, plain text. Markup is shown as text.
253
+ - **A notification that opens a page** needs a template with a link, which an operator adds under **Templates** in Pigeon's console (an in-app variant with a `link`). Use its key as `templateKey` and pass its variables. Only absolute `https://` links open. You cannot create a template from the app: ask the user.
254
+ - **Many recipients:** call `notify` once per person. For "everyone in the organisation", that is an announcement, which an operator sends from Pigeon's console; do not loop over all users.
255
+ - **`dedupe`** must name the event and the record; the recipient is added for you. Sent again with the same parts, the notification arrives once, which is what makes a retry safe. So when the same thing can truly happen twice (added, removed, added again), include what tells the two apart, such as the id of the membership record. The same parts with different text are refused.
256
+
257
+ ## Consent
258
+
259
+ A person can switch notifications off, and Pigeon enforces it. For in-app notifications this is **already done** when the steps above are done:
260
+
261
+ - The bell has "Notification preferences", where a person switches **this app's** notifications off or on. Other apps and the organisation's announcements are not affected.
262
+ - When it is off, `notify` answers `false` and nothing arrives. That is the person's choice, not a failure: do not retry, do not send by another route, do not tell the person who triggered it.
263
+
264
+ What the app must not do, because each of these breaks a choice the person made:
265
+
266
+ - **No consent kept in the app**: no `notifications_enabled` column, no settings collection for it, no cached copy. Pigeon is where the choice lives, so it also holds when the person changes it somewhere else.
267
+ - **No check before sending.** Send, and let Pigeon refuse.
268
+ - **No switching a person back on for them**, and no pre-ticked box.
269
+
270
+ Read `references/consent.instructions.md` before you build any of these. It has the code and the wording:
271
+
272
+ - a **notification settings page** (email, WhatsApp and in-app switches in one place): the package has the route and the component, `createPreferencesHandler` and `NotificationPreferences`, so it is two short files;
273
+ - sending **email or WhatsApp** through Pigeon as well, where choices cover every app of the organisation and WhatsApp needs an explicit opt-in;
274
+ - **collecting a phone number**, importing people, or recording a choice on someone's behalf;
275
+ - messages that **must always arrive** (receipts, security alerts);
276
+ - **erasing** a person.
277
+
278
+ ## Step 8: Tests
279
+
280
+ Add these. They are quick, and the first group needs no key.
281
+
282
+ **Playwright, signed out** (the route protects itself):
283
+
284
+ ```ts
285
+ test('the inbox route refuses a signed-out caller and a foreign origin', async ({ request }) => {
286
+ const signedOut = await request.get('/api/communication/inbox/summary');
287
+ expect(signedOut.status()).toBe(401);
288
+ expect((await signedOut.json()).error.code).toBe('not_signed_in');
289
+
290
+ const foreign = await request.post('/api/communication/inbox/read-all', { headers: { origin: 'https://evil.example' }, data: {} });
291
+ expect(foreign.status()).toBe(403);
292
+
293
+ expect((await request.get('/api/communication/inbox/everyone')).status()).toBe(404);
294
+ });
295
+ ```
296
+
297
+ **Playwright, signed in** (with the app's existing test sign-in): the bell is in the header and opens.
298
+
299
+ ```ts
300
+ const bell = page.getByRole('button', { name: /^Notifications/ });
301
+ await expect(bell).toBeVisible();
302
+ await bell.click();
303
+ await expect(page.getByRole('dialog').getByRole('heading', { name: 'Notifications' })).toBeVisible();
304
+ ```
305
+
306
+ **Vitest:** `personIdOf(' Ana@Acme.com ')` is `'ana@acme.com'`; and `notify` answers `false` without throwing when the client throws (mock `./client`).
307
+
308
+ ## Step 9: Verify
309
+
310
+ - [ ] `pnpm build` and the type check pass.
311
+ - [ ] Signed out, `GET /api/communication/inbox/summary` answers `401` with `{"error":{"code":"not_signed_in",...}}`, as JSON and not as a redirect.
312
+ - [ ] Signed in, the same call answers `200` with `unreadCount`, and the bell shows in the header.
313
+ - [ ] Trigger a `notify`. The badge appears at the bell's next check, within 60 seconds. Opening the bell shows the notification; selecting it marks it read.
314
+ - [ ] `grep -rn "COMMUNICATION_API_KEY" app components lib` shows no file with `'use client'` and no `NEXT_PUBLIC_` name.
315
+ - [ ] No custom bell, notifications collection or polling code was added, and no field or collection that stores an opt-in or opt-out.
316
+ - [ ] The app's docs say where notifications are sent from and which two variables a deployment needs.
317
+
318
+ ## When it does not work
319
+
320
+ | What you see | Cause |
321
+ |---|---|
322
+ | `pnpm add` answers 404 | The package is not published, or the registry is not npm's. Stop and tell the user. |
323
+ | The bell's calls answer `503 unavailable` | The key or the address is missing or wrong, or `resolvePerson` threw. The server log has the real error after `[communication-inbox]`. On Amplify, the two `amplify.yml` lines are usually what is missing. |
324
+ | The bell's calls answer `401 not_signed_in` while signed in | `resolvePerson` returned `null`: the session is not read in the route. |
325
+ | The bell's calls return HTML, or the bell never shows a count | The middleware redirects the route to a login page. Exclude `/api/communication/inbox`. |
326
+ | A send throws `channel_unavailable` | The in-app channel is not switched on for the tenant, or `IN_APP_CHANNEL` is not the key that `channels.list()` named. |
327
+ | A send throws `forbidden` | The key lacks a scope. Ask for a new key. |
328
+ | The message is delivered but the bell is empty | The `personId` sent to differs from what `resolvePerson` returns, or the key used for sending sits in another sub-tenant than the key the route uses. Use one key and `personIdOf` on both sides. |
329
+ | `notify` answers `false` and nothing is logged | The message was refused by the person's own choice: they switched this app's notifications off in the bell's preferences. This is correct. |
330
+ | The list inside the bell looks unstyled | The stylesheet import of Step 6 is missing. |
331
+ | An error says `MantineProvider` was not found | The bell is rendered outside `MantineProvider`. |
332
+
333
+ ## Reference
334
+
335
+ `references/consent.instructions.md`: who decides whether a message goes out, the rules for an app, the defaults per medium, registering people and addresses, a settings page from the package's `createPreferencesHandler` and `NotificationPreferences` (options, labels and the route's contract), transactional templates and erasure.
336
+
337
+ `references/inbox-contract.instructions.md`: every option of `InboxProvider` and `createInboxHandler`, the list of labels for translation, the six operations of the route with their answers and error codes, calling the inbox directly from server code, and what to do in an app without React or Mantine.
@@ -0,0 +1,263 @@
1
+ # Consent: who may be messaged, and how an app takes part
2
+
3
+ Read this when the app has a notification settings page, sends email or WhatsApp through Pigeon as well as in-app notifications, collects phone numbers, imports people, or has to erase a person.
4
+
5
+ ## Contents
6
+
7
+ - [Who decides](#who-decides)
8
+ - [The rules](#the-rules)
9
+ - [What each medium does by default](#what-each-medium-does-by-default)
10
+ - [What the package gives you, and what it does not](#what-the-package-gives-you-and-what-it-does-not)
11
+ - [1. When a person joins](#1-when-a-person-joins)
12
+ - [2. A settings page](#2-a-settings-page)
13
+ - [3. Messages that must always arrive](#3-messages-that-must-always-arrive)
14
+ - [4. Erasing a person](#4-erasing-a-person)
15
+ - [Tests](#tests)
16
+
17
+ ## Who decides
18
+
19
+ **Pigeon decides, for every message, whether it may go out.** It checks, in this order: the person was erased; the person is suspended; the address is suppressed (for example a hard bounce); the person opted out; the medium needs an opt-in that was not given; it is inside the person's quiet hours. The first that applies wins.
20
+
21
+ The app has two jobs and no more:
22
+
23
+ 1. **Tell Pigeon what people chose**, when they choose it.
24
+ 2. **Show people their choices** and let them change them.
25
+
26
+ The app never decides itself. This is what makes a choice hold in every app of the organisation: an opt-out recorded by one app stops the mail of all of them.
27
+
28
+ ## The rules
29
+
30
+ 1. **Keep no consent in the app.** No `email_opt_in` column, no `notification_settings` collection, no cached copy. Read it from Pigeon when you show it; record it in Pigeon when it changes. A local copy goes stale the moment the person unsubscribes from an email or changes it in another app.
31
+ 2. **Do not check before sending.** Send, and let Pigeon refuse. A refused message is not an error: `messages.send` answers a message whose `state` is `failed`, and `messages.get(id)` gives the reason, which starts `policy_refused.`. Do not show it to the person who triggered the send, and do not retry it.
32
+ 3. **Only the person records their own choice.** The route that records a choice reads the person from the signed-in session, never from the request. Recording for someone else (an import, a paper form) needs evidence of how they chose.
33
+ 4. **Never opt a person in for them.** No pre-ticked box, no "on" written at sign-up without a tick. With no choice recorded, Pigeon already does the right thing for each medium.
34
+ 5. **Say what a switch covers.** The email and WhatsApp choices cover **every app of the organisation**. The in-app choice covers **this app only**. The words beside each switch must say so (see the wording below).
35
+ 6. **Make switching off as easy as switching on.** Same page, same kind of control, no confirmation that argues.
36
+ 7. **Do not mark a message transactional to get round an opt-out.** That class is for messages a person must get: receipts, security alerts, password resets.
37
+
38
+ ## What each medium does by default
39
+
40
+ | Medium | With no choice recorded | A choice covers | How a person changes it outside your app |
41
+ |---|---|---|---|
42
+ | Email | Sent | Every app of the organisation | The unsubscribe link in every email |
43
+ | WhatsApp | **Not sent.** It needs an explicit opt-in, for every message, transactional ones too | Every app of the organisation | Texting STOP or START to the number |
44
+ | In-app | Shown | This app only (its sub-tenant) | The preferences in the bell |
45
+
46
+ An operator can set email to need an opt-in too; then email behaves like WhatsApp in the table. You do not have to know: `delivers` in a person's choices says whether a message reaches them now, and the settings component follows it.
47
+
48
+ ## What the package gives you, and what it does not
49
+
50
+ | Piece | From | Does |
51
+ |---|---|---|
52
+ | `createPreferencesHandler` | `@xtremax/pigeon/preferences` (server) | The app's own route for a settings page: `GET` answers the signed-in person's choices, `PUT` records one, with evidence |
53
+ | `NotificationPreferences` | `@xtremax/pigeon/preferences-react` (browser) | One switch per medium the app can send on, with a sentence that says what the switch covers |
54
+ | The bell's preference dialog | `@xtremax/pigeon/inbox-react` | The in-app choice, with no code from you |
55
+ | `preferences.get(personId)` | the client | Each medium's choice, and `delivers`: whether a message reaches the person now |
56
+ | `preferences.record({ recipient, medium, state, expressedAt?, evidence? })` | the client | Records a choice. Creates the person if Pigeon has not met them |
57
+ | `recipients.put(personId, { locale?, timezone?, quietHours? })` | the client | Creates or updates the person |
58
+ | `recipients.putAddress(personId, { medium, address, primary?, verified? })` | the client | Stores an email address or a WhatsApp number (E.164) |
59
+ | `recipients.get(personId)` | the client | The person and their masked addresses |
60
+ | `messageClasses.put(templateKey, { class })` | the client | Marks a template `transactional` or `non_transactional` |
61
+ | `recipients.delete(personId)` | the client | Erases the person's data, for the whole organisation |
62
+
63
+ "The client" is `communication()` from `lib/communication/client.ts`; its calls are server-side only.
64
+
65
+ **Not in the package**, so do not look for it: choices per topic ("product news" but not "weekly digest"). Pigeon knows one choice per medium. If the app wants topics, they are the app's own settings about *what it sends*, kept in the app, and they never replace the person's consent.
66
+
67
+ ## 1. When a person joins
68
+
69
+ For in-app notifications alone, **do nothing**: sending to a `personId` is enough.
70
+
71
+ Register the person only when the app sends them **email or WhatsApp** by `personId`, because Pigeon then needs an address on record. Do it after the sign-up itself has succeeded, and never let it fail the sign-up:
72
+
73
+ ```ts
74
+ // lib/communication/recipients.ts
75
+ import { communication } from './client';
76
+ import { personIdOf } from './person';
77
+
78
+ /** Tells Pigeon about a person and where to reach them. Never throws: a sign-up must not fail because of it. */
79
+ export async function registerRecipient(person: { email: string; locale?: string; timezone?: string; whatsapp?: string }): Promise<void> {
80
+ const personId = personIdOf(person.email);
81
+ try {
82
+ const pigeon = communication();
83
+ // Only what you know: a field you leave out keeps the value another app may have set.
84
+ await pigeon.recipients.put(personId, { ...(person.locale ? { locale: person.locale } : {}), ...(person.timezone ? { timezone: person.timezone } : {}) });
85
+ // `verified` only for an address the person proved is theirs, such as the one they signed in with.
86
+ await pigeon.recipients.putAddress(personId, { medium: 'email', address: person.email, primary: true, verified: true });
87
+ if (person.whatsapp) await pigeon.recipients.putAddress(personId, { medium: 'whatsapp', address: person.whatsapp, primary: true });
88
+ } catch (error) {
89
+ console.error('[communication] recipient not registered:', error);
90
+ }
91
+ }
92
+ ```
93
+
94
+ Storing a WhatsApp number is not an opt-in. To message it, the person must have agreed, and Pigeon must have that on record with evidence. Ask where you collect the number, with a box that starts **unticked**, and record it only when it was ticked:
95
+
96
+ ```ts
97
+ if (form.whatsappAgreed) {
98
+ await communication().preferences.record({
99
+ recipient: { personId: personIdOf(form.email) },
100
+ medium: 'whatsapp',
101
+ state: 'opted_in',
102
+ expressedAt: new Date().toISOString(),
103
+ evidence: { method: 'signup_form', wordingVersion: '2026-11', proofRef: submission.id },
104
+ });
105
+ }
106
+ ```
107
+
108
+ `evidence` is what you would show if asked "when did this person agree, and to what words": `method` (where they chose), `wordingVersion` (which text they saw; change it when the text changes), `proofRef` (your own record's id).
109
+
110
+ An address that already belongs to another person is refused (`conflict`); a person may have at most 10.
111
+
112
+ ## 2. A settings page
113
+
114
+ Two pieces, both from the package. Do not write the route or the switches yourself.
115
+
116
+ **The route.** It uses the same `resolvePerson` as the inbox route:
117
+
118
+ ```ts
119
+ // app/api/communication/preferences/route.ts
120
+ import { createPreferencesHandler } from '@xtremax/pigeon/preferences';
121
+ import { resolvePerson } from '@/lib/communication/session';
122
+
123
+ export const dynamic = 'force-dynamic';
124
+
125
+ const handler = createPreferencesHandler({
126
+ resolvePerson,
127
+ // Recorded with every choice. Change the version whenever the text beside the switches changes.
128
+ evidence: { wordingVersion: '2026-11' },
129
+ onError: (error) => console.error('[communication-preferences]', error),
130
+ });
131
+
132
+ export { handler as GET, handler as PUT };
133
+ ```
134
+
135
+ **The switches.** Put the component on the app's settings page, inside `MantineProvider`:
136
+
137
+ ```tsx
138
+ // app/(authenticated)/settings/notifications/page.tsx
139
+ import { Stack, Title } from '@mantine/core';
140
+ import { NotificationPreferences } from '@xtremax/pigeon/preferences-react';
141
+
142
+ export default function NotificationSettingsPage() {
143
+ return (
144
+ <Stack gap="md" maw={560}>
145
+ <Title order={2}>Notifications</Title>
146
+ <NotificationPreferences />
147
+ </Stack>
148
+ );
149
+ }
150
+ ```
151
+
152
+ That is the whole settings page. What it does:
153
+
154
+ - It shows a switch for each medium **the app can send on** (Pigeon says which), so an app with no WhatsApp channel shows no WhatsApp switch.
155
+ - A switch shows what is **recorded**: it moves when the choice is saved, not before. A failure says so.
156
+ - Each switch has a sentence that says what it covers: email and WhatsApp "every app of your organisation", in-app "this app".
157
+ - With no choice recorded, email and in-app are on and WhatsApp is off. If an operator made email need an opt-in, the switch follows.
158
+ - The in-app switch is the same choice as the one in the bell.
159
+
160
+ The component's switches are Mantine's, inside the package. That is not app code, so the Buildpad-First rule does not apply to it; do not rebuild it with the Buildpad `Toggle`. If the page needs a layout of its own, build that page against the route's contract below, and there use the Buildpad `Toggle`.
161
+
162
+ `createPreferencesHandler` options:
163
+
164
+ | Option | Default | Use |
165
+ |---|---|---|
166
+ | `resolvePerson` (required) | | The signed-in person's id from the app's own session, or `null` |
167
+ | `client` | made on the first request from `COMMUNICATION_API_KEY` and `COMMUNICATION_URL` | `createPreferencesClient(...)` with other settings |
168
+ | `basePath` | `/api/communication/preferences` | Where the route is mounted. Must match the component's `apiBase` |
169
+ | `mediums` | all three | The media a person may see and change through this route, for example `['email', 'in_app']` |
170
+ | `evidence` | `{ method: 'settings_page' }` | `method` and `wordingVersion`, recorded with every choice |
171
+ | `allowedOrigins` | the request's own origin | Origins that may send `PUT` |
172
+ | `timeoutMs` | 8000 | Bounds each call to Pigeon |
173
+ | `onError` | | Told about every failure answered as `unavailable` |
174
+
175
+ `NotificationPreferences` props, all optional:
176
+
177
+ | Prop | Default | Use |
178
+ |---|---|---|
179
+ | `apiBase` | `/api/communication/preferences` | A path on the app's own origin |
180
+ | `labels` | English | Replaces any text. See below |
181
+ | `mediums` | the media the app can send on | Which switches to show, in this order |
182
+ | `onChange` | | Called with the saved choice, for example to show a toast. Needs a client component |
183
+
184
+ Labels, for another language. Translate the words and keep what each sentence says about scope. The component picks the sentence from what Pigeon says the choice covers: today email and WhatsApp choices cover the whole organisation, and the `...App` sentences are shown only if Pigeon answers that a choice covers this app alone.
185
+
186
+ | Key | English default |
187
+ |---|---|
188
+ | `group` | Notification preferences |
189
+ | `email` | Email |
190
+ | `emailOn` | You receive email from every app of your organisation. |
191
+ | `emailOff` | You receive no email from any app of your organisation, except messages you must get, such as receipts and security alerts. |
192
+ | `emailOnApp` | You receive email from this app. |
193
+ | `emailOffApp` | You receive no email from this app, except messages you must get, such as receipts and security alerts. Other apps are not affected. |
194
+ | `whatsapp` | WhatsApp |
195
+ | `whatsappOn` | You receive WhatsApp messages from every app of your organisation. |
196
+ | `whatsappOff` | You receive no WhatsApp messages from any app of your organisation. |
197
+ | `whatsappOnApp` | You receive WhatsApp messages from this app. |
198
+ | `whatsappOffApp` | You receive no WhatsApp messages from this app. Other apps are not affected. |
199
+ | `inApp` | Notifications in this app |
200
+ | `inAppOn` | New notifications from this app appear under the bell. |
201
+ | `inAppOff` | New notifications from this app do not appear. The ones you already have stay. Other apps are not affected. |
202
+ | `loading` | Loading your notification preferences |
203
+ | `loadError` | Your notification preferences could not be loaded. |
204
+ | `retry` | Try again |
205
+ | `saveError` | Your choice was not saved. Try again. |
206
+ | `none` | There is nothing to choose here yet. |
207
+
208
+ The route's contract, for a page of your own. Answers are JSON with `Cache-Control: no-store`; failures use the same six codes as the inbox route.
209
+
210
+ | Method | Request | `data` in the answer |
211
+ |---|---|---|
212
+ | `GET` | | `{ preferences: [...] }` |
213
+ | `PUT` | `{ "medium": "email" \| "whatsapp" \| "in_app", "state": "opted_in" \| "opted_out" }` | `{ preferences: [...] }`, as they are afterwards |
214
+
215
+ ```ts
216
+ interface PreferenceChoice {
217
+ medium: 'email' | 'whatsapp' | 'in_app';
218
+ state: 'opted_in' | 'opted_out' | 'not_set';
219
+ changedAt: string | null;
220
+ delivers: boolean; // whether a message on this medium reaches the person now
221
+ covers: 'tenant' | 'app'; // tenant: every app of the organisation; app: this app only
222
+ available: boolean; // whether this app can send on the medium
223
+ }
224
+ ```
225
+
226
+ To read a person's choices in server code (never to decide whether to send):
227
+
228
+ ```ts
229
+ const choices = await communication().preferences.get(personId);
230
+ const emailArrives = choices.find((choice) => choice.medium === 'email')?.delivers ?? false;
231
+ ```
232
+
233
+ - In an app with a `[lang]` segment, the route still lives at `app/api/...`.
234
+ - Check the middleware lets `/api/communication/preferences` answer its own `401` JSON, as for the inbox route.
235
+ - If the route answers `503` and the server log shows `not_found`, the Pigeon deployment is older than the package: it has no `GET /preferences` yet. Tell the user; do not work around it.
236
+
237
+ ## 3. Messages that must always arrive
238
+
239
+ By default every template is `non_transactional`: an opt-out stops it. For a receipt, a security alert or a password reset, mark the template once (a one-off script or a deploy step, not on every send):
240
+
241
+ ```ts
242
+ await communication().messageClasses.put('order.receipt', { class: 'transactional', note: 'Order receipts' });
243
+ ```
244
+
245
+ - A transactional email is sent to a person who opted out, and is not held by quiet hours.
246
+ - It changes nothing on WhatsApp, which needs an opt-in for every message.
247
+ - A suppressed address, a suspended person and an erased person are still not messaged.
248
+ - Ask the user which templates qualify. Do not decide it yourself, and never mark `generic.notification`.
249
+
250
+ ## 4. Erasing a person
251
+
252
+ ```ts
253
+ await communication().recipients.delete(personId);
254
+ ```
255
+
256
+ This erases the person's data in Pigeon **for the whole organisation, for good**: every app loses the ability to message them. Use it only for a real erasure request, and only after the user has confirmed. It is not what you call when a person merely leaves this app or switches notifications off.
257
+
258
+ ## Tests
259
+
260
+ - **Playwright, signed out:** `GET /api/communication/preferences` answers `401`; a `PUT` with `origin: https://evil.example` answers `403`.
261
+ - **Playwright, signed in:** the settings page shows the switches (`getByRole('switch', { name: 'Email' })` inside `getByRole('group', { name: 'Notification preferences' })`); switching one off shows its "off" sentence and survives a reload. Wait for the `PUT` to answer before you assert: the switch moves only when the choice is saved.
262
+ - **Vitest, `registerRecipient`** with `./client` mocked: it never throws, and it sends no `locale` or `timezone` it was not given.
263
+ - **A grep:** no column, field or collection in the app that stores an opt-in or opt-out.