@xtremax/pigeon 0.0.0-stage → 0.3.1
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.
- package/CHANGELOG.md +22 -0
- package/README.md +68 -2
- package/bin/pigeon.mjs +92 -0
- package/dist/cjs/browser-stub.js +10 -0
- package/dist/cjs/client.js +112 -0
- package/dist/cjs/contract.js +5 -0
- package/dist/cjs/errors.js +48 -0
- package/dist/cjs/events.js +62 -0
- package/dist/cjs/idempotency.js +14 -0
- package/dist/cjs/inbox/browser-stub.js +10 -0
- package/dist/cjs/inbox/client.js +39 -0
- package/dist/cjs/inbox/errors.js +44 -0
- package/dist/cjs/inbox/handler.js +127 -0
- package/dist/cjs/inbox/index.js +15 -0
- package/dist/cjs/inbox/origin.js +22 -0
- package/dist/cjs/inbox/project.js +84 -0
- package/dist/cjs/inbox/validate.js +59 -0
- package/dist/cjs/inbox-react/format.js +72 -0
- package/dist/cjs/inbox-react/inbox-client.js +63 -0
- package/dist/cjs/inbox-react/inbox-context.js +13 -0
- package/dist/cjs/inbox-react/inbox-full-list.js +13 -0
- package/dist/cjs/inbox-react/inbox-list.js +37 -0
- package/dist/cjs/inbox-react/inbox-panel.js +13 -0
- package/dist/cjs/inbox-react/inbox-provider.js +157 -0
- package/dist/cjs/inbox-react/inbox-state.js +88 -0
- package/dist/cjs/inbox-react/index.js +25 -0
- package/dist/cjs/inbox-react/labels.js +38 -0
- package/dist/cjs/inbox-react/notification-bell.js +32 -0
- package/dist/cjs/inbox-react/notification-item.js +17 -0
- package/dist/cjs/inbox-react/parse.js +94 -0
- package/dist/cjs/inbox-react/preference-dialog.js +49 -0
- package/dist/cjs/inbox-react/types.js +4 -0
- package/dist/cjs/inbox-react/use-inbox-polling.js +94 -0
- package/dist/cjs/index.js +14 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/retry.js +14 -0
- package/dist/esm/browser-stub.js +6 -0
- package/dist/esm/client.js +107 -0
- package/dist/esm/contract.js +2 -0
- package/dist/esm/errors.js +42 -0
- package/dist/esm/events.js +57 -0
- package/dist/esm/idempotency.js +10 -0
- package/dist/esm/inbox/browser-stub.js +7 -0
- package/dist/esm/inbox/client.js +35 -0
- package/dist/esm/inbox/errors.js +38 -0
- package/dist/esm/inbox/handler.js +124 -0
- package/dist/esm/inbox/index.js +5 -0
- package/dist/esm/inbox/origin.js +19 -0
- package/dist/esm/inbox/project.js +76 -0
- package/dist/esm/inbox/validate.js +49 -0
- package/dist/esm/inbox-react/format.js +64 -0
- package/dist/esm/inbox-react/inbox-client.js +58 -0
- package/dist/esm/inbox-react/inbox-context.js +9 -0
- package/dist/esm/inbox-react/inbox-full-list.js +10 -0
- package/dist/esm/inbox-react/inbox-list.js +34 -0
- package/dist/esm/inbox-react/inbox-panel.js +10 -0
- package/dist/esm/inbox-react/inbox-provider.js +154 -0
- package/dist/esm/inbox-react/inbox-state.js +83 -0
- package/dist/esm/inbox-react/index.js +18 -0
- package/dist/esm/inbox-react/labels.js +34 -0
- package/dist/esm/inbox-react/notification-bell.js +29 -0
- package/dist/esm/inbox-react/notification-item.js +14 -0
- package/dist/esm/inbox-react/parse.js +85 -0
- package/dist/esm/inbox-react/preference-dialog.js +46 -0
- package/dist/esm/inbox-react/types.js +1 -0
- package/dist/esm/inbox-react/use-inbox-polling.js +88 -0
- package/dist/esm/index.js +4 -0
- package/dist/esm/retry.js +9 -0
- package/dist/inbox-react/styles.css +65 -0
- package/dist/types/browser-stub.d.ts +4 -0
- package/dist/types/client.d.ts +94 -0
- package/dist/types/contract.d.ts +182 -0
- package/dist/types/errors.d.ts +29 -0
- package/dist/types/events.d.ts +26 -0
- package/dist/types/idempotency.d.ts +4 -0
- package/dist/types/inbox/browser-stub.d.ts +3 -0
- package/dist/types/inbox/client.d.ts +34 -0
- package/dist/types/inbox/errors.d.ts +13 -0
- package/dist/types/inbox/handler.d.ts +25 -0
- package/dist/types/inbox/index.d.ts +6 -0
- package/dist/types/inbox/origin.d.ts +5 -0
- package/dist/types/inbox/project.d.ts +7 -0
- package/dist/types/inbox/validate.d.ts +25 -0
- package/dist/types/inbox-react/format.d.ts +13 -0
- package/dist/types/inbox-react/inbox-client.d.ts +24 -0
- package/dist/types/inbox-react/inbox-context.d.ts +29 -0
- package/dist/types/inbox-react/inbox-full-list.d.ts +7 -0
- package/dist/types/inbox-react/inbox-list.d.ts +12 -0
- package/dist/types/inbox-react/inbox-panel.d.ts +2 -0
- package/dist/types/inbox-react/inbox-provider.d.ts +18 -0
- package/dist/types/inbox-react/inbox-state.d.ts +70 -0
- package/dist/types/inbox-react/index.d.ts +19 -0
- package/dist/types/inbox-react/labels.d.ts +40 -0
- package/dist/types/inbox-react/notification-bell.d.ts +4 -0
- package/dist/types/inbox-react/notification-item.d.ts +9 -0
- package/dist/types/inbox-react/parse.d.ts +9 -0
- package/dist/types/inbox-react/preference-dialog.d.ts +5 -0
- package/dist/types/inbox-react/types.d.ts +55 -0
- package/dist/types/inbox-react/use-inbox-polling.d.ts +33 -0
- package/dist/types/index.d.ts +6 -0
- package/dist/types/retry.d.ts +13 -0
- package/package.json +34 -4
- package/skills/add-pigeon-notifications/SKILL.md +302 -0
- package/skills/add-pigeon-notifications/references/inbox-contract.instructions.md +208 -0
|
@@ -0,0 +1,302 @@
|
|
|
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. Do not build a custom bell, a notifications table or polling code - 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
|
+
| The bell | the app's header | `@xtremax/pigeon/inbox-react` |
|
|
28
|
+
| Sending | wherever something happens that a person should know | `@xtremax/pigeon/client` |
|
|
29
|
+
|
|
30
|
+
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.
|
|
31
|
+
|
|
32
|
+
## The rules
|
|
33
|
+
|
|
34
|
+
These are what keeps the integration safe. Each has a reason; do not trade one away for convenience.
|
|
35
|
+
|
|
36
|
+
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.
|
|
37
|
+
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.
|
|
38
|
+
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.
|
|
39
|
+
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.
|
|
40
|
+
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.
|
|
41
|
+
6. **A notification must never break the action that caused it.** If sending fails, log it and carry on (Step 6).
|
|
42
|
+
|
|
43
|
+
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.
|
|
44
|
+
|
|
45
|
+
## Before you start
|
|
46
|
+
|
|
47
|
+
**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.
|
|
48
|
+
|
|
49
|
+
**2. You need a key and Pigeon's address.** Look in this order, and stop at the first that has them:
|
|
50
|
+
|
|
51
|
+
- `get_project_detail` (the platform MCP tool): if `connectors[]` has `communication`, its variables are `COMMUNICATION_API_KEY` and `COMMUNICATION_URL`.
|
|
52
|
+
- `.env.local` already has both.
|
|
53
|
+
- 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:
|
|
54
|
+
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).
|
|
55
|
+
2. Switch to that sub-tenant, then **Delivery › API clients › New API client**. The key is shown once.
|
|
56
|
+
3. Under **Channels**, the in-app channel must be switched on for the tenant.
|
|
57
|
+
|
|
58
|
+
**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.
|
|
59
|
+
|
|
60
|
+
## Step 1: Install
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pnpm add @xtremax/pigeon
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Version 0.3.1 or later. No component files are copied into the app.
|
|
67
|
+
|
|
68
|
+
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.
|
|
69
|
+
|
|
70
|
+
## Step 2: Environment
|
|
71
|
+
|
|
72
|
+
`.env.local`:
|
|
73
|
+
|
|
74
|
+
```env
|
|
75
|
+
COMMUNICATION_API_KEY=<the key>
|
|
76
|
+
COMMUNICATION_URL=<Pigeon's address, for example https://pigeon.example.com>
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
For a deployed app on AWS Amplify, do both of these, or the running app will not see the variables:
|
|
80
|
+
|
|
81
|
+
- Set the two variables on the Amplify app (the `amplify_set_env_vars` platform tool, or the Amplify console).
|
|
82
|
+
- Add them to `amplify.yml`, in the build commands, beside the other server-only variables:
|
|
83
|
+
|
|
84
|
+
```yaml
|
|
85
|
+
- echo "COMMUNICATION_API_KEY=$COMMUNICATION_API_KEY" >> .env.production
|
|
86
|
+
- echo "COMMUNICATION_URL=$COMMUNICATION_URL" >> .env.production
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Step 3: Check the key works
|
|
90
|
+
|
|
91
|
+
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.
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
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());"
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Read the output:
|
|
98
|
+
|
|
99
|
+
- `scopes` must include `inbox:read` and `inbox:write`. If not, the key is an old one: ask the user for a new key.
|
|
100
|
+
- `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.
|
|
101
|
+
- 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.
|
|
102
|
+
|
|
103
|
+
## Step 4: One helper for the person's id
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
// lib/communication/person.ts
|
|
107
|
+
/** The id Pigeon knows a person by: their email, in lower case. Used for reading and for sending. */
|
|
108
|
+
export const personIdOf = (email: string): string => email.trim().toLowerCase();
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
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.
|
|
112
|
+
|
|
113
|
+
## Step 5: The route
|
|
114
|
+
|
|
115
|
+
One file. It needs no other code.
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
// app/api/communication/inbox/[...path]/route.ts
|
|
119
|
+
import { createInboxHandler } from '@xtremax/pigeon/inbox';
|
|
120
|
+
import { personIdOf } from '@/lib/communication/person';
|
|
121
|
+
import { createClient } from '@/lib/supabase/server';
|
|
122
|
+
|
|
123
|
+
export const dynamic = 'force-dynamic';
|
|
124
|
+
|
|
125
|
+
const handler = createInboxHandler({
|
|
126
|
+
// Who is signed in, from this app's own session. null means nobody.
|
|
127
|
+
resolvePerson: async () => {
|
|
128
|
+
const supabase = await createClient();
|
|
129
|
+
const { data } = await supabase.auth.getUser();
|
|
130
|
+
return data.user?.email ? personIdOf(data.user.email) : null;
|
|
131
|
+
},
|
|
132
|
+
// The browser is never told why something failed. This is where you see it.
|
|
133
|
+
onError: (error) => console.error('[communication-inbox]', error),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
export { handler as GET, handler as POST, handler as PUT };
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
- `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.
|
|
140
|
+
- In an app with locale routes (`app/[lang]/...`), the route still lives at `app/api/...`, not under `[lang]`.
|
|
141
|
+
- **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`.
|
|
142
|
+
- The handler reads the key and the address on the first request. A missing one answers `503 unavailable` and never stops the build.
|
|
143
|
+
|
|
144
|
+
## Step 6: The bell
|
|
145
|
+
|
|
146
|
+
Import the stylesheet once, in the root layout, **after** Mantine's:
|
|
147
|
+
|
|
148
|
+
```tsx
|
|
149
|
+
// app/layout.tsx
|
|
150
|
+
import '@mantine/core/styles.css';
|
|
151
|
+
import '@xtremax/pigeon/inbox-react/styles.css';
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Put the bell in the header that signed-in people see. In a Buildpad starter that is `<AppShell.Header>` in `components/layout/AuthenticatedShell.tsx`:
|
|
155
|
+
|
|
156
|
+
```tsx
|
|
157
|
+
import { InboxProvider, NotificationBell } from '@xtremax/pigeon/inbox-react';
|
|
158
|
+
|
|
159
|
+
<InboxProvider>
|
|
160
|
+
<NotificationBell />
|
|
161
|
+
</InboxProvider>
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
- It must render inside `MantineProvider`, and only for signed-in people.
|
|
165
|
+
- It follows the app's Mantine theme and colour scheme. Do not restyle it.
|
|
166
|
+
- 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`.
|
|
167
|
+
- 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.
|
|
168
|
+
|
|
169
|
+
## Step 7: Send notifications
|
|
170
|
+
|
|
171
|
+
One module makes the client, lazily, so a missing key cannot break the build:
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
// lib/communication/client.ts
|
|
175
|
+
import { createCommunicationClient, type CommunicationClient } from '@xtremax/pigeon/client';
|
|
176
|
+
|
|
177
|
+
let client: CommunicationClient | undefined;
|
|
178
|
+
/** Server only. Reads COMMUNICATION_API_KEY and COMMUNICATION_URL. */
|
|
179
|
+
export const communication = (): CommunicationClient => (client ??= createCommunicationClient());
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
One function sends, and never throws:
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
// lib/communication/notify.ts
|
|
186
|
+
import { deriveIdempotencyKey } from '@xtremax/pigeon';
|
|
187
|
+
import { CommunicationError } from '@xtremax/pigeon/client';
|
|
188
|
+
import { communication } from './client';
|
|
189
|
+
import { personIdOf } from './person';
|
|
190
|
+
|
|
191
|
+
/** The in-app channel's key, as `channels.list()` named it in Step 3. */
|
|
192
|
+
const IN_APP_CHANNEL = 'in-app';
|
|
193
|
+
|
|
194
|
+
export interface Notification {
|
|
195
|
+
/** The recipient's email. */
|
|
196
|
+
to: string;
|
|
197
|
+
/** Plain text, at most 120 characters. */
|
|
198
|
+
title: string;
|
|
199
|
+
/** Plain text, at most 1800 characters. */
|
|
200
|
+
body: string;
|
|
201
|
+
/** What makes this notification unique: the event, the record and the recipient. Sent twice with the same parts, it arrives once. */
|
|
202
|
+
dedupe: Array<string | number>;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Sends one in-app notification. Answers whether Pigeon accepted it. Never throws: a notification must not break the action that caused it. */
|
|
206
|
+
export async function notify({ to, title, body, dedupe }: Notification): Promise<boolean> {
|
|
207
|
+
try {
|
|
208
|
+
const personId = personIdOf(to);
|
|
209
|
+
const message = await communication().messages.send({
|
|
210
|
+
templateKey: 'generic.notification',
|
|
211
|
+
channel: IN_APP_CHANNEL,
|
|
212
|
+
recipient: { personId },
|
|
213
|
+
variables: { title: title.slice(0, 120), body: body.slice(0, 1800) },
|
|
214
|
+
idempotencyKey: await deriveIdempotencyKey(...dedupe, personId),
|
|
215
|
+
});
|
|
216
|
+
// `failed` here is usually the person's own choice: they switched this app's notifications off.
|
|
217
|
+
return message.state !== 'failed';
|
|
218
|
+
} catch (error) {
|
|
219
|
+
console.error('[communication] notification not sent:', error instanceof CommunicationError ? error.code : error);
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Call it from server code only (an API route or a server action), after the action itself has succeeded:
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
await notify({
|
|
229
|
+
to: member.email,
|
|
230
|
+
title: 'You were added to Finance',
|
|
231
|
+
body: 'Open Organisations to see your unit.',
|
|
232
|
+
dedupe: ['unit-member-added', unit.id],
|
|
233
|
+
});
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
What to know when choosing what to send:
|
|
237
|
+
|
|
238
|
+
- **Ask the user which events should notify whom** if they have not said. Do not notify on everything.
|
|
239
|
+
- **Do not notify the person who did the action** about their own action.
|
|
240
|
+
- **`generic.notification`** is a template every tenant has: a title and a body, plain text. Markup is shown as text.
|
|
241
|
+
- **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.
|
|
242
|
+
- **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.
|
|
243
|
+
- **`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.
|
|
244
|
+
|
|
245
|
+
## Step 8: Tests
|
|
246
|
+
|
|
247
|
+
Add these. They are quick, and the first group needs no key.
|
|
248
|
+
|
|
249
|
+
**Playwright, signed out** (the route protects itself):
|
|
250
|
+
|
|
251
|
+
```ts
|
|
252
|
+
test('the inbox route refuses a signed-out caller and a foreign origin', async ({ request }) => {
|
|
253
|
+
const signedOut = await request.get('/api/communication/inbox/summary');
|
|
254
|
+
expect(signedOut.status()).toBe(401);
|
|
255
|
+
expect((await signedOut.json()).error.code).toBe('not_signed_in');
|
|
256
|
+
|
|
257
|
+
const foreign = await request.post('/api/communication/inbox/read-all', { headers: { origin: 'https://evil.example' }, data: {} });
|
|
258
|
+
expect(foreign.status()).toBe(403);
|
|
259
|
+
|
|
260
|
+
expect((await request.get('/api/communication/inbox/everyone')).status()).toBe(404);
|
|
261
|
+
});
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
**Playwright, signed in** (with the app's existing test sign-in): the bell is in the header and opens.
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
const bell = page.getByRole('button', { name: /^Notifications/ });
|
|
268
|
+
await expect(bell).toBeVisible();
|
|
269
|
+
await bell.click();
|
|
270
|
+
await expect(page.getByRole('dialog').getByRole('heading', { name: 'Notifications' })).toBeVisible();
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
**Vitest:** `personIdOf(' Ana@Acme.com ')` is `'ana@acme.com'`; and `notify` answers `false` without throwing when the client throws (mock `./client`).
|
|
274
|
+
|
|
275
|
+
## Step 9: Verify
|
|
276
|
+
|
|
277
|
+
- [ ] `pnpm build` and the type check pass.
|
|
278
|
+
- [ ] Signed out, `GET /api/communication/inbox/summary` answers `401` with `{"error":{"code":"not_signed_in",...}}`, as JSON and not as a redirect.
|
|
279
|
+
- [ ] Signed in, the same call answers `200` with `unreadCount`, and the bell shows in the header.
|
|
280
|
+
- [ ] 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.
|
|
281
|
+
- [ ] `grep -rn "COMMUNICATION_API_KEY" app components lib` shows no file with `'use client'` and no `NEXT_PUBLIC_` name.
|
|
282
|
+
- [ ] No custom bell, notifications collection or polling code was added.
|
|
283
|
+
- [ ] The app's docs say where notifications are sent from and which two variables a deployment needs.
|
|
284
|
+
|
|
285
|
+
## When it does not work
|
|
286
|
+
|
|
287
|
+
| What you see | Cause |
|
|
288
|
+
|---|---|
|
|
289
|
+
| `pnpm add` answers 404 | The package is not published, or the registry is not npm's. Stop and tell the user. |
|
|
290
|
+
| 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. |
|
|
291
|
+
| The bell's calls answer `401 not_signed_in` while signed in | `resolvePerson` returned `null`: the session is not read in the route. |
|
|
292
|
+
| 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`. |
|
|
293
|
+
| 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. |
|
|
294
|
+
| A send throws `forbidden` | The key lacks a scope. Ask for a new key. |
|
|
295
|
+
| 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. |
|
|
296
|
+
| `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. |
|
|
297
|
+
| The list inside the bell looks unstyled | The stylesheet import of Step 6 is missing. |
|
|
298
|
+
| An error says `MantineProvider` was not found | The bell is rendered outside `MantineProvider`. |
|
|
299
|
+
|
|
300
|
+
## Reference
|
|
301
|
+
|
|
302
|
+
`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,208 @@
|
|
|
1
|
+
# Pigeon inbox: the full contract
|
|
2
|
+
|
|
3
|
+
Read this when the steps in `SKILL.md` are not enough: translating the bell, placing the full list on a page of its own, reading the inbox from server code, or building a bell without React and Mantine.
|
|
4
|
+
|
|
5
|
+
## Contents
|
|
6
|
+
|
|
7
|
+
- [The package's entries](#the-packages-entries)
|
|
8
|
+
- [`InboxProvider`, `NotificationBell`, `InboxFullList`](#inboxprovider-notificationbell-inboxfulllist)
|
|
9
|
+
- [Labels](#labels)
|
|
10
|
+
- [`createInboxHandler`](#createinboxhandler)
|
|
11
|
+
- [The six operations of the route](#the-six-operations-of-the-route)
|
|
12
|
+
- [Error codes](#error-codes)
|
|
13
|
+
- [How the bell behaves](#how-the-bell-behaves)
|
|
14
|
+
- [Reading the inbox from server code](#reading-the-inbox-from-server-code)
|
|
15
|
+
- [An app without React or Mantine](#an-app-without-react-or-mantine)
|
|
16
|
+
|
|
17
|
+
## The package's entries
|
|
18
|
+
|
|
19
|
+
| Import | Runs | Holds |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| `@xtremax/pigeon/client` | server | `createCommunicationClient`, `CommunicationError` |
|
|
22
|
+
| `@xtremax/pigeon/inbox` | server | `createInboxHandler`, `createInboxClient` |
|
|
23
|
+
| `@xtremax/pigeon/inbox-react` | browser | `InboxProvider`, `NotificationBell`, `InboxFullList`, `DEFAULT_INBOX_LABELS` |
|
|
24
|
+
| `@xtremax/pigeon/inbox-react/styles.css` | browser | the bell's stylesheet |
|
|
25
|
+
| `@xtremax/pigeon/events` | server | `createEventHandler`, `verifyEventSignature` (delivery events; not needed for the bell) |
|
|
26
|
+
| `@xtremax/pigeon` | server | all server entries, plus `deriveIdempotencyKey`, `newIdempotencyKey` |
|
|
27
|
+
|
|
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.
|
|
29
|
+
|
|
30
|
+
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
|
+
|
|
32
|
+
## `InboxProvider`, `NotificationBell`, `InboxFullList`
|
|
33
|
+
|
|
34
|
+
`InboxProvider` props, all optional:
|
|
35
|
+
|
|
36
|
+
| Prop | Default | Use |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| `apiBase` | `/api/communication/inbox` | Where the app mounted the route. A path on the app's own origin only: an absolute URL is refused. |
|
|
39
|
+
| `labels` | English | Replaces any visible or spoken text. See [Labels](#labels). |
|
|
40
|
+
| `locale` | the browser's | Formats times, for example `'id'` or `'en-GB'`. |
|
|
41
|
+
| `onViewAll` | opens the full list in a dialog | Call your router to open a page of your own instead. |
|
|
42
|
+
| `onOpenPreferences` | opens the preference dialog | Open a settings page of your own instead. |
|
|
43
|
+
| `openLink` | a new tab, `noopener,noreferrer` | Open a notification's link your own way, for example in the same tab. |
|
|
44
|
+
|
|
45
|
+
`labels` is a plain object, so it can be passed from a server component. The three callbacks are functions: to use one, render `InboxProvider` from a client component (`'use client'`).
|
|
46
|
+
|
|
47
|
+
`NotificationBell` takes `size` (pixels, never under 44). It must be inside `InboxProvider`.
|
|
48
|
+
|
|
49
|
+
`InboxFullList` is the full, paged list. Use it with `onViewAll` to give notifications a page of their own. It needs an `InboxProvider` above it, so put the provider around the whole signed-in layout: the header's bell and the page's list then share one, and the count is asked for once.
|
|
50
|
+
|
|
51
|
+
```tsx
|
|
52
|
+
'use client';
|
|
53
|
+
import { InboxFullList, InboxProvider, NotificationBell } from '@xtremax/pigeon/inbox-react';
|
|
54
|
+
import { useRouter } from 'next/navigation';
|
|
55
|
+
|
|
56
|
+
// around the signed-in layout: the header with the bell, and the pages
|
|
57
|
+
const router = useRouter();
|
|
58
|
+
<InboxProvider onViewAll={() => router.push('/notifications')}>
|
|
59
|
+
<header><NotificationBell /></header>
|
|
60
|
+
{children}
|
|
61
|
+
</InboxProvider>
|
|
62
|
+
|
|
63
|
+
// on app/(authenticated)/notifications/page.tsx
|
|
64
|
+
<InboxFullList maxHeight="70vh" />
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Labels
|
|
68
|
+
|
|
69
|
+
Pass any of these in `labels`. `{n}` and `{label}` are filled in.
|
|
70
|
+
|
|
71
|
+
| Key | English default |
|
|
72
|
+
|---|---|
|
|
73
|
+
| `bell` | Notifications |
|
|
74
|
+
| `bellWithCount` | {label}, {n} unread |
|
|
75
|
+
| `heading` | Notifications |
|
|
76
|
+
| `tabAll` | All |
|
|
77
|
+
| `tabUnread` | Unread |
|
|
78
|
+
| `tabUnreadWithCount` | Unread ({n}) |
|
|
79
|
+
| `markAllRead` | Mark all as read |
|
|
80
|
+
| `viewAll` | View all |
|
|
81
|
+
| `preferences` | Notification preferences |
|
|
82
|
+
| `emptyAll` | You have no notifications. |
|
|
83
|
+
| `emptyUnread` | You have no unread notifications. |
|
|
84
|
+
| `loading` | Loading notifications |
|
|
85
|
+
| `loadError` | Notifications could not be loaded. |
|
|
86
|
+
| `retry` | Try again |
|
|
87
|
+
| `actionError` | That did not work. Try again. |
|
|
88
|
+
| `dismiss` | Dismiss |
|
|
89
|
+
| `close` | Close |
|
|
90
|
+
| `unread` | Unread |
|
|
91
|
+
| `announcement` | Announcement |
|
|
92
|
+
| `loadMore` | Show older notifications |
|
|
93
|
+
| `newOne` | 1 new notification |
|
|
94
|
+
| `newMany` | {n} new notifications |
|
|
95
|
+
| `fullListTitle` | All notifications |
|
|
96
|
+
| `preferencesTitle` | Notification preferences |
|
|
97
|
+
| `preferenceToggle` | Receive in-app notifications |
|
|
98
|
+
| `preferenceOn` | New notifications from this app appear here. |
|
|
99
|
+
| `preferenceOff` | New notifications from this app do not appear here. The ones you already have stay. Other apps are not affected. |
|
|
100
|
+
| `preferenceLoadError` | Your preference could not be loaded. |
|
|
101
|
+
| `preferenceSaveError` | Your choice was not saved. Try again. |
|
|
102
|
+
|
|
103
|
+
The titles and bodies of notifications are not labels: they are written by whoever sent them, in the language they chose.
|
|
104
|
+
|
|
105
|
+
## `createInboxHandler`
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
createInboxHandler(options): (request: Request) => Promise<Response>
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
| Option | Default | Use |
|
|
112
|
+
|---|---|---|
|
|
113
|
+
| `resolvePerson` (required) | | `(request) => string \| null`, sync or async. The signed-in person's id from the app's own session. `null` or an empty string means nobody. At most 128 characters. |
|
|
114
|
+
| `client` | made on the first request from `COMMUNICATION_API_KEY` and `COMMUNICATION_URL` | Pass `createInboxClient({ apiKey, baseUrl })` to use other settings. |
|
|
115
|
+
| `basePath` | `/api/communication/inbox` | Set it when the route file is somewhere else. It must match `apiBase` on `InboxProvider`. |
|
|
116
|
+
| `allowedOrigins` | the request's own origin | Origins that may send `POST` and `PUT`. Needed only when pages are served from another origin than the route. |
|
|
117
|
+
| `timeoutMs` | 8000 | Bounds each call to Pigeon. |
|
|
118
|
+
| `onError` | | Told about every failure answered as `unavailable`. Log it: the browser is never told the cause. |
|
|
119
|
+
|
|
120
|
+
The handler takes a standard `Request` and answers a standard `Response`, so it works in any server that speaks the Fetch API, not only Next.js. Export it for `GET`, `POST` and `PUT`.
|
|
121
|
+
|
|
122
|
+
## The six operations of the route
|
|
123
|
+
|
|
124
|
+
All paths are under the base path. Every answer is JSON with `Cache-Control: no-store`. A success is `{ "data": ... }`; a failure is `{ "error": { "code", "message", "field"? } }`.
|
|
125
|
+
|
|
126
|
+
| Method and path | Request | `data` in the answer |
|
|
127
|
+
|---|---|---|
|
|
128
|
+
| `GET /summary` | | `{ unreadCount, newestUnreadAt, pollSeconds }` |
|
|
129
|
+
| `GET /notifications` | query `limit` (1 to 100, default 20), `cursor`, `unread=true` | `{ notifications: [...], nextCursor, unreadCount }` |
|
|
130
|
+
| `POST /notifications/{id}/read` | empty JSON body | `{ notification, unreadCount }` |
|
|
131
|
+
| `POST /read-all` | `{ "upTo"?: ISO time }` | `{ marked, remaining, unreadCount }`; call again while `remaining > 0` |
|
|
132
|
+
| `GET /preference` | | `{ state, changedAt, delivers }` |
|
|
133
|
+
| `PUT /preference` | `{ "state": "opted_in" \| "opted_out" }` | `{ state, changedAt, delivers }` |
|
|
134
|
+
|
|
135
|
+
A notification:
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
interface InboxNotification {
|
|
139
|
+
id: string;
|
|
140
|
+
kind: 'notification' | 'announcement';
|
|
141
|
+
title: string; // plain text; never render it as markup
|
|
142
|
+
body: string; // plain text
|
|
143
|
+
link: string | null; // absolute https, or null
|
|
144
|
+
source: { type: 'xapp' | 'tenant'; name: string };
|
|
145
|
+
createdAt: string; // ISO time
|
|
146
|
+
read: boolean;
|
|
147
|
+
readAt: string | null;
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
- `nextCursor` is `null` on the last page. Pass it back as `cursor` unchanged.
|
|
152
|
+
- `state` is `opted_in`, `opted_out` or `not_set`; `delivers` says whether new notifications reach the person now.
|
|
153
|
+
- `POST` and `PUT` must come from the app's own pages: the handler checks `Sec-Fetch-Site: same-origin` or the `Origin` header.
|
|
154
|
+
|
|
155
|
+
## Error codes
|
|
156
|
+
|
|
157
|
+
| Code | Status | Meaning |
|
|
158
|
+
|---|---|---|
|
|
159
|
+
| `not_signed_in` | 401 | `resolvePerson` returned nobody. The bell stops asking, silently. |
|
|
160
|
+
| `not_permitted` | 403 | A `POST` or `PUT` from another origin, or Pigeon refused the key for this call. |
|
|
161
|
+
| `not_found` | 404 | No such operation, or a notification that is not this person's or not this app's. |
|
|
162
|
+
| `invalid_input` | 422 | A value from the browser was refused; `field` names it. |
|
|
163
|
+
| `rate_limited` | 429 | Too many calls. |
|
|
164
|
+
| `unavailable` | 503 | Everything else: a wrong or revoked key, a timeout, Pigeon not reachable. The cause goes to `onError` only. |
|
|
165
|
+
|
|
166
|
+
Errors thrown by `createCommunicationClient` and `createInboxClient` are `CommunicationError` with `code`, `status`, `requestId` and `retryable`. Codes you may meet when sending: `channel_unavailable`, `validation_failed` (a missing or too long variable, an unknown template), `forbidden`, `invalid_credentials`, `rate_limited`, `idempotency_conflict`, `not_configured`, `timeout`, `network_error`.
|
|
167
|
+
|
|
168
|
+
## How the bell behaves
|
|
169
|
+
|
|
170
|
+
- **Updates.** It asks for the count every 60 seconds (Pigeon's setting, never under 15). It pauses while the page is hidden and asks at once when it is visible again. After failures it waits longer, up to 5 minutes. There is no realtime push.
|
|
171
|
+
- **Outage.** It keeps the last count and the page keeps working. A list that cannot load shows "Try again".
|
|
172
|
+
- **Selecting a notification** marks it read, and opens its link when it has an `https` one.
|
|
173
|
+
- **"Mark all as read"** covers what this bell shows, never another app's notifications.
|
|
174
|
+
- **Announcements** are one copy per person: read in one app's bell, they are read in every bell.
|
|
175
|
+
- **The preference** is this app's own. Switching it off stops this app's notifications for the person; other apps and announcements go on.
|
|
176
|
+
- **Nothing is kept in the browser's storage.**
|
|
177
|
+
|
|
178
|
+
## Reading the inbox from server code
|
|
179
|
+
|
|
180
|
+
For a count in a server-rendered page, a digest email or a test. Each call takes the person id first.
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
import { createInboxClient } from '@xtremax/pigeon/inbox';
|
|
184
|
+
import { communication } from '@/lib/communication/client';
|
|
185
|
+
|
|
186
|
+
const inbox = createInboxClient(communication()); // shares the key and settings
|
|
187
|
+
|
|
188
|
+
const { unreadCount } = await inbox.summary(personId);
|
|
189
|
+
const page = await inbox.list(personId, { limit: 20, unreadOnly: true });
|
|
190
|
+
await inbox.markRead(personId, page.notifications[0].id);
|
|
191
|
+
await inbox.markAllRead(personId);
|
|
192
|
+
const preference = await inbox.getPreference(personId);
|
|
193
|
+
await inbox.setPreference(personId, 'opted_out');
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
These throw `CommunicationError` on failure. The person id here is trusted, so it must come from your own session or data, never from a request.
|
|
197
|
+
|
|
198
|
+
## An app without React or Mantine
|
|
199
|
+
|
|
200
|
+
The route is the same: mount `createInboxHandler` on a catch-all route of the app's server. Then build the bell with the app's own components against the six operations above:
|
|
201
|
+
|
|
202
|
+
1. Ask `GET /summary` when the page loads, then every `pollSeconds`. Stop on `401`. Wait longer after a failure.
|
|
203
|
+
2. Show `unreadCount` on the bell (`99+` above 99).
|
|
204
|
+
3. On open, `GET /notifications`. Show `title` and `body` as text. Offer `nextCursor` as "show older".
|
|
205
|
+
4. On select, `POST /notifications/{id}/read`, and open `link` in a new tab with `noopener,noreferrer` only when it starts with `https://`.
|
|
206
|
+
5. Offer "mark all as read" (`POST /read-all`, repeated while `remaining > 0`) and the on/off choice (`GET` and `PUT /preference`).
|
|
207
|
+
|
|
208
|
+
Send requests with `credentials: 'same-origin'`. Never call Pigeon from the browser and never put the key in the page.
|