@xtremax/pigeon 0.3.1 → 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 (40) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +28 -1
  3. package/dist/cjs/client.js +3 -0
  4. package/dist/cjs/inbox/client.js +4 -9
  5. package/dist/cjs/person.js +10 -0
  6. package/dist/cjs/preferences/browser-stub.js +10 -0
  7. package/dist/cjs/preferences/client.js +21 -0
  8. package/dist/cjs/preferences/handler.js +96 -0
  9. package/dist/cjs/preferences/index.js +8 -0
  10. package/dist/cjs/preferences-react/index.js +18 -0
  11. package/dist/cjs/preferences-react/labels.js +32 -0
  12. package/dist/cjs/preferences-react/notification-preferences.js +76 -0
  13. package/dist/cjs/preferences-react/preferences-client.js +72 -0
  14. package/dist/esm/client.js +3 -0
  15. package/dist/esm/inbox/client.js +2 -7
  16. package/dist/esm/person.js +7 -0
  17. package/dist/esm/preferences/browser-stub.js +7 -0
  18. package/dist/esm/preferences/client.js +18 -0
  19. package/dist/esm/preferences/handler.js +92 -0
  20. package/dist/esm/preferences/index.js +2 -0
  21. package/dist/esm/preferences-react/index.js +13 -0
  22. package/dist/esm/preferences-react/labels.js +28 -0
  23. package/dist/esm/preferences-react/notification-preferences.js +73 -0
  24. package/dist/esm/preferences-react/preferences-client.js +66 -0
  25. package/dist/types/client.d.ts +5 -10
  26. package/dist/types/contract.d.ts +27 -0
  27. package/dist/types/inbox/client.d.ts +2 -2
  28. package/dist/types/person.d.ts +2 -0
  29. package/dist/types/preferences/browser-stub.d.ts +3 -0
  30. package/dist/types/preferences/client.d.ts +18 -0
  31. package/dist/types/preferences/handler.d.ts +32 -0
  32. package/dist/types/preferences/index.d.ts +3 -0
  33. package/dist/types/preferences-react/index.d.ts +14 -0
  34. package/dist/types/preferences-react/labels.d.ts +32 -0
  35. package/dist/types/preferences-react/notification-preferences.d.ts +18 -0
  36. package/dist/types/preferences-react/preferences-client.d.ts +22 -0
  37. package/package.json +4 -2
  38. package/skills/add-pigeon-notifications/SKILL.md +49 -14
  39. package/skills/add-pigeon-notifications/references/consent.instructions.md +263 -0
  40. package/skills/add-pigeon-notifications/references/inbox-contract.instructions.md +3 -1
@@ -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.
@@ -22,10 +22,12 @@ Read this when the steps in `SKILL.md` are not enough: translating the bell, pla
22
22
  | `@xtremax/pigeon/inbox` | server | `createInboxHandler`, `createInboxClient` |
23
23
  | `@xtremax/pigeon/inbox-react` | browser | `InboxProvider`, `NotificationBell`, `InboxFullList`, `DEFAULT_INBOX_LABELS` |
24
24
  | `@xtremax/pigeon/inbox-react/styles.css` | browser | the bell's stylesheet |
25
+ | `@xtremax/pigeon/preferences` | server | `createPreferencesHandler`, `createPreferencesClient` (see `consent.instructions.md`) |
26
+ | `@xtremax/pigeon/preferences-react` | browser | `NotificationPreferences`, `DEFAULT_PREFERENCE_LABELS` |
25
27
  | `@xtremax/pigeon/events` | server | `createEventHandler`, `verifyEventSignature` (delivery events; not needed for the bell) |
26
28
  | `@xtremax/pigeon` | server | all server entries, plus `deriveIdempotencyKey`, `newIdempotencyKey` |
27
29
 
28
- The server entries hold the key. Imported into a browser bundle they resolve to a stub that throws `server_only`. `inbox-react` holds no key and calls only the app's own route.
30
+ The server entries hold the key. Imported into a browser bundle they resolve to a stub that throws `server_only`. `inbox-react` and `preferences-react` hold no key and call only the app's own routes.
29
31
 
30
32
  The names in the code say `Communication` (`createCommunicationClient`, `COMMUNICATION_API_KEY`). That is Pigeon's name on the Buildpad platform, where it is the Communication connector. It is the same service.
31
33