@12-apps/notifications 1.0.0 → 2.1.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/package.json +2 -2
- package/src/index.ts +0 -1
- package/src/react/bell-button.tsx +4 -1
- package/src/react/create-web-notifications.tsx +15 -2
- package/src/react/hooks.ts +31 -1
- package/src/react/index.ts +1 -0
- package/src/server/create-api-notifications.ts +10 -2
- package/src/server/transports/whatsapp.ts +11 -3
- package/src/types.ts +31 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/notifications",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Plug-and-play notification system (12-15): an always-on in-app inbox, per-user × per-category channel preferences, and email / SMS / WhatsApp / web-push transports behind vendor DRIVERS so a second provider is a config entry. Framework-free core (.), host-mounted backend surface (./server: inbox / preferences / push-subscription endpoints, the channel router with delivery records + retry sweep, the permission fan-out, duck-typed Prisma seam), Hono adapter (./hono), React surface (./react: bell + badge, inbox drawer, preferences screen), VAPID sender (./web-push) and the package-owned Prisma partial + migrations. Standardized adoption contract in ADOPTING.md.",
|
|
6
6
|
"exports": {
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"prisma:sync:check": "node scripts/sync-notifications-schema.mjs --check"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@12-apps/ui": "^4.0
|
|
25
|
+
"@12-apps/ui": "^4.1.0"
|
|
26
26
|
},
|
|
27
27
|
"peerDependencies": {
|
|
28
28
|
"hono": ">=4.0.0",
|
package/src/index.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { Box } from '@12-apps/ui/mui/Box';
|
|
|
11
11
|
import type { NotificationMessages } from '../messages';
|
|
12
12
|
|
|
13
13
|
import { BellIcon } from './bell-icon';
|
|
14
|
-
import { useUnreadCount, type NotificationsSubscribe } from './hooks';
|
|
14
|
+
import { useUnreadCount, type NotificationsSignalHook, type NotificationsSubscribe } from './hooks';
|
|
15
15
|
import type { InboxStore } from './inbox-state';
|
|
16
16
|
|
|
17
17
|
const triggerSx = {
|
|
@@ -46,14 +46,17 @@ export function BellButton({
|
|
|
46
46
|
store,
|
|
47
47
|
messages,
|
|
48
48
|
subscribe,
|
|
49
|
+
useSignal,
|
|
49
50
|
}: BellButtonProps & {
|
|
50
51
|
store: InboxStore;
|
|
51
52
|
messages: NotificationMessages;
|
|
52
53
|
subscribe?: NotificationsSubscribe;
|
|
54
|
+
useSignal?: NotificationsSignalHook;
|
|
53
55
|
}): JSX.Element {
|
|
54
56
|
const count = useUnreadCount(store, {
|
|
55
57
|
enabled,
|
|
56
58
|
...(subscribe ? { subscribe } : {}),
|
|
59
|
+
...(useSignal ? { useSignal } : {}),
|
|
57
60
|
});
|
|
58
61
|
return (
|
|
59
62
|
<Box
|
|
@@ -4,7 +4,11 @@ import { messagesOf, type NotificationMessages } from '../messages';
|
|
|
4
4
|
|
|
5
5
|
import { createNotificationsApiClient, type NotificationsApiClient } from './api';
|
|
6
6
|
import { BellButton, type BellButtonProps } from './bell-button';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
useUnreadCount,
|
|
9
|
+
type NotificationsSignalHook,
|
|
10
|
+
type NotificationsSubscribe,
|
|
11
|
+
} from './hooks';
|
|
8
12
|
import { createInboxStore, type InboxStore } from './inbox-state';
|
|
9
13
|
import { NotificationsPanel, type NotificationsPanelProps } from './panel';
|
|
10
14
|
import { PreferencesScreen, type PreferencesScreenProps } from './preferences-screen';
|
|
@@ -41,6 +45,12 @@ export interface NotificationsWebConfig {
|
|
|
41
45
|
* correctness.
|
|
42
46
|
*/
|
|
43
47
|
subscribe?: NotificationsSubscribe;
|
|
48
|
+
/**
|
|
49
|
+
* The same wiring as a HOOK, for a host whose realtime connection lives in
|
|
50
|
+
* React context — see `NotificationsSignalHook`. `subscribe` is read at
|
|
51
|
+
* factory time, which such a host cannot reach.
|
|
52
|
+
*/
|
|
53
|
+
useSignal?: NotificationsSignalHook;
|
|
44
54
|
/** The browser push enable step's host seams (SW path, platform hint). */
|
|
45
55
|
webPush?: WebPushSetupConfig;
|
|
46
56
|
}
|
|
@@ -79,7 +89,10 @@ export function createWebNotifications(config: NotificationsWebConfig): WebNotif
|
|
|
79
89
|
const store = createInboxStore(api);
|
|
80
90
|
const webPush = config.webPush ?? {};
|
|
81
91
|
const subscribe = config.subscribe;
|
|
82
|
-
const subscribeOption =
|
|
92
|
+
const subscribeOption = {
|
|
93
|
+
...(subscribe ? { subscribe } : {}),
|
|
94
|
+
...(config.useSignal ? { useSignal: config.useSignal } : {}),
|
|
95
|
+
};
|
|
83
96
|
|
|
84
97
|
const Bell: ComponentType<BellButtonProps> = (props) => (
|
|
85
98
|
<BellButton {...props} store={store} messages={messages} {...subscribeOption} />
|
package/src/react/hooks.ts
CHANGED
|
@@ -24,6 +24,26 @@ import {
|
|
|
24
24
|
*/
|
|
25
25
|
export type NotificationsSubscribe = (onHint: () => void) => () => void;
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* The same wiring, as a HOOK — for a host whose realtime connection lives in
|
|
29
|
+
* React context rather than in a module.
|
|
30
|
+
*
|
|
31
|
+
* `subscribe` above is supplied at FACTORY time, which is module scope, and a
|
|
32
|
+
* context-bound connection cannot be reached from there: the provider holding
|
|
33
|
+
* it is inside the tree. A host in that shape (a `<UserRealtimeProvider>` and a
|
|
34
|
+
* `useUserTopics` hook, which is the common one) had no way to pass anything at
|
|
35
|
+
* all, and the badge simply never heard an event.
|
|
36
|
+
*
|
|
37
|
+
* So this is the second door, and it is the one `@12-apps/app-shell` already
|
|
38
|
+
* uses for the same problem — its consent dialog takes a `useSignal` hook for
|
|
39
|
+
* exactly this reason. Two packages solving one problem two ways is how an
|
|
40
|
+
* adopter ends up believing the feature is unavailable to it.
|
|
41
|
+
*
|
|
42
|
+
* Called during render, so it may use context and hooks freely. Pass one or
|
|
43
|
+
* the other; passing both runs both, which is a host's business.
|
|
44
|
+
*/
|
|
45
|
+
export type NotificationsSignalHook = (onHint: () => void) => void;
|
|
46
|
+
|
|
27
47
|
export function useInboxState(store: InboxStore): InboxState {
|
|
28
48
|
return useSyncExternalStore(store.subscribe, store.getState, store.getState);
|
|
29
49
|
}
|
|
@@ -36,12 +56,22 @@ export function useInboxState(store: InboxStore): InboxState {
|
|
|
36
56
|
*/
|
|
37
57
|
export function useUnreadCount(
|
|
38
58
|
store: InboxStore,
|
|
39
|
-
options: {
|
|
59
|
+
options: {
|
|
60
|
+
enabled?: boolean;
|
|
61
|
+
subscribe?: NotificationsSubscribe;
|
|
62
|
+
useSignal?: NotificationsSignalHook;
|
|
63
|
+
} = {},
|
|
40
64
|
): number {
|
|
41
65
|
const enabled = options.enabled ?? true;
|
|
42
66
|
const subscribe = options.subscribe;
|
|
43
67
|
const { unread } = useInboxState(store);
|
|
44
68
|
|
|
69
|
+
// Called unconditionally — it is a hook, so it cannot sit behind `enabled`.
|
|
70
|
+
// The host's own hook decides what to do when there is nothing to hear.
|
|
71
|
+
options.useSignal?.(() => {
|
|
72
|
+
if (enabled) store.invalidate();
|
|
73
|
+
});
|
|
74
|
+
|
|
45
75
|
useEffect(() => {
|
|
46
76
|
if (!enabled) return;
|
|
47
77
|
store.refreshBadge();
|
package/src/react/index.ts
CHANGED
|
@@ -76,8 +76,16 @@ export interface NotificationsServerConfig {
|
|
|
76
76
|
drivers?: ExtraDrivers;
|
|
77
77
|
/** The domain events this mount can emit. */
|
|
78
78
|
generators?: readonly NotificationGenerator<never>[];
|
|
79
|
-
/**
|
|
80
|
-
|
|
79
|
+
/**
|
|
80
|
+
* Preference categories — the granularity at which a user chooses channels.
|
|
81
|
+
*
|
|
82
|
+
* REQUIRED. This defaulted to one product's four (`orders`, `payments`,
|
|
83
|
+
* `stock`, `system`), which is the host's vocabulary and not this library's:
|
|
84
|
+
* a host that omitted it rendered four rows it never chose, with its own
|
|
85
|
+
* categories absent, and nothing failed — `category` is a free string by
|
|
86
|
+
* design, so there was no layer left to notice.
|
|
87
|
+
*/
|
|
88
|
+
categories: readonly NotificationCategory[];
|
|
81
89
|
/** Override which channels a never-touched category defaults to. */
|
|
82
90
|
channelDefaults?: Partial<ChannelRow>;
|
|
83
91
|
/** The tenant plan gate, answered per emit. */
|
|
@@ -54,8 +54,16 @@ export interface WhatsAppDriverDeclaration extends DriverDeclarationBase {
|
|
|
54
54
|
accessToken?: string;
|
|
55
55
|
phoneNumberId?: string;
|
|
56
56
|
templateName?: string;
|
|
57
|
-
/**
|
|
58
|
-
|
|
57
|
+
/**
|
|
58
|
+
* The WhatsApp template's language code, e.g. `pt_BR`, `en_US`.
|
|
59
|
+
*
|
|
60
|
+
* REQUIRED: a template is registered with Meta under one language, and
|
|
61
|
+
* sending it with the wrong code is rejected by the Graph API. This defaulted
|
|
62
|
+
* to `pt_BR` — one market's answer — so a host that forgot it did not get a
|
|
63
|
+
* sensible fallback, it got somebody else's template language and a delivery
|
|
64
|
+
* failure it had no reason to expect.
|
|
65
|
+
*/
|
|
66
|
+
templateLanguage: string;
|
|
59
67
|
/** Graph API base, so a host can pin a version. */
|
|
60
68
|
graphApiBase?: string;
|
|
61
69
|
appUrl?: string;
|
|
@@ -80,7 +88,7 @@ function templatePayload(
|
|
|
80
88
|
type: 'template',
|
|
81
89
|
template: {
|
|
82
90
|
name: declaration.templateName,
|
|
83
|
-
language: { code: declaration.templateLanguage
|
|
91
|
+
language: { code: declaration.templateLanguage },
|
|
84
92
|
components: [
|
|
85
93
|
{
|
|
86
94
|
type: 'body',
|
package/src/types.ts
CHANGED
|
@@ -18,18 +18,25 @@ export const NOTIFICATION_CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'WEB_PUSH'] as
|
|
|
18
18
|
export type NotificationChannel = (typeof NOTIFICATION_CHANNELS)[number];
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
* The
|
|
22
|
-
* channels. Every notification `type` belongs to exactly one category via its
|
|
23
|
-
* generator.
|
|
21
|
+
* The preference categories are the HOST's, and required.
|
|
24
22
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
23
|
+
* There used to be a `NOTIFICATION_CATEGORIES = ['orders','payments','stock',
|
|
24
|
+
* 'system']` here — one product's set — and `taxonomyOf` fell back to it
|
|
25
|
+
* whenever a host passed none. The docstring argued the case itself: "it is
|
|
26
|
+
* product vocabulary, not machinery", and then shipped the vocabulary anyway as
|
|
27
|
+
* the default, which is the only part a forgetful host would ever see.
|
|
28
|
+
*
|
|
29
|
+
* The consequence was quiet rather than loud: the settings screen renders four
|
|
30
|
+
* rows a foreign host never chose, its own categories are absent, and every
|
|
31
|
+
* preference a user sets is filed against a taxonomy nothing else in that
|
|
32
|
+
* system uses. Nothing throws, because `category` is deliberately a free string
|
|
33
|
+
* — the packaged migration puts **no CHECK** on it, precisely because a closed
|
|
34
|
+
* set would be wrong for every host but the first. That freedom is what made
|
|
35
|
+
* the default undetectable.
|
|
36
|
+
*
|
|
37
|
+
* `channel` and `status` are different and keep their CHECKs: those ARE this
|
|
38
|
+
* library's own closed sets.
|
|
31
39
|
*/
|
|
32
|
-
export const NOTIFICATION_CATEGORIES = ['orders', 'payments', 'stock', 'system'] as const;
|
|
33
40
|
export type NotificationCategory = string;
|
|
34
41
|
|
|
35
42
|
/**
|
|
@@ -140,13 +147,22 @@ export interface NotificationTaxonomy {
|
|
|
140
147
|
categories: readonly NotificationCategory[];
|
|
141
148
|
}
|
|
142
149
|
|
|
143
|
-
/**
|
|
150
|
+
/**
|
|
151
|
+
* The taxonomy in force. `categories` is REQUIRED — see above.
|
|
152
|
+
*
|
|
153
|
+
* The empty check was already here and stays: an empty list and a missing one
|
|
154
|
+
* are the same mistake, and both now fail at assembly rather than rendering an
|
|
155
|
+
* empty settings screen or somebody else's four rows.
|
|
156
|
+
*/
|
|
144
157
|
export function taxonomyOf(config: {
|
|
145
|
-
categories
|
|
158
|
+
categories: readonly NotificationCategory[];
|
|
146
159
|
}): NotificationTaxonomy {
|
|
147
|
-
const categories = config.categories
|
|
148
|
-
if (categories.length === 0) {
|
|
149
|
-
throw new Error(
|
|
160
|
+
const categories = config.categories;
|
|
161
|
+
if (!categories || categories.length === 0) {
|
|
162
|
+
throw new Error(
|
|
163
|
+
'@12-apps/notifications: `categories` is required and must not be empty — ' +
|
|
164
|
+
'the preference categories are the host\'s product vocabulary.',
|
|
165
|
+
);
|
|
150
166
|
}
|
|
151
167
|
return { categories: [...categories] };
|
|
152
168
|
}
|