@xtremax/pigeon 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +40 -1
  3. package/bin/pigeon.mjs +92 -0
  4. package/dist/cjs/client.js +3 -0
  5. package/dist/cjs/inbox/client.js +4 -9
  6. package/dist/cjs/person.js +10 -0
  7. package/dist/cjs/preferences/browser-stub.js +10 -0
  8. package/dist/cjs/preferences/client.js +21 -0
  9. package/dist/cjs/preferences/handler.js +96 -0
  10. package/dist/cjs/preferences/index.js +8 -0
  11. package/dist/cjs/preferences-react/index.js +18 -0
  12. package/dist/cjs/preferences-react/labels.js +32 -0
  13. package/dist/cjs/preferences-react/notification-preferences.js +76 -0
  14. package/dist/cjs/preferences-react/preferences-client.js +72 -0
  15. package/dist/esm/client.js +3 -0
  16. package/dist/esm/inbox/client.js +2 -7
  17. package/dist/esm/person.js +7 -0
  18. package/dist/esm/preferences/browser-stub.js +7 -0
  19. package/dist/esm/preferences/client.js +18 -0
  20. package/dist/esm/preferences/handler.js +92 -0
  21. package/dist/esm/preferences/index.js +2 -0
  22. package/dist/esm/preferences-react/index.js +13 -0
  23. package/dist/esm/preferences-react/labels.js +28 -0
  24. package/dist/esm/preferences-react/notification-preferences.js +73 -0
  25. package/dist/esm/preferences-react/preferences-client.js +66 -0
  26. package/dist/types/client.d.ts +5 -10
  27. package/dist/types/contract.d.ts +27 -0
  28. package/dist/types/inbox/client.d.ts +2 -2
  29. package/dist/types/person.d.ts +2 -0
  30. package/dist/types/preferences/browser-stub.d.ts +3 -0
  31. package/dist/types/preferences/client.d.ts +18 -0
  32. package/dist/types/preferences/handler.d.ts +32 -0
  33. package/dist/types/preferences/index.d.ts +3 -0
  34. package/dist/types/preferences-react/index.d.ts +14 -0
  35. package/dist/types/preferences-react/labels.d.ts +32 -0
  36. package/dist/types/preferences-react/notification-preferences.d.ts +18 -0
  37. package/dist/types/preferences-react/preferences-client.d.ts +22 -0
  38. package/package.json +5 -2
  39. package/skills/add-pigeon-notifications/SKILL.md +337 -0
  40. package/skills/add-pigeon-notifications/references/consent.instructions.md +263 -0
  41. package/skills/add-pigeon-notifications/references/inbox-contract.instructions.md +210 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0
4
+ - New entry `@xtremax/pigeon/preferences` (server only): `createPreferencesHandler`, the app's own route for a notification settings page (`GET` answers the signed-in person's choices, `PUT` records one with evidence), and `createPreferencesClient`.
5
+ - New entry `@xtremax/pigeon/preferences-react`: `NotificationPreferences`, one switch per medium the app can send on, with a sentence that says what each switch covers. It needs React 19 and Mantine 8, and no stylesheet.
6
+ - `communication.preferences.get(personId)`: each medium's choice, whether a message reaches the person now (`delivers`), what the choice covers and whether the app can send on the medium. It needs a Pigeon deployment that has `GET /preferences`.
7
+ - `communication.preferences.record` answers a typed result.
8
+ - The skill covers consent: what an app must and must not do, the defaults per medium, registering people and addresses, the settings page, transactional templates and erasure. New file `skills/add-pigeon-notifications/references/consent.instructions.md`.
9
+ - No change to existing calls.
10
+
11
+ ## 0.3.1
12
+ - The package carries its agent skill, `skills/add-pigeon-notifications/`: the steps a coding agent follows to add the bell and the sending code to an app.
13
+ - New command `npx @xtremax/pigeon add-skill`: copies the skill into the project's `.claude/skills/`, and into `.kiro/skills/` when the project has a `.kiro` folder. Run it again after an upgrade to get the new version's skill. `--check` says what would change.
14
+ - No change to the library.
15
+
3
16
  ## 0.3.0
4
17
  - The package is named `@xtremax/pigeon`. Earlier versions were called `@xtremax/communication` and were never published. Only the import path changes: function names, environment variables and headers are the same.
5
18
  - New entry `@xtremax/pigeon/inbox-react`: the notification bell (`InboxProvider`, `NotificationBell`, `InboxFullList`), with its stylesheet at `@xtremax/pigeon/inbox-react/styles.css`. It replaces the files that were copied into each app. React 19, Mantine 8 and `@tabler/icons-react` are optional peer dependencies.
package/README.md CHANGED
@@ -54,4 +54,43 @@ import '@xtremax/pigeon/inbox-react/styles.css'; // once, after Mantine's styles
54
54
  - **Direct calls:** `createInboxClient()` has `summary`, `list`, `markRead`, `markAllRead`, `getPreference` and `setPreference`, each taking the person id first.
55
55
  - **What a key sees:** what its own sub-tenant (or the tenant level, for a key outside any) sent the person, and the tenant's announcements. Another app's notifications are not part of it, and the on/off preference is this app's own. `ping()` returns the key's `subKey`.
56
56
 
57
- Entry points: `/client`, `/inbox`, `/events` and `/contract` (types only) for the server; `/inbox-react` and `/inbox-react/styles.css` for the browser. Node 20 or later. Below 1.0 a breaking change raises the minor version.
57
+ ## Notification settings
58
+
59
+ A person's choices for email, WhatsApp and in-app notifications, on a settings page of your app. Pigeon keeps the choices and decides for every message; your app keeps nothing. Two pieces again:
60
+
61
+ ```ts
62
+ // app/api/communication/preferences/route.ts
63
+ import { createPreferencesHandler } from '@xtremax/pigeon/preferences';
64
+
65
+ const handler = createPreferencesHandler({
66
+ resolvePerson: async () => (await currentUser())?.email ?? null, // your own session
67
+ evidence: { wordingVersion: '2026-11' }, // recorded with every choice
68
+ });
69
+ export { handler as GET, handler as PUT };
70
+ ```
71
+
72
+ ```tsx
73
+ // on your settings page
74
+ import { NotificationPreferences } from '@xtremax/pigeon/preferences-react';
75
+
76
+ <NotificationPreferences />
77
+ ```
78
+
79
+ - **It shows a switch for each medium your app can send on**, and each switch shows what is recorded: it moves when the choice is saved.
80
+ - **The sentences say what a switch covers:** email and WhatsApp hold in every app of the organisation, in-app in this app only. Pass `labels` to translate, `mediums` to choose which switches to show.
81
+ - **In server code,** `communication.preferences.get(personId)` answers each medium's choice with `delivers`: whether a message reaches the person now. Do not use it to decide whether to send: send, and Pigeon refuses when it must.
82
+ - **Do not keep consent in your app.** No opt-in column, no cached copy.
83
+
84
+ ## For AI coding agents
85
+
86
+ The package carries a skill that tells a coding agent (Claude Code, Kiro and others that read `SKILL.md`) how to add the bell and the sending code to an app, step by step, with tests and a troubleshooting table.
87
+
88
+ ```bash
89
+ npx @xtremax/pigeon add-skill
90
+ ```
91
+
92
+ It copies `add-pigeon-notifications` into the project's `.claude/skills/`, and into `.kiro/skills/` when the project has a `.kiro` folder (`--kiro` forces it, `--dir` names another project folder, `--check` changes nothing and says what would change). Then ask the agent to "add a notification bell", or run `/add-pigeon-notifications`. Run the command again after upgrading the package: the skill's files are replaced with the new version's.
93
+
94
+ An agent that has not been given the skill can read it in place: `node_modules/@xtremax/pigeon/skills/add-pigeon-notifications/SKILL.md`.
95
+
96
+ Entry points: `/client`, `/inbox`, `/preferences`, `/events` and `/contract` (types only) for the server; `/inbox-react`, `/inbox-react/styles.css` and `/preferences-react` for the browser. Node 20 or later. Below 1.0 a breaking change raises the minor version.
package/bin/pigeon.mjs ADDED
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env node
2
+ // The package's command line. One job: put the agent skills that ship with the package where a coding agent finds them.
3
+ //
4
+ // npx @xtremax/pigeon add-skill copy the skills into this project
5
+ // npx @xtremax/pigeon add-skill --check say what would change, and change nothing
6
+ // npx @xtremax/pigeon add-skill --dir app another project folder
7
+ //
8
+ // The skills go to .claude/skills/ always, and to .kiro/skills/ when the project has a .kiro folder (or with --kiro).
9
+ // Run it again after upgrading the package: it replaces the skill's files with the new version's.
10
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
11
+ import { dirname, join, relative, resolve } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
15
+ const skillsRoot = join(packageRoot, 'skills');
16
+ const { name, version } = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8'));
17
+
18
+ const USAGE = `${name} ${version}
19
+
20
+ Usage:
21
+ npx ${name} add-skill [--dir <project folder>] [--kiro] [--check]
22
+
23
+ add-skill Copy the agent skills of this package into the project:
24
+ .claude/skills/<skill>/, and .kiro/skills/<skill>/ when the project has a .kiro folder.
25
+ --dir The project folder. Default: the current folder.
26
+ --kiro Also write .kiro/skills/ when the project has no .kiro folder yet.
27
+ --check Say what would change and change nothing. Exits 1 when something would.
28
+ `;
29
+
30
+ function fail(message) {
31
+ process.stderr.write(`${message}\n\n${USAGE}`);
32
+ process.exit(2);
33
+ }
34
+
35
+ /** Every file under a folder, as paths relative to it. */
36
+ function filesOf(dir, base = dir) {
37
+ return readdirSync(dir).flatMap((entry) => {
38
+ const path = join(dir, entry);
39
+ return statSync(path).isDirectory() ? filesOf(path, base) : [relative(base, path)];
40
+ });
41
+ }
42
+
43
+ function addSkill(args) {
44
+ let dir = process.cwd();
45
+ let kiro = false;
46
+ let check = false;
47
+ for (let i = 0; i < args.length; i += 1) {
48
+ const arg = args[i];
49
+ if (arg === '--dir') {
50
+ const value = args[(i += 1)];
51
+ if (!value) fail('--dir needs a folder.');
52
+ dir = resolve(value);
53
+ } else if (arg === '--kiro') kiro = true;
54
+ else if (arg === '--check') check = true;
55
+ else fail(`Unknown option: ${arg}`);
56
+ }
57
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) fail(`Not a folder: ${dir}`);
58
+ // A project, not any folder: writing skills into the wrong place helps nobody.
59
+ if (!existsSync(join(dir, 'package.json'))) fail(`No package.json in ${dir}. Run this in the project's folder, or pass --dir.`);
60
+
61
+ const skills = readdirSync(skillsRoot).filter((entry) => existsSync(join(skillsRoot, entry, 'SKILL.md')));
62
+ const homes = ['.claude/skills', ...(kiro || existsSync(join(dir, '.kiro')) ? ['.kiro/skills'] : [])];
63
+ const counts = { added: 0, updated: 0, same: 0 };
64
+
65
+ for (const home of homes) {
66
+ for (const skill of skills) {
67
+ for (const file of filesOf(join(skillsRoot, skill))) {
68
+ const content = readFileSync(join(skillsRoot, skill, file));
69
+ const target = join(dir, home, skill, file);
70
+ const state = !existsSync(target) ? 'added' : readFileSync(target).equals(content) ? 'same' : 'updated';
71
+ counts[state] += 1;
72
+ if (state === 'same') continue;
73
+ process.stdout.write(`${check ? `would be ${state}` : state}: ${join(home, skill, file)}\n`);
74
+ if (check) continue;
75
+ mkdirSync(dirname(target), { recursive: true });
76
+ writeFileSync(target, content);
77
+ }
78
+ }
79
+ }
80
+
81
+ const changed = counts.added + counts.updated;
82
+ if (changed === 0) process.stdout.write(`Up to date: ${skills.join(', ')} (${name} ${version}) in ${homes.join(' and ')}.\n`);
83
+ else if (check) process.stdout.write(`${changed} file(s) would change. Run without --check to write them.\n`);
84
+ else process.stdout.write(`\nDone: ${skills.join(', ')} (${name} ${version}) in ${homes.join(' and ')}.\nAsk your coding agent to "add a notification bell", or run /${skills[0]}.\n`);
85
+ process.exit(check && changed > 0 ? 1 : 0);
86
+ }
87
+
88
+ const [command, ...rest] = process.argv.slice(2);
89
+ if (command === 'add-skill') addSkill(rest);
90
+ else if (command === undefined || command === 'help' || command === '--help' || command === '-h') process.stdout.write(USAGE);
91
+ else if (command === '--version' || command === '-v') process.stdout.write(`${version}\n`);
92
+ else fail(`Unknown command: ${command}`);
@@ -4,6 +4,7 @@ exports.CommunicationError = exports.TRANSPORT = void 0;
4
4
  exports.createCommunicationClient = createCommunicationClient;
5
5
  const errors_1 = require("./errors");
6
6
  const idempotency_1 = require("./idempotency");
7
+ const person_1 = require("./person");
7
8
  const retry_1 = require("./retry");
8
9
  /** Where a client keeps its transport, so `@xtremax/pigeon/inbox` can reuse the key handling, retries and errors. */
9
10
  exports.TRANSPORT = Symbol.for('@xtremax/pigeon.transport');
@@ -97,6 +98,8 @@ function createCommunicationClient(options = {}) {
97
98
  },
98
99
  },
99
100
  preferences: {
101
+ /** A person's choice for each medium, and whether a message reaches them now. Someone Pigeon has not met has made no choice. */
102
+ get: (personId) => request('GET', '/preferences', undefined, { headers: { 'X-Communication-Person': (0, person_1.personHeader)(personId) } }).then((r) => r.data.preferences),
100
103
  record: (body) => request('POST', '/preferences', body).then((r) => r.data),
101
104
  },
102
105
  messageClasses: {
@@ -1,15 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.personHeader = personHeader;
3
+ exports.personHeader = void 0;
4
4
  exports.createInboxClient = createInboxClient;
5
5
  const client_1 = require("../client");
6
- /** `X-Communication-Person` is base64url of the UTF-8 person id, so any identifier travels safely in a header. */
7
- function personHeader(personId) {
8
- let binary = '';
9
- for (const byte of new TextEncoder().encode(personId))
10
- binary += String.fromCharCode(byte);
11
- return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
12
- }
6
+ const person_1 = require("../person");
7
+ Object.defineProperty(exports, "personHeader", { enumerable: true, get: function () { return person_1.personHeader; } });
13
8
  const isClient = (v) => client_1.TRANSPORT in v;
14
9
  /**
15
10
  * The in-app inbox of one person. Pass an existing client to share its key and settings, or the same options as
@@ -17,7 +12,7 @@ const isClient = (v) => client_1.TRANSPORT in v;
17
12
  */
18
13
  function createInboxClient(client = {}) {
19
14
  const request = (isClient(client) ? client : (0, client_1.createCommunicationClient)(client))[client_1.TRANSPORT];
20
- const call = (method, path, personId, body, read, options = {}) => request(method, path, body, { headers: { 'X-Communication-Person': personHeader(personId) }, retry: options.retry ?? read, timeoutMs: options.timeoutMs }).then((r) => r.data);
15
+ const call = (method, path, personId, body, read, options = {}) => request(method, path, body, { headers: { 'X-Communication-Person': (0, person_1.personHeader)(personId) }, retry: options.retry ?? read, timeoutMs: options.timeoutMs }).then((r) => r.data);
21
16
  return {
22
17
  summary: (personId, options) => call('GET', '/inbox/summary', personId, undefined, true, options),
23
18
  list(personId, query = {}, options) {
@@ -0,0 +1,10 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.personHeader = personHeader;
4
+ /** `X-Communication-Person` is base64url of the UTF-8 person id, so any identifier travels safely in a header. */
5
+ function personHeader(personId) {
6
+ let binary = '';
7
+ for (const byte of new TextEncoder().encode(personId))
8
+ binary += String.fromCharCode(byte);
9
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
10
+ }
@@ -0,0 +1,10 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createPreferencesHandler = exports.createPreferencesClient = void 0;
4
+ const errors_1 = require("../errors");
5
+ const serverOnly = () => {
6
+ throw new errors_1.CommunicationError({ code: 'server_only', message: 'The Communication preferences client holds a secret key and must run on the server.' });
7
+ };
8
+ /** Selected by the `browser` export condition: this entry never reaches client code. The component talks to the host's own route instead. */
9
+ exports.createPreferencesClient = serverOnly;
10
+ exports.createPreferencesHandler = serverOnly;
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createPreferencesClient = createPreferencesClient;
4
+ const client_1 = require("../client");
5
+ const person_1 = require("../person");
6
+ const isClient = (v) => client_1.TRANSPORT in v;
7
+ /**
8
+ * Pass an existing client to share its key and settings, or the same options as `createCommunicationClient`.
9
+ * The key needs the scopes `recipients:read` and `consent:write`.
10
+ */
11
+ function createPreferencesClient(client = {}) {
12
+ const request = (isClient(client) ? client : (0, client_1.createCommunicationClient)(client))[client_1.TRANSPORT];
13
+ const get = (personId, options = {}) => request('GET', '/preferences', undefined, { headers: { 'X-Communication-Person': (0, person_1.personHeader)(personId) }, retry: options.retry ?? true, timeoutMs: options.timeoutMs }).then((r) => r.data.preferences);
14
+ return {
15
+ get,
16
+ async set(personId, medium, state, evidence, options = {}) {
17
+ await request('POST', '/preferences', { recipient: { personId }, medium, state, expressedAt: new Date().toISOString(), ...(evidence ? { evidence } : {}) }, { retry: options.retry ?? false, timeoutMs: options.timeoutMs });
18
+ return get(personId, options);
19
+ },
20
+ };
21
+ }
@@ -0,0 +1,96 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PREFERENCE_MEDIUMS = void 0;
4
+ exports.createPreferencesHandler = createPreferencesHandler;
5
+ const errors_1 = require("../inbox/errors");
6
+ const origin_1 = require("../inbox/origin");
7
+ const validate_1 = require("../inbox/validate");
8
+ const client_1 = require("./client");
9
+ exports.PREFERENCE_MEDIUMS = ['email', 'whatsapp', 'in_app'];
10
+ /** Allowlist projection: only the documented fields leave the host. A value of the wrong shape gives `null`. */
11
+ function projectChoice(v) {
12
+ if (typeof v !== 'object' || v === null)
13
+ return null;
14
+ const o = v;
15
+ const medium = exports.PREFERENCE_MEDIUMS.find((m) => m === o.medium);
16
+ if (!medium)
17
+ return null;
18
+ if (o.state !== 'opted_in' && o.state !== 'opted_out' && o.state !== 'not_set')
19
+ return null;
20
+ if (o.changedAt !== null && typeof o.changedAt !== 'string')
21
+ return null;
22
+ if (typeof o.delivers !== 'boolean' || typeof o.available !== 'boolean')
23
+ return null;
24
+ if (o.covers !== 'tenant' && o.covers !== 'app')
25
+ return null;
26
+ return { medium, state: o.state, changedAt: o.changedAt, delivers: o.delivers, covers: o.covers, available: o.available };
27
+ }
28
+ /**
29
+ * The host app's browser-facing route for a person's notification choices, in one tested function:
30
+ *
31
+ * // app/api/communication/preferences/route.ts
32
+ * const handler = createPreferencesHandler({ resolvePerson });
33
+ * export { handler as GET, handler as PUT };
34
+ *
35
+ * `GET` answers the signed-in person's choices; `PUT` with `{ medium, state }` records one and answers them again.
36
+ * The person comes only from `resolvePerson`. A `PUT` from another origin is refused. The host stores nothing.
37
+ */
38
+ function createPreferencesHandler(options) {
39
+ const base = (options.basePath ?? '/api/communication/preferences').replace(/\/+$/, '');
40
+ const mediums = options.mediums && options.mediums.length > 0 ? exports.PREFERENCE_MEDIUMS.filter((m) => options.mediums.includes(m)) : exports.PREFERENCE_MEDIUMS;
41
+ const evidence = { method: options.evidence?.method ?? 'settings_page', ...(options.evidence?.wordingVersion ? { wordingVersion: options.evidence.wordingVersion } : {}) };
42
+ let made;
43
+ const clientOf = () => options.client ?? (made ??= (0, client_1.createPreferencesClient)());
44
+ return async function preferencesHandler(request) {
45
+ try {
46
+ const method = request.method.toUpperCase();
47
+ if (new URL(request.url).pathname.replace(/\/+$/, '') !== base || (method !== 'GET' && method !== 'PUT'))
48
+ return (0, errors_1.hostError)('not_found');
49
+ if (method === 'PUT' && !(0, origin_1.originAllowed)(request, options.allowedOrigins))
50
+ return (0, errors_1.hostError)('not_permitted');
51
+ const person = await options.resolvePerson(request);
52
+ if (typeof person !== 'string' || person.length === 0 || person.length > validate_1.INBOX_LIMITS.personMax)
53
+ return (0, errors_1.hostError)('not_signed_in');
54
+ const client = clientOf();
55
+ if (method === 'GET')
56
+ return answer(await client.get(person, { retry: false, timeoutMs: options.timeoutMs ?? 8000 }));
57
+ const body = (0, validate_1.parseBody)(await request.text());
58
+ if (!body.ok)
59
+ return (0, errors_1.hostError)('invalid_input', body.field);
60
+ const medium = mediums.find((m) => m === body.value.medium);
61
+ if (!medium)
62
+ return (0, errors_1.hostError)('invalid_input', 'medium');
63
+ const state = body.value.state;
64
+ if (state !== 'opted_in' && state !== 'opted_out')
65
+ return (0, errors_1.hostError)('invalid_input', 'state');
66
+ return answer(await client.set(person, medium, state, evidence, { retry: false, timeoutMs: options.timeoutMs ?? 8000 }));
67
+ }
68
+ catch (error) {
69
+ const code = (0, errors_1.hostCodeOf)(error);
70
+ if (code === 'unavailable')
71
+ report(error);
72
+ return (0, errors_1.hostError)(code);
73
+ }
74
+ };
75
+ function answer(data) {
76
+ const preferences = [];
77
+ for (const raw of Array.isArray(data) ? data : [null]) {
78
+ const choice = projectChoice(raw);
79
+ if (!choice) {
80
+ report(new Error('The preferences answer did not have the documented shape'));
81
+ return (0, errors_1.hostError)('unavailable');
82
+ }
83
+ if (mediums.includes(choice.medium))
84
+ preferences.push(choice);
85
+ }
86
+ return (0, errors_1.hostData)({ preferences });
87
+ }
88
+ function report(error) {
89
+ try {
90
+ options.onError?.(error);
91
+ }
92
+ catch {
93
+ // A failing logger must not change the answer.
94
+ }
95
+ }
96
+ }
@@ -0,0 +1,8 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PREFERENCE_MEDIUMS = exports.createPreferencesHandler = exports.createPreferencesClient = void 0;
4
+ var client_1 = require("./client");
5
+ Object.defineProperty(exports, "createPreferencesClient", { enumerable: true, get: function () { return client_1.createPreferencesClient; } });
6
+ var handler_1 = require("./handler");
7
+ Object.defineProperty(exports, "createPreferencesHandler", { enumerable: true, get: function () { return handler_1.createPreferencesHandler; } });
8
+ Object.defineProperty(exports, "PREFERENCE_MEDIUMS", { enumerable: true, get: function () { return handler_1.PREFERENCE_MEDIUMS; } });
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_PREFERENCE_LABELS = exports.NotificationPreferences = void 0;
4
+ /**
5
+ * A person's notification choices for a settings page: one switch per medium the app can send on.
6
+ *
7
+ * import { NotificationPreferences } from '@xtremax/pigeon/preferences-react';
8
+ *
9
+ * <NotificationPreferences />
10
+ *
11
+ * The component calls the host app's own route (default `/api/communication/preferences`), which
12
+ * `createPreferencesHandler` from `@xtremax/pigeon/preferences` serves. It needs React 19 and Mantine 8, and no
13
+ * stylesheet of its own. Nothing here holds a key or talks to another origin.
14
+ */
15
+ var notification_preferences_1 = require("./notification-preferences");
16
+ Object.defineProperty(exports, "NotificationPreferences", { enumerable: true, get: function () { return notification_preferences_1.NotificationPreferences; } });
17
+ var labels_1 = require("./labels");
18
+ Object.defineProperty(exports, "DEFAULT_PREFERENCE_LABELS", { enumerable: true, get: function () { return labels_1.DEFAULT_PREFERENCE_LABELS; } });
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_PREFERENCE_LABELS = void 0;
4
+ exports.mergePreferenceLabels = mergePreferenceLabels;
5
+ /**
6
+ * The sentences say what each switch covers, and Pigeon says which applies (`covers` in its answer): a choice that
7
+ * holds in every app of the organisation, or in this app only. Keep that when you translate.
8
+ */
9
+ exports.DEFAULT_PREFERENCE_LABELS = {
10
+ group: 'Notification preferences',
11
+ email: 'Email',
12
+ emailOn: 'You receive email from every app of your organisation.',
13
+ emailOff: 'You receive no email from any app of your organisation, except messages you must get, such as receipts and security alerts.',
14
+ emailOnApp: 'You receive email from this app.',
15
+ emailOffApp: 'You receive no email from this app, except messages you must get, such as receipts and security alerts. Other apps are not affected.',
16
+ whatsapp: 'WhatsApp',
17
+ whatsappOn: 'You receive WhatsApp messages from every app of your organisation.',
18
+ whatsappOff: 'You receive no WhatsApp messages from any app of your organisation.',
19
+ whatsappOnApp: 'You receive WhatsApp messages from this app.',
20
+ whatsappOffApp: 'You receive no WhatsApp messages from this app. Other apps are not affected.',
21
+ inApp: 'Notifications in this app',
22
+ inAppOn: 'New notifications from this app appear under the bell.',
23
+ inAppOff: 'New notifications from this app do not appear. The ones you already have stay. Other apps are not affected.',
24
+ loading: 'Loading your notification preferences',
25
+ loadError: 'Your notification preferences could not be loaded.',
26
+ retry: 'Try again',
27
+ saveError: 'Your choice was not saved. Try again.',
28
+ none: 'There is nothing to choose here yet.',
29
+ };
30
+ function mergePreferenceLabels(labels) {
31
+ return { ...exports.DEFAULT_PREFERENCE_LABELS, ...(labels ?? {}) };
32
+ }
@@ -0,0 +1,76 @@
1
+ "use strict";
2
+ 'use client';
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.NotificationPreferences = NotificationPreferences;
5
+ const jsx_runtime_1 = require("react/jsx-runtime");
6
+ const react_1 = require("react");
7
+ const core_1 = require("@mantine/core");
8
+ const labels_1 = require("./labels");
9
+ const preferences_client_1 = require("./preferences-client");
10
+ const words = (labels, choice) => {
11
+ // Pigeon says what a choice covers, so the sentence stays true if that changes on Pigeon's side.
12
+ const own = choice.covers === 'app';
13
+ if (choice.medium === 'email')
14
+ return { label: labels.email, description: choice.delivers ? (own ? labels.emailOnApp : labels.emailOn) : own ? labels.emailOffApp : labels.emailOff };
15
+ if (choice.medium === 'whatsapp')
16
+ return { label: labels.whatsapp, description: choice.delivers ? (own ? labels.whatsappOnApp : labels.whatsappOn) : own ? labels.whatsappOffApp : labels.whatsappOff };
17
+ return { label: labels.inApp, description: choice.delivers ? labels.inAppOn : labels.inAppOff };
18
+ };
19
+ /**
20
+ * A person's notification choices: one switch per medium, with a sentence that says what the switch covers.
21
+ * It shows what is recorded: a switch moves when the choice is saved, not before. Place it on a settings page,
22
+ * inside `MantineProvider`. It calls the host app's own route, which `createPreferencesHandler` serves.
23
+ */
24
+ function NotificationPreferences({ apiBase, labels, mediums, onChange }) {
25
+ const client = (0, react_1.useMemo)(() => (0, preferences_client_1.createPreferencesBrowserClient)(apiBase), [apiBase]);
26
+ const merged = (0, react_1.useMemo)(() => (0, labels_1.mergePreferenceLabels)(labels), [labels]);
27
+ const [choices, setChoices] = (0, react_1.useState)(null);
28
+ const [status, setStatus] = (0, react_1.useState)('loading');
29
+ const [saving, setSaving] = (0, react_1.useState)(null);
30
+ const [saveFailed, setSaveFailed] = (0, react_1.useState)(false);
31
+ const load = (0, react_1.useCallback)(async () => {
32
+ setStatus('loading');
33
+ const res = await client.get();
34
+ if (res.ok)
35
+ setChoices(res.choices);
36
+ setStatus(res.ok ? 'ready' : 'error');
37
+ }, [client]);
38
+ (0, react_1.useEffect)(() => {
39
+ void load();
40
+ }, [load]);
41
+ const change = async (medium, on) => {
42
+ if (saving)
43
+ return;
44
+ setSaving(medium);
45
+ setSaveFailed(false);
46
+ const res = await client.set(medium, on);
47
+ setSaving(null);
48
+ if (!res.ok) {
49
+ setSaveFailed(true);
50
+ return;
51
+ }
52
+ setChoices(res.choices);
53
+ const saved = res.choices.find((choice) => choice.medium === medium);
54
+ // A choice that was recorded and still does not hold (for example a suspended person) is said, not hidden.
55
+ if (!saved || saved.delivers !== on)
56
+ setSaveFailed(true);
57
+ else
58
+ onChange?.(saved);
59
+ };
60
+ if (status === 'loading' && !choices) {
61
+ return ((0, jsx_runtime_1.jsx)(core_1.Center, { mih: 96, role: "status", "aria-label": merged.loading, children: (0, jsx_runtime_1.jsx)(core_1.Loader, { size: "sm" }) }));
62
+ }
63
+ if (!choices) {
64
+ return ((0, jsx_runtime_1.jsx)(core_1.Alert, { color: "red", variant: "light", title: merged.loadError, children: (0, jsx_runtime_1.jsx)(core_1.Button, { variant: "default", size: "sm", mih: 44, onClick: () => void load(), children: merged.retry }) }));
65
+ }
66
+ const shown = mediums
67
+ ? mediums.flatMap((medium) => choices.filter((choice) => choice.medium === medium))
68
+ : choices.filter((choice) => choice.available);
69
+ if (shown.length === 0) {
70
+ return ((0, jsx_runtime_1.jsx)(core_1.Text, { c: "dimmed", size: "sm", children: merged.none }));
71
+ }
72
+ return ((0, jsx_runtime_1.jsxs)(core_1.Stack, { gap: "md", role: "group", "aria-label": merged.group, children: [shown.map((choice) => {
73
+ const { label, description } = words(merged, choice);
74
+ return ((0, jsx_runtime_1.jsx)(core_1.Switch, { label: label, description: description, checked: choice.delivers, disabled: saving !== null, onChange: (event) => void change(choice.medium, event.currentTarget.checked) }, choice.medium));
75
+ }), saveFailed && ((0, jsx_runtime_1.jsx)(core_1.Alert, { color: "red", variant: "light", role: "alert", children: merged.saveError }))] }));
76
+ }
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PREFERENCE_MEDIUMS = exports.DEFAULT_PREFERENCES_API_BASE = void 0;
4
+ exports.parseChoice = parseChoice;
5
+ exports.parseChoices = parseChoices;
6
+ exports.createPreferencesBrowserClient = createPreferencesBrowserClient;
7
+ const inbox_client_1 = require("../inbox-react/inbox-client");
8
+ exports.DEFAULT_PREFERENCES_API_BASE = '/api/communication/preferences';
9
+ exports.PREFERENCE_MEDIUMS = ['email', 'whatsapp', 'in_app'];
10
+ /** A choice as the host route answers it. Anything else is refused, so a wrong answer never becomes a switch. */
11
+ function parseChoice(v) {
12
+ if (typeof v !== 'object' || v === null)
13
+ return null;
14
+ const o = v;
15
+ const medium = exports.PREFERENCE_MEDIUMS.find((m) => m === o.medium);
16
+ if (!medium)
17
+ return null;
18
+ if (o.state !== 'opted_in' && o.state !== 'opted_out' && o.state !== 'not_set')
19
+ return null;
20
+ if (o.changedAt !== null && typeof o.changedAt !== 'string')
21
+ return null;
22
+ if (typeof o.delivers !== 'boolean' || typeof o.available !== 'boolean')
23
+ return null;
24
+ if (o.covers !== 'tenant' && o.covers !== 'app')
25
+ return null;
26
+ return { medium, state: o.state, changedAt: o.changedAt, delivers: o.delivers, covers: o.covers, available: o.available };
27
+ }
28
+ function parseChoices(v) {
29
+ const list = v?.preferences;
30
+ if (!Array.isArray(list))
31
+ return null;
32
+ const choices = [];
33
+ for (const raw of list) {
34
+ const choice = parseChoice(raw);
35
+ if (!choice)
36
+ return null;
37
+ choices.push(choice);
38
+ }
39
+ return choices;
40
+ }
41
+ /**
42
+ * The browser talks only to the host app's own route. Every call answers a result and never throws, so an outage
43
+ * cannot break the host page.
44
+ */
45
+ function createPreferencesBrowserClient(apiBase, fetchImpl) {
46
+ const base = (0, inbox_client_1.normalizeApiBase)(apiBase ?? exports.DEFAULT_PREFERENCES_API_BASE);
47
+ async function call(method, body) {
48
+ if (base === null)
49
+ return { ok: false, code: 'unavailable' };
50
+ try {
51
+ const res = await (fetchImpl ?? fetch)(base, {
52
+ method,
53
+ credentials: 'same-origin',
54
+ cache: 'no-store',
55
+ headers: { Accept: 'application/json', ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}) },
56
+ body: body === undefined ? undefined : JSON.stringify(body),
57
+ });
58
+ if (res.status === 401)
59
+ return { ok: false, code: 'not_signed_in' };
60
+ const json = await res.json().catch(() => null);
61
+ const choices = res.ok ? parseChoices(json?.data) : null;
62
+ return choices ? { ok: true, choices } : { ok: false, code: 'unavailable' };
63
+ }
64
+ catch {
65
+ return { ok: false, code: 'unavailable' };
66
+ }
67
+ }
68
+ return {
69
+ get: () => call('GET'),
70
+ set: (medium, on) => call('PUT', { medium, state: on ? 'opted_in' : 'opted_out' }),
71
+ };
72
+ }
@@ -1,5 +1,6 @@
1
1
  import { CommunicationError, fromResponse, redact } from './errors.js';
2
2
  import { newIdempotencyKey } from './idempotency.js';
3
+ import { personHeader } from './person.js';
3
4
  import { DEFAULT_RETRY, backoffMs, isRetryableStatus } from './retry.js';
4
5
  /** Where a client keeps its transport, so `@xtremax/pigeon/inbox` can reuse the key handling, retries and errors. */
5
6
  export const TRANSPORT = Symbol.for('@xtremax/pigeon.transport');
@@ -93,6 +94,8 @@ export function createCommunicationClient(options = {}) {
93
94
  },
94
95
  },
95
96
  preferences: {
97
+ /** A person's choice for each medium, and whether a message reaches them now. Someone Pigeon has not met has made no choice. */
98
+ get: (personId) => request('GET', '/preferences', undefined, { headers: { 'X-Communication-Person': personHeader(personId) } }).then((r) => r.data.preferences),
96
99
  record: (body) => request('POST', '/preferences', body).then((r) => r.data),
97
100
  },
98
101
  messageClasses: {
@@ -1,11 +1,5 @@
1
1
  import { TRANSPORT, createCommunicationClient } from '../client.js';
2
- /** `X-Communication-Person` is base64url of the UTF-8 person id, so any identifier travels safely in a header. */
3
- export function personHeader(personId) {
4
- let binary = '';
5
- for (const byte of new TextEncoder().encode(personId))
6
- binary += String.fromCharCode(byte);
7
- return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
8
- }
2
+ import { personHeader } from '../person.js';
9
3
  const isClient = (v) => TRANSPORT in v;
10
4
  /**
11
5
  * The in-app inbox of one person. Pass an existing client to share its key and settings, or the same options as
@@ -33,3 +27,4 @@ export function createInboxClient(client = {}) {
33
27
  setPreference: (personId, state, options) => call('PUT', '/inbox/preference', personId, { state }, false, options),
34
28
  };
35
29
  }
30
+ export { personHeader };
@@ -0,0 +1,7 @@
1
+ /** `X-Communication-Person` is base64url of the UTF-8 person id, so any identifier travels safely in a header. */
2
+ export function personHeader(personId) {
3
+ let binary = '';
4
+ for (const byte of new TextEncoder().encode(personId))
5
+ binary += String.fromCharCode(byte);
6
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
7
+ }
@@ -0,0 +1,7 @@
1
+ import { CommunicationError } from '../errors.js';
2
+ const serverOnly = () => {
3
+ throw new CommunicationError({ code: 'server_only', message: 'The Communication preferences client holds a secret key and must run on the server.' });
4
+ };
5
+ /** Selected by the `browser` export condition: this entry never reaches client code. The component talks to the host's own route instead. */
6
+ export const createPreferencesClient = serverOnly;
7
+ export const createPreferencesHandler = serverOnly;
@@ -0,0 +1,18 @@
1
+ import { TRANSPORT, createCommunicationClient } from '../client.js';
2
+ import { personHeader } from '../person.js';
3
+ const isClient = (v) => TRANSPORT in v;
4
+ /**
5
+ * Pass an existing client to share its key and settings, or the same options as `createCommunicationClient`.
6
+ * The key needs the scopes `recipients:read` and `consent:write`.
7
+ */
8
+ export function createPreferencesClient(client = {}) {
9
+ const request = (isClient(client) ? client : createCommunicationClient(client))[TRANSPORT];
10
+ const get = (personId, options = {}) => request('GET', '/preferences', undefined, { headers: { 'X-Communication-Person': personHeader(personId) }, retry: options.retry ?? true, timeoutMs: options.timeoutMs }).then((r) => r.data.preferences);
11
+ return {
12
+ get,
13
+ async set(personId, medium, state, evidence, options = {}) {
14
+ await request('POST', '/preferences', { recipient: { personId }, medium, state, expressedAt: new Date().toISOString(), ...(evidence ? { evidence } : {}) }, { retry: options.retry ?? false, timeoutMs: options.timeoutMs });
15
+ return get(personId, options);
16
+ },
17
+ };
18
+ }