@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.
- package/CHANGELOG.md +8 -0
- package/README.md +28 -1
- package/dist/cjs/client.js +3 -0
- package/dist/cjs/inbox/client.js +4 -9
- package/dist/cjs/person.js +10 -0
- package/dist/cjs/preferences/browser-stub.js +10 -0
- package/dist/cjs/preferences/client.js +21 -0
- package/dist/cjs/preferences/handler.js +96 -0
- package/dist/cjs/preferences/index.js +8 -0
- package/dist/cjs/preferences-react/index.js +18 -0
- package/dist/cjs/preferences-react/labels.js +32 -0
- package/dist/cjs/preferences-react/notification-preferences.js +76 -0
- package/dist/cjs/preferences-react/preferences-client.js +72 -0
- package/dist/esm/client.js +3 -0
- package/dist/esm/inbox/client.js +2 -7
- package/dist/esm/person.js +7 -0
- package/dist/esm/preferences/browser-stub.js +7 -0
- package/dist/esm/preferences/client.js +18 -0
- package/dist/esm/preferences/handler.js +92 -0
- package/dist/esm/preferences/index.js +2 -0
- package/dist/esm/preferences-react/index.js +13 -0
- package/dist/esm/preferences-react/labels.js +28 -0
- package/dist/esm/preferences-react/notification-preferences.js +73 -0
- package/dist/esm/preferences-react/preferences-client.js +66 -0
- package/dist/types/client.d.ts +5 -10
- package/dist/types/contract.d.ts +27 -0
- package/dist/types/inbox/client.d.ts +2 -2
- package/dist/types/person.d.ts +2 -0
- package/dist/types/preferences/browser-stub.d.ts +3 -0
- package/dist/types/preferences/client.d.ts +18 -0
- package/dist/types/preferences/handler.d.ts +32 -0
- package/dist/types/preferences/index.d.ts +3 -0
- package/dist/types/preferences-react/index.d.ts +14 -0
- package/dist/types/preferences-react/labels.d.ts +32 -0
- package/dist/types/preferences-react/notification-preferences.d.ts +18 -0
- package/dist/types/preferences-react/preferences-client.d.ts +22 -0
- package/package.json +4 -2
- package/skills/add-pigeon-notifications/SKILL.md +49 -14
- package/skills/add-pigeon-notifications/references/consent.instructions.md +263 -0
- package/skills/add-pigeon-notifications/references/inbox-contract.instructions.md +3 -1
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sentences say what each switch covers, and Pigeon says which applies (`covers` in its answer): a choice that
|
|
3
|
+
* holds in every app of the organisation, or in this app only. Keep that when you translate.
|
|
4
|
+
*/
|
|
5
|
+
export const DEFAULT_PREFERENCE_LABELS = {
|
|
6
|
+
group: 'Notification preferences',
|
|
7
|
+
email: 'Email',
|
|
8
|
+
emailOn: 'You receive email from every app of your organisation.',
|
|
9
|
+
emailOff: 'You receive no email from any app of your organisation, except messages you must get, such as receipts and security alerts.',
|
|
10
|
+
emailOnApp: 'You receive email from this app.',
|
|
11
|
+
emailOffApp: 'You receive no email from this app, except messages you must get, such as receipts and security alerts. Other apps are not affected.',
|
|
12
|
+
whatsapp: 'WhatsApp',
|
|
13
|
+
whatsappOn: 'You receive WhatsApp messages from every app of your organisation.',
|
|
14
|
+
whatsappOff: 'You receive no WhatsApp messages from any app of your organisation.',
|
|
15
|
+
whatsappOnApp: 'You receive WhatsApp messages from this app.',
|
|
16
|
+
whatsappOffApp: 'You receive no WhatsApp messages from this app. Other apps are not affected.',
|
|
17
|
+
inApp: 'Notifications in this app',
|
|
18
|
+
inAppOn: 'New notifications from this app appear under the bell.',
|
|
19
|
+
inAppOff: 'New notifications from this app do not appear. The ones you already have stay. Other apps are not affected.',
|
|
20
|
+
loading: 'Loading your notification preferences',
|
|
21
|
+
loadError: 'Your notification preferences could not be loaded.',
|
|
22
|
+
retry: 'Try again',
|
|
23
|
+
saveError: 'Your choice was not saved. Try again.',
|
|
24
|
+
none: 'There is nothing to choose here yet.',
|
|
25
|
+
};
|
|
26
|
+
export function mergePreferenceLabels(labels) {
|
|
27
|
+
return { ...DEFAULT_PREFERENCE_LABELS, ...(labels ?? {}) };
|
|
28
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
4
|
+
import { Alert, Button, Center, Loader, Stack, Switch, Text } from '@mantine/core';
|
|
5
|
+
import { mergePreferenceLabels } from './labels.js';
|
|
6
|
+
import { createPreferencesBrowserClient } from './preferences-client.js';
|
|
7
|
+
const words = (labels, choice) => {
|
|
8
|
+
// Pigeon says what a choice covers, so the sentence stays true if that changes on Pigeon's side.
|
|
9
|
+
const own = choice.covers === 'app';
|
|
10
|
+
if (choice.medium === 'email')
|
|
11
|
+
return { label: labels.email, description: choice.delivers ? (own ? labels.emailOnApp : labels.emailOn) : own ? labels.emailOffApp : labels.emailOff };
|
|
12
|
+
if (choice.medium === 'whatsapp')
|
|
13
|
+
return { label: labels.whatsapp, description: choice.delivers ? (own ? labels.whatsappOnApp : labels.whatsappOn) : own ? labels.whatsappOffApp : labels.whatsappOff };
|
|
14
|
+
return { label: labels.inApp, description: choice.delivers ? labels.inAppOn : labels.inAppOff };
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* A person's notification choices: one switch per medium, with a sentence that says what the switch covers.
|
|
18
|
+
* It shows what is recorded: a switch moves when the choice is saved, not before. Place it on a settings page,
|
|
19
|
+
* inside `MantineProvider`. It calls the host app's own route, which `createPreferencesHandler` serves.
|
|
20
|
+
*/
|
|
21
|
+
export function NotificationPreferences({ apiBase, labels, mediums, onChange }) {
|
|
22
|
+
const client = useMemo(() => createPreferencesBrowserClient(apiBase), [apiBase]);
|
|
23
|
+
const merged = useMemo(() => mergePreferenceLabels(labels), [labels]);
|
|
24
|
+
const [choices, setChoices] = useState(null);
|
|
25
|
+
const [status, setStatus] = useState('loading');
|
|
26
|
+
const [saving, setSaving] = useState(null);
|
|
27
|
+
const [saveFailed, setSaveFailed] = useState(false);
|
|
28
|
+
const load = useCallback(async () => {
|
|
29
|
+
setStatus('loading');
|
|
30
|
+
const res = await client.get();
|
|
31
|
+
if (res.ok)
|
|
32
|
+
setChoices(res.choices);
|
|
33
|
+
setStatus(res.ok ? 'ready' : 'error');
|
|
34
|
+
}, [client]);
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
void load();
|
|
37
|
+
}, [load]);
|
|
38
|
+
const change = async (medium, on) => {
|
|
39
|
+
if (saving)
|
|
40
|
+
return;
|
|
41
|
+
setSaving(medium);
|
|
42
|
+
setSaveFailed(false);
|
|
43
|
+
const res = await client.set(medium, on);
|
|
44
|
+
setSaving(null);
|
|
45
|
+
if (!res.ok) {
|
|
46
|
+
setSaveFailed(true);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
setChoices(res.choices);
|
|
50
|
+
const saved = res.choices.find((choice) => choice.medium === medium);
|
|
51
|
+
// A choice that was recorded and still does not hold (for example a suspended person) is said, not hidden.
|
|
52
|
+
if (!saved || saved.delivers !== on)
|
|
53
|
+
setSaveFailed(true);
|
|
54
|
+
else
|
|
55
|
+
onChange?.(saved);
|
|
56
|
+
};
|
|
57
|
+
if (status === 'loading' && !choices) {
|
|
58
|
+
return (_jsx(Center, { mih: 96, role: "status", "aria-label": merged.loading, children: _jsx(Loader, { size: "sm" }) }));
|
|
59
|
+
}
|
|
60
|
+
if (!choices) {
|
|
61
|
+
return (_jsx(Alert, { color: "red", variant: "light", title: merged.loadError, children: _jsx(Button, { variant: "default", size: "sm", mih: 44, onClick: () => void load(), children: merged.retry }) }));
|
|
62
|
+
}
|
|
63
|
+
const shown = mediums
|
|
64
|
+
? mediums.flatMap((medium) => choices.filter((choice) => choice.medium === medium))
|
|
65
|
+
: choices.filter((choice) => choice.available);
|
|
66
|
+
if (shown.length === 0) {
|
|
67
|
+
return (_jsx(Text, { c: "dimmed", size: "sm", children: merged.none }));
|
|
68
|
+
}
|
|
69
|
+
return (_jsxs(Stack, { gap: "md", role: "group", "aria-label": merged.group, children: [shown.map((choice) => {
|
|
70
|
+
const { label, description } = words(merged, choice);
|
|
71
|
+
return (_jsx(Switch, { label: label, description: description, checked: choice.delivers, disabled: saving !== null, onChange: (event) => void change(choice.medium, event.currentTarget.checked) }, choice.medium));
|
|
72
|
+
}), saveFailed && (_jsx(Alert, { color: "red", variant: "light", role: "alert", children: merged.saveError }))] }));
|
|
73
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { normalizeApiBase } from '../inbox-react/inbox-client.js';
|
|
2
|
+
export const DEFAULT_PREFERENCES_API_BASE = '/api/communication/preferences';
|
|
3
|
+
export const PREFERENCE_MEDIUMS = ['email', 'whatsapp', 'in_app'];
|
|
4
|
+
/** A choice as the host route answers it. Anything else is refused, so a wrong answer never becomes a switch. */
|
|
5
|
+
export function parseChoice(v) {
|
|
6
|
+
if (typeof v !== 'object' || v === null)
|
|
7
|
+
return null;
|
|
8
|
+
const o = v;
|
|
9
|
+
const medium = PREFERENCE_MEDIUMS.find((m) => m === o.medium);
|
|
10
|
+
if (!medium)
|
|
11
|
+
return null;
|
|
12
|
+
if (o.state !== 'opted_in' && o.state !== 'opted_out' && o.state !== 'not_set')
|
|
13
|
+
return null;
|
|
14
|
+
if (o.changedAt !== null && typeof o.changedAt !== 'string')
|
|
15
|
+
return null;
|
|
16
|
+
if (typeof o.delivers !== 'boolean' || typeof o.available !== 'boolean')
|
|
17
|
+
return null;
|
|
18
|
+
if (o.covers !== 'tenant' && o.covers !== 'app')
|
|
19
|
+
return null;
|
|
20
|
+
return { medium, state: o.state, changedAt: o.changedAt, delivers: o.delivers, covers: o.covers, available: o.available };
|
|
21
|
+
}
|
|
22
|
+
export function parseChoices(v) {
|
|
23
|
+
const list = v?.preferences;
|
|
24
|
+
if (!Array.isArray(list))
|
|
25
|
+
return null;
|
|
26
|
+
const choices = [];
|
|
27
|
+
for (const raw of list) {
|
|
28
|
+
const choice = parseChoice(raw);
|
|
29
|
+
if (!choice)
|
|
30
|
+
return null;
|
|
31
|
+
choices.push(choice);
|
|
32
|
+
}
|
|
33
|
+
return choices;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The browser talks only to the host app's own route. Every call answers a result and never throws, so an outage
|
|
37
|
+
* cannot break the host page.
|
|
38
|
+
*/
|
|
39
|
+
export function createPreferencesBrowserClient(apiBase, fetchImpl) {
|
|
40
|
+
const base = normalizeApiBase(apiBase ?? DEFAULT_PREFERENCES_API_BASE);
|
|
41
|
+
async function call(method, body) {
|
|
42
|
+
if (base === null)
|
|
43
|
+
return { ok: false, code: 'unavailable' };
|
|
44
|
+
try {
|
|
45
|
+
const res = await (fetchImpl ?? fetch)(base, {
|
|
46
|
+
method,
|
|
47
|
+
credentials: 'same-origin',
|
|
48
|
+
cache: 'no-store',
|
|
49
|
+
headers: { Accept: 'application/json', ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}) },
|
|
50
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
51
|
+
});
|
|
52
|
+
if (res.status === 401)
|
|
53
|
+
return { ok: false, code: 'not_signed_in' };
|
|
54
|
+
const json = await res.json().catch(() => null);
|
|
55
|
+
const choices = res.ok ? parseChoices(json?.data) : null;
|
|
56
|
+
return choices ? { ok: true, choices } : { ok: false, code: 'unavailable' };
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return { ok: false, code: 'unavailable' };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
get: () => call('GET'),
|
|
64
|
+
set: (medium, on) => call('PUT', { medium, state: on ? 'opted_in' : 'opted_out' }),
|
|
65
|
+
};
|
|
66
|
+
}
|
package/dist/types/client.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AcceptedMessage, ChannelInfo, MessageView, PingInfo, RecipientView, SendMessageRequest } from './contract.js';
|
|
1
|
+
import type { AcceptedMessage, ChannelInfo, MessageView, PingInfo, PreferenceChoice, PreferenceEvidence, PreferenceRecorded, RecipientView, SendMessageRequest } from './contract.js';
|
|
2
2
|
import { type RetryPolicy } from './retry.js';
|
|
3
3
|
export interface ClientOptions {
|
|
4
4
|
/** Defaults to `process.env.COMMUNICATION_API_KEY`. */
|
|
@@ -56,6 +56,8 @@ export declare function createCommunicationClient(options?: ClientOptions): {
|
|
|
56
56
|
deleteAddress(personId: string, addressId: string): Promise<void>;
|
|
57
57
|
};
|
|
58
58
|
preferences: {
|
|
59
|
+
/** A person's choice for each medium, and whether a message reaches them now. Someone Pigeon has not met has made no choice. */
|
|
60
|
+
get: (personId: string) => Promise<PreferenceChoice[]>;
|
|
59
61
|
record: (body: {
|
|
60
62
|
recipient: {
|
|
61
63
|
personId?: string;
|
|
@@ -65,15 +67,8 @@ export declare function createCommunicationClient(options?: ClientOptions): {
|
|
|
65
67
|
medium: "email" | "whatsapp" | "in_app";
|
|
66
68
|
state: "opted_in" | "opted_out";
|
|
67
69
|
expressedAt?: string;
|
|
68
|
-
evidence?:
|
|
69
|
-
|
|
70
|
-
wordingVersion?: string;
|
|
71
|
-
proofRef?: string;
|
|
72
|
-
};
|
|
73
|
-
}) => Promise<{
|
|
74
|
-
changed: boolean;
|
|
75
|
-
current: unknown;
|
|
76
|
-
}>;
|
|
70
|
+
evidence?: PreferenceEvidence;
|
|
71
|
+
}) => Promise<PreferenceRecorded>;
|
|
77
72
|
};
|
|
78
73
|
messageClasses: {
|
|
79
74
|
put: (templateKey: string, body: {
|
package/dist/types/contract.d.ts
CHANGED
|
@@ -130,6 +130,33 @@ export interface RecipientView {
|
|
|
130
130
|
source: string | null;
|
|
131
131
|
}>;
|
|
132
132
|
}
|
|
133
|
+
export type PreferenceMedium = 'email' | 'whatsapp' | 'in_app';
|
|
134
|
+
export interface PreferenceChoice {
|
|
135
|
+
medium: PreferenceMedium;
|
|
136
|
+
state: 'opted_in' | 'opted_out' | 'not_set';
|
|
137
|
+
changedAt: string | null;
|
|
138
|
+
/** Whether a message on this medium reaches the person now: the choice, the tenant's consent mode and the person's status together. */
|
|
139
|
+
delivers: boolean;
|
|
140
|
+
/** `tenant`: the choice holds in every application of the tenant. `app`: it holds for the calling application only. */
|
|
141
|
+
covers: 'tenant' | 'app';
|
|
142
|
+
/** Whether the calling key has a ready channel of this medium. */
|
|
143
|
+
available: boolean;
|
|
144
|
+
}
|
|
145
|
+
/** How a choice was made: where, which wording the person saw, and your own record of it. */
|
|
146
|
+
export interface PreferenceEvidence {
|
|
147
|
+
method?: string;
|
|
148
|
+
wordingVersion?: string;
|
|
149
|
+
proofRef?: string;
|
|
150
|
+
}
|
|
151
|
+
export interface PreferenceRecorded {
|
|
152
|
+
changed: boolean;
|
|
153
|
+
current: {
|
|
154
|
+
medium: string;
|
|
155
|
+
state: 'opted_in' | 'opted_out' | 'not_set';
|
|
156
|
+
changedAt: string | null;
|
|
157
|
+
source: string | null;
|
|
158
|
+
};
|
|
159
|
+
}
|
|
133
160
|
export interface InboxNotification {
|
|
134
161
|
/** The inbox item, not the message. */
|
|
135
162
|
id: string;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type ClientOptions, type CommunicationClient } from '../client.js';
|
|
2
|
+
import { personHeader } from '../person.js';
|
|
2
3
|
import type { InboxMarkReadResult, InboxNotificationPage, InboxPreference, InboxReadAllResult, InboxSummary } from '../contract.js';
|
|
3
4
|
export interface CallOptions {
|
|
4
5
|
/** Reads are retried by the SDK's policy unless this is `false`. Writes make one attempt unless this is `true`. */
|
|
@@ -25,10 +26,9 @@ export interface InboxClient {
|
|
|
25
26
|
getPreference(personId: string, options?: CallOptions): Promise<InboxPreference>;
|
|
26
27
|
setPreference(personId: string, state: 'opted_in' | 'opted_out', options?: CallOptions): Promise<InboxPreference>;
|
|
27
28
|
}
|
|
28
|
-
/** `X-Communication-Person` is base64url of the UTF-8 person id, so any identifier travels safely in a header. */
|
|
29
|
-
export declare function personHeader(personId: string): string;
|
|
30
29
|
/**
|
|
31
30
|
* The in-app inbox of one person. Pass an existing client to share its key and settings, or the same options as
|
|
32
31
|
* `createCommunicationClient`. The key needs the scopes `inbox:read` and `inbox:write`.
|
|
33
32
|
*/
|
|
34
33
|
export declare function createInboxClient(client?: ClientOptions | CommunicationClient): InboxClient;
|
|
34
|
+
export { personHeader };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type ClientOptions, type CommunicationClient } from '../client.js';
|
|
2
|
+
import type { PreferenceChoice, PreferenceEvidence, PreferenceMedium } from '../contract.js';
|
|
3
|
+
import type { CallOptions } from '../inbox/client.js';
|
|
4
|
+
/**
|
|
5
|
+
* A person's consent choices as this key meets them: email and WhatsApp for the whole tenant, in-app for this key's
|
|
6
|
+
* own sub-tenant (or the tenant level, for a key outside any).
|
|
7
|
+
*/
|
|
8
|
+
export interface PreferencesClient {
|
|
9
|
+
/** One entry per medium. Someone Pigeon has not met has made no choice. */
|
|
10
|
+
get(personId: string, options?: CallOptions): Promise<PreferenceChoice[]>;
|
|
11
|
+
/** Records one choice with how it was made, and answers the choices as they are afterwards. */
|
|
12
|
+
set(personId: string, medium: PreferenceMedium, state: 'opted_in' | 'opted_out', evidence?: PreferenceEvidence, options?: CallOptions): Promise<PreferenceChoice[]>;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Pass an existing client to share its key and settings, or the same options as `createCommunicationClient`.
|
|
16
|
+
* The key needs the scopes `recipients:read` and `consent:write`.
|
|
17
|
+
*/
|
|
18
|
+
export declare function createPreferencesClient(client?: ClientOptions | CommunicationClient): PreferencesClient;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { PreferenceEvidence, PreferenceMedium } from '../contract.js';
|
|
2
|
+
import { type PreferencesClient } from './client.js';
|
|
3
|
+
export declare const PREFERENCE_MEDIUMS: readonly PreferenceMedium[];
|
|
4
|
+
export interface PreferencesHandlerOptions {
|
|
5
|
+
/** The app's only job: who is signed in. `null` means nobody. Read it from your own session, never from the request's path, query or body. */
|
|
6
|
+
resolvePerson: (request: Request) => Promise<string | null> | string | null;
|
|
7
|
+
/** Default: a client made on the first request from `COMMUNICATION_API_KEY` and `COMMUNICATION_URL`. */
|
|
8
|
+
client?: PreferencesClient;
|
|
9
|
+
/** Where the route is mounted. Default `/api/communication/preferences`. */
|
|
10
|
+
basePath?: string;
|
|
11
|
+
/** The media a person may see and change through this route. Default: all three. */
|
|
12
|
+
mediums?: readonly PreferenceMedium[];
|
|
13
|
+
/** Recorded with every choice. `method` defaults to `settings_page`; set `wordingVersion` and change it when the text beside the switches changes. */
|
|
14
|
+
evidence?: Pick<PreferenceEvidence, 'method' | 'wordingVersion'>;
|
|
15
|
+
/** Origins that may send PUT. Default: the request's own origin. */
|
|
16
|
+
allowedOrigins?: readonly string[];
|
|
17
|
+
/** Default 8000. */
|
|
18
|
+
timeoutMs?: number;
|
|
19
|
+
/** Told about every failure answered as `unavailable`, for the host's own log. The browser never sees it. */
|
|
20
|
+
onError?: (error: unknown) => void;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The host app's browser-facing route for a person's notification choices, in one tested function:
|
|
24
|
+
*
|
|
25
|
+
* // app/api/communication/preferences/route.ts
|
|
26
|
+
* const handler = createPreferencesHandler({ resolvePerson });
|
|
27
|
+
* export { handler as GET, handler as PUT };
|
|
28
|
+
*
|
|
29
|
+
* `GET` answers the signed-in person's choices; `PUT` with `{ medium, state }` records one and answers them again.
|
|
30
|
+
* The person comes only from `resolvePerson`. A `PUT` from another origin is refused. The host stores nothing.
|
|
31
|
+
*/
|
|
32
|
+
export declare function createPreferencesHandler(options: PreferencesHandlerOptions): (request: Request) => Promise<Response>;
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { createPreferencesClient, type PreferencesClient } from './client.js';
|
|
2
|
+
export { createPreferencesHandler, PREFERENCE_MEDIUMS, type PreferencesHandlerOptions } from './handler.js';
|
|
3
|
+
export type { PreferenceChoice, PreferenceEvidence, PreferenceMedium } from '../contract.js';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A person's notification choices for a settings page: one switch per medium the app can send on.
|
|
3
|
+
*
|
|
4
|
+
* import { NotificationPreferences } from '@xtremax/pigeon/preferences-react';
|
|
5
|
+
*
|
|
6
|
+
* <NotificationPreferences />
|
|
7
|
+
*
|
|
8
|
+
* The component calls the host app's own route (default `/api/communication/preferences`), which
|
|
9
|
+
* `createPreferencesHandler` from `@xtremax/pigeon/preferences` serves. It needs React 19 and Mantine 8, and no
|
|
10
|
+
* stylesheet of its own. Nothing here holds a key or talks to another origin.
|
|
11
|
+
*/
|
|
12
|
+
export { NotificationPreferences, type NotificationPreferencesProps } from './notification-preferences.js';
|
|
13
|
+
export { DEFAULT_PREFERENCE_LABELS, type PreferenceLabels } from './labels.js';
|
|
14
|
+
export type { PreferenceChoice, PreferenceMedium } from '../contract.js';
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Every visible and accessible text of the preferences component. Pass `labels` to replace any of them. */
|
|
2
|
+
export interface PreferenceLabels {
|
|
3
|
+
/** The accessible name of the group of switches. */
|
|
4
|
+
group: string;
|
|
5
|
+
email: string;
|
|
6
|
+
/** Email, when the choice holds in every app of the organisation. */
|
|
7
|
+
emailOn: string;
|
|
8
|
+
emailOff: string;
|
|
9
|
+
/** Email, when Pigeon says the choice holds for this app only (`covers: 'app'`). */
|
|
10
|
+
emailOnApp: string;
|
|
11
|
+
emailOffApp: string;
|
|
12
|
+
whatsapp: string;
|
|
13
|
+
whatsappOn: string;
|
|
14
|
+
whatsappOff: string;
|
|
15
|
+
whatsappOnApp: string;
|
|
16
|
+
whatsappOffApp: string;
|
|
17
|
+
inApp: string;
|
|
18
|
+
inAppOn: string;
|
|
19
|
+
inAppOff: string;
|
|
20
|
+
loading: string;
|
|
21
|
+
loadError: string;
|
|
22
|
+
retry: string;
|
|
23
|
+
saveError: string;
|
|
24
|
+
/** Shown when the app can send on no medium. */
|
|
25
|
+
none: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The sentences say what each switch covers, and Pigeon says which applies (`covers` in its answer): a choice that
|
|
29
|
+
* holds in every app of the organisation, or in this app only. Keep that when you translate.
|
|
30
|
+
*/
|
|
31
|
+
export declare const DEFAULT_PREFERENCE_LABELS: PreferenceLabels;
|
|
32
|
+
export declare function mergePreferenceLabels(labels?: Partial<PreferenceLabels>): PreferenceLabels;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { PreferenceChoice, PreferenceMedium } from '../contract.js';
|
|
2
|
+
import { type PreferenceLabels } from './labels.js';
|
|
3
|
+
export interface NotificationPreferencesProps {
|
|
4
|
+
/** A path on the host's own origin. Default `/api/communication/preferences`. */
|
|
5
|
+
apiBase?: string;
|
|
6
|
+
/** Replaces any visible or accessible text. */
|
|
7
|
+
labels?: Partial<PreferenceLabels>;
|
|
8
|
+
/** Which media to show, in this order. Default: the ones the app can send on. */
|
|
9
|
+
mediums?: readonly PreferenceMedium[];
|
|
10
|
+
/** Told after a choice was saved. */
|
|
11
|
+
onChange?: (choice: PreferenceChoice) => void;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* A person's notification choices: one switch per medium, with a sentence that says what the switch covers.
|
|
15
|
+
* It shows what is recorded: a switch moves when the choice is saved, not before. Place it on a settings page,
|
|
16
|
+
* inside `MantineProvider`. It calls the host app's own route, which `createPreferencesHandler` serves.
|
|
17
|
+
*/
|
|
18
|
+
export declare function NotificationPreferences({ apiBase, labels, mediums, onChange }: NotificationPreferencesProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { PreferenceChoice, PreferenceMedium } from '../contract.js';
|
|
2
|
+
export declare const DEFAULT_PREFERENCES_API_BASE = "/api/communication/preferences";
|
|
3
|
+
export declare const PREFERENCE_MEDIUMS: readonly PreferenceMedium[];
|
|
4
|
+
export type PreferencesResult = {
|
|
5
|
+
ok: true;
|
|
6
|
+
choices: PreferenceChoice[];
|
|
7
|
+
} | {
|
|
8
|
+
ok: false;
|
|
9
|
+
code: 'not_signed_in' | 'unavailable';
|
|
10
|
+
};
|
|
11
|
+
/** A choice as the host route answers it. Anything else is refused, so a wrong answer never becomes a switch. */
|
|
12
|
+
export declare function parseChoice(v: unknown): PreferenceChoice | null;
|
|
13
|
+
export declare function parseChoices(v: unknown): PreferenceChoice[] | null;
|
|
14
|
+
export interface PreferencesBrowserClient {
|
|
15
|
+
get(): Promise<PreferencesResult>;
|
|
16
|
+
set(medium: PreferenceMedium, on: boolean): Promise<PreferencesResult>;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The browser talks only to the host app's own route. Every call answers a result and never throws, so an outage
|
|
20
|
+
* cannot break the host page.
|
|
21
|
+
*/
|
|
22
|
+
export declare function createPreferencesBrowserClient(apiBase?: string, fetchImpl?: typeof fetch): PreferencesBrowserClient;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xtremax/pigeon",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Client for Pigeon, the Buildpad Communication Gateway: send messages, verify signed events, serve an in-app inbox and show its notification bell.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -15,10 +15,12 @@
|
|
|
15
15
|
"./inbox": { "browser": "./dist/esm/inbox/browser-stub.js", "types": "./dist/types/inbox/index.d.ts", "import": "./dist/esm/inbox/index.js", "require": "./dist/cjs/inbox/index.js" },
|
|
16
16
|
"./inbox-react": { "types": "./dist/types/inbox-react/index.d.ts", "import": "./dist/esm/inbox-react/index.js", "require": "./dist/cjs/inbox-react/index.js" },
|
|
17
17
|
"./inbox-react/styles.css": "./dist/inbox-react/styles.css",
|
|
18
|
+
"./preferences": { "browser": "./dist/esm/preferences/browser-stub.js", "types": "./dist/types/preferences/index.d.ts", "import": "./dist/esm/preferences/index.js", "require": "./dist/cjs/preferences/index.js" },
|
|
19
|
+
"./preferences-react": { "types": "./dist/types/preferences-react/index.d.ts", "import": "./dist/esm/preferences-react/index.js", "require": "./dist/cjs/preferences-react/index.js" },
|
|
18
20
|
"./events": { "types": "./dist/types/events.d.ts", "import": "./dist/esm/events.js", "require": "./dist/cjs/events.js" },
|
|
19
21
|
"./contract": { "types": "./dist/types/contract.d.ts" }
|
|
20
22
|
},
|
|
21
|
-
"bin": { "pigeon": "
|
|
23
|
+
"bin": { "pigeon": "bin/pigeon.mjs" },
|
|
22
24
|
"files": ["dist", "bin", "skills", "README.md", "CHANGELOG.md"],
|
|
23
25
|
"scripts": {
|
|
24
26
|
"build": "node scripts/build.mjs",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
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
|
|
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
4
|
user-invokable: true
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -24,6 +24,7 @@ Three small pieces, all from one package:
|
|
|
24
24
|
| Piece | Where | From |
|
|
25
25
|
|---|---|---|
|
|
26
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` |
|
|
27
28
|
| The bell | the app's header | `@xtremax/pigeon/inbox-react` |
|
|
28
29
|
| Sending | wherever something happens that a person should know | `@xtremax/pigeon/client` |
|
|
29
30
|
|
|
@@ -38,7 +39,8 @@ These are what keeps the integration safe. Each has a reason; do not trade one a
|
|
|
38
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.
|
|
39
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.
|
|
40
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.
|
|
41
|
-
6. **A notification must never break the action that caused it.** If sending fails, log it and carry on (Step
|
|
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).
|
|
42
44
|
|
|
43
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.
|
|
44
46
|
|
|
@@ -63,7 +65,7 @@ The package's preference dialog uses Mantine's `Switch` internally. That is insi
|
|
|
63
65
|
pnpm add @xtremax/pigeon
|
|
64
66
|
```
|
|
65
67
|
|
|
66
|
-
Version 0.
|
|
68
|
+
Version 0.4.0 or later. No component files are copied into the app.
|
|
67
69
|
|
|
68
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.
|
|
69
71
|
|
|
@@ -100,7 +102,7 @@ Read the output:
|
|
|
100
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.
|
|
101
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.
|
|
102
104
|
|
|
103
|
-
## Step 4:
|
|
105
|
+
## Step 4: Who the person is
|
|
104
106
|
|
|
105
107
|
```ts
|
|
106
108
|
// lib/communication/person.ts
|
|
@@ -110,6 +112,23 @@ export const personIdOf = (email: string): string => email.trim().toLowerCase();
|
|
|
110
112
|
|
|
111
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.
|
|
112
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
|
+
|
|
113
132
|
## Step 5: The route
|
|
114
133
|
|
|
115
134
|
One file. It needs no other code.
|
|
@@ -117,18 +136,12 @@ One file. It needs no other code.
|
|
|
117
136
|
```ts
|
|
118
137
|
// app/api/communication/inbox/[...path]/route.ts
|
|
119
138
|
import { createInboxHandler } from '@xtremax/pigeon/inbox';
|
|
120
|
-
import {
|
|
121
|
-
import { createClient } from '@/lib/supabase/server';
|
|
139
|
+
import { resolvePerson } from '@/lib/communication/session';
|
|
122
140
|
|
|
123
141
|
export const dynamic = 'force-dynamic';
|
|
124
142
|
|
|
125
143
|
const handler = createInboxHandler({
|
|
126
|
-
|
|
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
|
-
},
|
|
144
|
+
resolvePerson,
|
|
132
145
|
// The browser is never told why something failed. This is where you see it.
|
|
133
146
|
onError: (error) => console.error('[communication-inbox]', error),
|
|
134
147
|
});
|
|
@@ -136,7 +149,6 @@ const handler = createInboxHandler({
|
|
|
136
149
|
export { handler as GET, handler as POST, handler as PUT };
|
|
137
150
|
```
|
|
138
151
|
|
|
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
152
|
- In an app with locale routes (`app/[lang]/...`), the route still lives at `app/api/...`, not under `[lang]`.
|
|
141
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`.
|
|
142
154
|
- The handler reads the key and the address on the first request. A missing one answers `503 unavailable` and never stops the build.
|
|
@@ -242,6 +254,27 @@ What to know when choosing what to send:
|
|
|
242
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.
|
|
243
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.
|
|
244
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
|
+
|
|
245
278
|
## Step 8: Tests
|
|
246
279
|
|
|
247
280
|
Add these. They are quick, and the first group needs no key.
|
|
@@ -279,7 +312,7 @@ await expect(page.getByRole('dialog').getByRole('heading', { name: 'Notification
|
|
|
279
312
|
- [ ] Signed in, the same call answers `200` with `unreadCount`, and the bell shows in the header.
|
|
280
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.
|
|
281
314
|
- [ ] `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.
|
|
315
|
+
- [ ] No custom bell, notifications collection or polling code was added, and no field or collection that stores an opt-in or opt-out.
|
|
283
316
|
- [ ] The app's docs say where notifications are sent from and which two variables a deployment needs.
|
|
284
317
|
|
|
285
318
|
## When it does not work
|
|
@@ -299,4 +332,6 @@ await expect(page.getByRole('dialog').getByRole('heading', { name: 'Notification
|
|
|
299
332
|
|
|
300
333
|
## Reference
|
|
301
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
|
+
|
|
302
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.
|