@krizaka/notifications 0.0.0-stage → 0.2.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/LICENSE ADDED
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
package/README.md CHANGED
@@ -1,3 +1,38 @@
1
- # Temporary Holding Version
1
+ # @krizaka/notifications
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The bell, the channels and the preferences for every Krizaka product. One `notify` writes the in-app item and appends one outbox message per enabled channel, respecting the account's matrix and a burst cap per category. No push or realtime import (the workers that deliver a channel are the product's), no database client, no wall clock.
4
+
5
+ - **Channels.** `app` (the bell and the live event of the open pages, one switch), `push` (one message per registered device) and `email`. The outbox topics are `notifications.live`, `notifications.push` and `notifications.email`; the event is `evt.notification.requested.v1`, the name and the shape of Java's `krizaka-notifications-api` request (`channel`, `recipient`, `template`, `locale`, `variables`, plus the ids; `channel` is `EMAIL`, and `PUSH` / `LIVE` for the web). A product delivers e-mail with its own worker (`@krizaka/email` to render, `@krizaka/mail` to queue) or lets the Java service consume it.
6
+ - **The product declares its categories** and their defaults (`notificationSettings(declared)` is the registry fragment); the admin tunes the default channels and the caps without a redeploy. A `locked` category (security, the account) cannot be switched off by anyone.
7
+ - **Burst caps.** After `burstMax` notifications of one category to one account within `burstWindowSeconds`, the next ones are bell-only: the item is kept, no channel is used. The count and the insert run under a transaction-level advisory lock per account and category, so the cap holds under concurrency when `notify` gets the caller's `tx`.
8
+ - **Idempotent.** `dedupeKey` makes one fact one notification; the outbox message ids derive from the item, so a retried append is a no-op.
9
+ - **The bell.** `list` (newest first, cursor), `unreadCount`, `markRead` (the account's own items; the first `read_at` stays). An item whose `app` channel was off is kept for the history and the cap, never shown.
10
+ - **Preferences.** `preferences` (every category by channel with its source: default, account, locked), `setPreference`, `setMatrix` (a preset applied).
11
+ - **Devices.** `registerDevice` (a token belongs to one account; registering it again moves it), `revokeDevice` when the push service refuses it.
12
+ - **Privacy.** The module holds no address (the product passes `email` per call); `erase(accountId)` removes the items, choices and devices; `purge` applies the retention.
13
+
14
+ ```ts
15
+ import { createOutbox } from "@krizaka/outbox";
16
+ import { createNotifications, dbNotificationStore } from "@krizaka/notifications";
17
+ import { notificationSettings, readNotificationConfig } from "@krizaka/notifications/settings";
18
+
19
+ const declared = {
20
+ messages: { defaults: { app: true, push: true, email: true }, burst: { max: 5, windowSeconds: 3600 } },
21
+ follows: { defaults: { app: true, push: false, email: false } },
22
+ account: { locked: true, defaults: { app: true, push: true, email: true } },
23
+ };
24
+ // settingsRegistry = { ...otherSettings, ...notificationSettings(declared) }
25
+
26
+ const notifications = createNotifications({
27
+ clock,
28
+ outbox: createOutbox({ clock, settings: outboxSettings }),
29
+ config: () => readNotificationConfig(declared, (key) => settings.get(key)),
30
+ });
31
+
32
+ // in the use case's transaction
33
+ await notifications.notify(tx, { accountId, category: "messages", template: "new-message", variables: { name }, locale: "fr", email, dedupeKey: `message:${messageId}` });
34
+ ```
35
+
36
+ Tables (schema `notifications`, `notificationsSchema`): `item`, `preference`, `device`. `notificationStoreContract` (`@krizaka/notifications/testkit`) is the contract every store runs.
37
+
38
+ Apache-2.0.
@@ -0,0 +1,88 @@
1
+ import {
2
+ CHANNELS,
3
+ NOTIFICATION_CODE,
4
+ NotificationError
5
+ } from "./chunk-FQYNUK64.js";
6
+
7
+ // src/rules.ts
8
+ import { createHash } from "crypto";
9
+ var NOTIFICATION_BOUNDS = { burstMax: [1, 1e3], burstWindowSeconds: [1, 604800], retentionDays: [1, 3650] };
10
+ var NOTIFICATION_DEFAULTS = { burstMax: 20, burstWindowSeconds: 3600, retentionDays: 180 };
11
+ var CATEGORY_NAME = /^[a-z][a-zA-Z0-9]{0,39}$/;
12
+ var TEMPLATE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
13
+ var LOCALE = /^[a-z]{2}(-[A-Za-z]{2,4})?$/;
14
+ var MAX_PAYLOAD_BYTES = 8192;
15
+ var MAX_KEY = 200;
16
+ function uuidOf(seed) {
17
+ const h = createHash("sha256").update(`krizaka.notifications:${seed}`).digest();
18
+ h[6] = h[6] & 15 | 80;
19
+ h[8] = h[8] & 63 | 128;
20
+ const x = h.subarray(0, 16).toString("hex");
21
+ return `${x.slice(0, 8)}-${x.slice(8, 12)}-${x.slice(12, 16)}-${x.slice(16, 20)}-${x.slice(20, 32)}`;
22
+ }
23
+ var within = (n, [min, max]) => Number.isInteger(n) && n >= min && n <= max;
24
+ function checkConfig(config) {
25
+ for (const [name, c] of Object.entries(config.categories)) {
26
+ if (!CATEGORY_NAME.test(name)) throw new NotificationError(NOTIFICATION_CODE.badConfig, `category \xAB ${name} \xBB must be a camel-case word`);
27
+ if (!within(c.burstMax, NOTIFICATION_BOUNDS.burstMax)) throw new NotificationError(NOTIFICATION_CODE.badConfig, `${name}.burstMax is out of bounds`);
28
+ if (!within(c.burstWindowSeconds, NOTIFICATION_BOUNDS.burstWindowSeconds)) throw new NotificationError(NOTIFICATION_CODE.badConfig, `${name}.burstWindowSeconds is out of bounds`);
29
+ }
30
+ if (!within(config.retentionDays, NOTIFICATION_BOUNDS.retentionDays)) throw new NotificationError(NOTIFICATION_CODE.badConfig, "retentionDays is out of bounds");
31
+ return config;
32
+ }
33
+ function categoryOf(config, name) {
34
+ const category = Object.hasOwn(config.categories, name) ? config.categories[name] : void 0;
35
+ if (!category) throw new NotificationError(NOTIFICATION_CODE.badCategory, `unknown category \xAB ${name} \xBB`);
36
+ return category;
37
+ }
38
+ function resolveChannels(category, chosen) {
39
+ const out = { ...category.defaults };
40
+ if (category.locked) return out;
41
+ for (const channel of CHANNELS) {
42
+ const choice = chosen.get(channel);
43
+ if (choice !== void 0) out[channel] = choice;
44
+ }
45
+ return out;
46
+ }
47
+ function viewOf(config, rows) {
48
+ return Object.entries(config.categories).map(([category, def]) => {
49
+ const chosen = new Map(rows.filter((r) => r.category === category).map((r) => [r.channel, r.enabled]));
50
+ const channels = Object.fromEntries(
51
+ CHANNELS.map((channel) => {
52
+ const source = def.locked ? "locked" : chosen.has(channel) ? "account" : "default";
53
+ return [channel, { enabled: def.locked ? def.defaults[channel] : chosen.get(channel) ?? def.defaults[channel], source }];
54
+ })
55
+ );
56
+ return { category, locked: def.locked, channels };
57
+ });
58
+ }
59
+ var NOT_IN_ADDRESS = /[\s<>]/;
60
+ function isAddress(value) {
61
+ const at = value.indexOf("@");
62
+ if (at < 1 || at !== value.lastIndexOf("@") || NOT_IN_ADDRESS.test(value)) return false;
63
+ const labels = value.slice(at + 1).split(".");
64
+ return labels.length >= 2 && labels.every((label) => label !== "");
65
+ }
66
+ function checkInput(input) {
67
+ const bad = (why) => new NotificationError(NOTIFICATION_CODE.badInput, why);
68
+ if (typeof input.accountId !== "string" || input.accountId === "") throw bad("accountId is required");
69
+ if (!TEMPLATE.test(input.template)) throw bad("template is lower-case words joined by hyphens");
70
+ if (input.locale !== void 0 && !LOCALE.test(input.locale)) throw bad("locale is a language tag like fr or en-CA");
71
+ if (input.dedupeKey !== void 0 && (input.dedupeKey === "" || input.dedupeKey.length > MAX_KEY)) throw bad("dedupeKey is 1 to 200 characters");
72
+ if (input.email !== void 0 && !isAddress(input.email)) throw bad("email is not an address");
73
+ for (const [key, value] of Object.entries(input.variables ?? {})) if (typeof value !== "string") throw bad(`variable \xAB ${key} \xBB must be a string`);
74
+ if (Buffer.byteLength(JSON.stringify({ v: input.variables ?? {}, p: input.payload ?? {} })) > MAX_PAYLOAD_BYTES) throw bad("variables and payload are too big for a notification");
75
+ }
76
+
77
+ export {
78
+ NOTIFICATION_BOUNDS,
79
+ NOTIFICATION_DEFAULTS,
80
+ CATEGORY_NAME,
81
+ uuidOf,
82
+ checkConfig,
83
+ categoryOf,
84
+ resolveChannels,
85
+ viewOf,
86
+ checkInput
87
+ };
88
+ //# sourceMappingURL=chunk-2MH7PZDT.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/rules.ts"],"sourcesContent":["import { createHash } from \"node:crypto\";\n\nimport { type CategoryDefaults, type Channel, CHANNELS, NOTIFICATION_CODE, type NotificationConfig, NotificationError } from \"./port\";\n\nexport const NOTIFICATION_BOUNDS = { burstMax: [1, 1000], burstWindowSeconds: [1, 604_800], retentionDays: [1, 3650] } as const;\nexport const NOTIFICATION_DEFAULTS = { burstMax: 20, burstWindowSeconds: 3600, retentionDays: 180 } as const;\n\n/** A category name: a camel-case word (it becomes part of a setting key). */\nexport const CATEGORY_NAME = /^[a-z][a-zA-Z0-9]{0,39}$/;\nconst TEMPLATE = /^[a-z0-9]+(-[a-z0-9]+)*$/;\nconst LOCALE = /^[a-z]{2}(-[A-Za-z]{2,4})?$/;\n// @fixed: a notification is a short event, not a document; a bigger payload belongs in the product's own tables\nconst MAX_PAYLOAD_BYTES = 8192;\nconst MAX_KEY = 200;\n\n/** A stable UUID (version 5 shaped) from a seed: the same fact appended twice is one outbox message. */\nexport function uuidOf(seed: string): string {\n const h = createHash(\"sha256\").update(`krizaka.notifications:${seed}`).digest();\n h[6] = (h[6]! & 0x0f) | 0x50;\n h[8] = (h[8]! & 0x3f) | 0x80;\n const x = h.subarray(0, 16).toString(\"hex\");\n return `${x.slice(0, 8)}-${x.slice(8, 12)}-${x.slice(12, 16)}-${x.slice(16, 20)}-${x.slice(20, 32)}`;\n}\n\nconst within = (n: number, [min, max]: readonly [number, number]) => Number.isInteger(n) && n >= min && n <= max;\n\n/** @throws NotificationError when a category name, a burst cap or the retention is out of bounds. */\nexport function checkConfig(config: NotificationConfig): NotificationConfig {\n for (const [name, c] of Object.entries(config.categories)) {\n if (!CATEGORY_NAME.test(name)) throw new NotificationError(NOTIFICATION_CODE.badConfig, `category « ${name} » must be a camel-case word`);\n if (!within(c.burstMax, NOTIFICATION_BOUNDS.burstMax)) throw new NotificationError(NOTIFICATION_CODE.badConfig, `${name}.burstMax is out of bounds`);\n if (!within(c.burstWindowSeconds, NOTIFICATION_BOUNDS.burstWindowSeconds)) throw new NotificationError(NOTIFICATION_CODE.badConfig, `${name}.burstWindowSeconds is out of bounds`);\n }\n if (!within(config.retentionDays, NOTIFICATION_BOUNDS.retentionDays)) throw new NotificationError(NOTIFICATION_CODE.badConfig, \"retentionDays is out of bounds\");\n return config;\n}\n\nexport function categoryOf(config: NotificationConfig, name: string): CategoryDefaults {\n const category = Object.hasOwn(config.categories, name) ? config.categories[name] : undefined;\n if (!category) throw new NotificationError(NOTIFICATION_CODE.badCategory, `unknown category « ${name} »`);\n return category;\n}\n\n/** The channels on for an account: the category's defaults, overridden by the account's choices, unless the category is locked. */\nexport function resolveChannels(category: CategoryDefaults, chosen: ReadonlyMap<Channel, boolean>): Record<Channel, boolean> {\n const out = { ...category.defaults };\n if (category.locked) return out;\n for (const channel of CHANNELS) {\n const choice = chosen.get(channel);\n if (choice !== undefined) out[channel] = choice;\n }\n return out;\n}\n\n/** What an account sees in its settings: every category, every channel, and why. */\nexport interface PreferenceView {\n category: string;\n locked: boolean;\n channels: Record<Channel, { enabled: boolean; source: \"default\" | \"account\" | \"locked\" }>;\n}\n\nexport function viewOf(config: NotificationConfig, rows: readonly { category: string; channel: Channel; enabled: boolean }[]): PreferenceView[] {\n return Object.entries(config.categories).map(([category, def]) => {\n const chosen = new Map(rows.filter((r) => r.category === category).map((r) => [r.channel, r.enabled] as const));\n const channels = Object.fromEntries(\n CHANNELS.map((channel) => {\n const source = def.locked ? \"locked\" : chosen.has(channel) ? \"account\" : \"default\";\n return [channel, { enabled: def.locked ? def.defaults[channel] : (chosen.get(channel) ?? def.defaults[channel]), source }];\n }),\n ) as PreferenceView[\"channels\"];\n return { category, locked: def.locked, channels };\n });\n}\n\nexport interface NotifyInput {\n accountId: string;\n category: string;\n /** The template the e-mail and the push are rendered from (`new-follower`): lower-case words joined by hyphens. */\n template: string;\n /** The words' variables: strings only, the shape of Java's `NotificationRequest`. Never a secret. */\n variables?: Record<string, string>;\n /** The recipient's language; only the language is used. */\n locale?: string;\n /** Where e-mail goes. Without it the e-mail channel is skipped (the product resolves the address, this module holds none). */\n email?: string;\n /** What the bell shows besides the template and variables (a link, an actor id): JSON, small. */\n payload?: Record<string, unknown>;\n /** One event notified twice is one notification: the same key for the same fact. */\n dedupeKey?: string;\n}\n\nconst NOT_IN_ADDRESS = /[\\s<>]/;\n\n/** One `@`, a local part, and a domain of dot-separated non-empty parts: no backtracking pattern over library input. */\nfunction isAddress(value: string): boolean {\n const at = value.indexOf(\"@\");\n if (at < 1 || at !== value.lastIndexOf(\"@\") || NOT_IN_ADDRESS.test(value)) return false;\n const labels = value.slice(at + 1).split(\".\");\n return labels.length >= 2 && labels.every((label) => label !== \"\");\n}\n\n/** @throws NotificationError on the first thing wrong with an input. */\nexport function checkInput(input: NotifyInput): void {\n const bad = (why: string) => new NotificationError(NOTIFICATION_CODE.badInput, why);\n if (typeof input.accountId !== \"string\" || input.accountId === \"\") throw bad(\"accountId is required\");\n if (!TEMPLATE.test(input.template)) throw bad(\"template is lower-case words joined by hyphens\");\n if (input.locale !== undefined && !LOCALE.test(input.locale)) throw bad(\"locale is a language tag like fr or en-CA\");\n if (input.dedupeKey !== undefined && (input.dedupeKey === \"\" || input.dedupeKey.length > MAX_KEY)) throw bad(\"dedupeKey is 1 to 200 characters\");\n if (input.email !== undefined && !isAddress(input.email)) throw bad(\"email is not an address\");\n for (const [key, value] of Object.entries(input.variables ?? {})) if (typeof value !== \"string\") throw bad(`variable « ${key} » must be a string`);\n if (Buffer.byteLength(JSON.stringify({ v: input.variables ?? {}, p: input.payload ?? {} })) > MAX_PAYLOAD_BYTES) throw bad(\"variables and payload are too big for a notification\");\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,29 @@
1
+ // src/port.ts
2
+ var CHANNEL = { app: "app", push: "push", email: "email" };
3
+ var CHANNELS = [CHANNEL.app, CHANNEL.push, CHANNEL.email];
4
+ var DELIVERY = { email: "EMAIL", push: "PUSH", live: "LIVE" };
5
+ var PLATFORM = { ios: "ios", android: "android", web: "web" };
6
+ var NOTIFICATION_CODE = {
7
+ badCategory: "NOTIFICATIONS_BAD_CATEGORY",
8
+ badInput: "NOTIFICATIONS_BAD_INPUT",
9
+ locked: "NOTIFICATIONS_LOCKED",
10
+ badConfig: "NOTIFICATIONS_BAD_CONFIG"
11
+ };
12
+ var NotificationError = class extends Error {
13
+ constructor(code, message) {
14
+ super(message);
15
+ this.code = code;
16
+ this.name = "NotificationError";
17
+ }
18
+ code;
19
+ };
20
+
21
+ export {
22
+ CHANNEL,
23
+ CHANNELS,
24
+ DELIVERY,
25
+ PLATFORM,
26
+ NOTIFICATION_CODE,
27
+ NotificationError
28
+ };
29
+ //# sourceMappingURL=chunk-FQYNUK64.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/port.ts"],"sourcesContent":["/**\n * The ports of « notifications »: the bell (an item per notification), the account's preferences (a matrix of categories by\n * channels), the phones registered for push, and one outbox message per enabled channel. Generic: no push or realtime import (the\n * workers that deliver a channel are the product's, or the Java service's for e-mail), no database client, no wall clock.\n */\nexport type { Clock, Query } from \"@krizaka/db\";\n\n/**\n * The channels an account switches. `app` is the bell and the live event of the open pages (one switch, like Orochia's matrix);\n * `push` goes to the account's registered phones; `email` to its address.\n */\nexport const CHANNEL = { app: \"app\", push: \"push\", email: \"email\" } as const;\nexport type Channel = (typeof CHANNEL)[keyof typeof CHANNEL];\nexport const CHANNELS: readonly Channel[] = [CHANNEL.app, CHANNEL.push, CHANNEL.email];\n\n/** What the outbox carries: the `channel` of Java's `evt.notification.requested.v1` (EMAIL) and two more for the web (PUSH, LIVE). */\nexport const DELIVERY = { email: \"EMAIL\", push: \"PUSH\", live: \"LIVE\" } as const;\nexport type Delivery = (typeof DELIVERY)[keyof typeof DELIVERY];\n\nexport const PLATFORM = { ios: \"ios\", android: \"android\", web: \"web\" } as const;\nexport type Platform = (typeof PLATFORM)[keyof typeof PLATFORM];\n\n/** A category as the product declares it. */\nexport interface CategoryDefaults {\n /** Cannot be switched off (security, the account): its channels follow `defaults`, whatever the account chose. */\n locked: boolean;\n /** Which channels are on until the account chooses. */\n defaults: Record<Channel, boolean>;\n /** After `burstMax` notifications of this category to one account within `burstWindowSeconds`, the next ones are bell-only. */\n burstMax: number;\n burstWindowSeconds: number;\n}\n\n/** The categories (declared by the product, tuned by the admin) and the retention. Read from the settings at each call. */\nexport interface NotificationConfig {\n categories: Record<string, CategoryDefaults>;\n /** Days an item is kept before `purge`. */\n retentionDays: number;\n}\n\n/** One line of the bell. */\nexport interface Item {\n id: string;\n accountId: string;\n category: string;\n template: string;\n payload: Record<string, unknown>;\n dedupeKey: string | null;\n /** Shown in the bell. An item whose app channel was off is kept (the burst count and the history) but not shown. */\n inBell: boolean;\n createdAt: Date;\n readAt: Date | null;\n}\n\nexport interface Device {\n id: string;\n accountId: string;\n platform: Platform;\n token: string;\n createdAt: Date;\n lastSeenAt: Date;\n}\n\n/** Where the state lives. Every adapter runs `notificationStoreContract` (`@krizaka/notifications/testkit`). */\nexport interface NotificationStore {\n /**\n * Writes the item and says whether it exceeds the burst cap, ATOMICALLY per account and category (the database serialises\n * concurrent callers with a transaction-level advisory lock, so the cap holds under concurrency: pass the caller's `tx`).\n * `duplicate` when the account already has an item with this `dedupeKey`: nothing is written.\n */\n record(item: Item, burst: { max: number; since: Date }): Promise<{ duplicate: boolean; capped: boolean }>;\n /** The account's explicit choices. */\n preferences(accountId: string): Promise<{ category: string; channel: Channel; enabled: boolean }[]>;\n setPreference(accountId: string, category: string, channel: Channel, enabled: boolean, now: Date): Promise<void>;\n /** Items shown in the bell, newest first; `before` is the cursor (the last item of the previous page). */\n list(accountId: string, options: { unreadOnly: boolean; limit: number; before?: { createdAt: Date; id: string } }): Promise<Item[]>;\n unreadCount(accountId: string): Promise<number>;\n /** Marks read the account's own unread items among `ids` (all of them when `ids` is null). The first `read_at` stays. Returns how many. */\n markRead(accountId: string, ids: readonly string[] | null, now: Date): Promise<number>;\n /** A token belongs to one account: registering it again moves it, and brings back a revoked one. */\n upsertDevice(device: Device): Promise<void>;\n devices(accountId: string): Promise<Device[]>;\n /** The push service said the token is dead. */\n revokeDevice(token: string, now: Date): Promise<boolean>;\n purgeBefore(before: Date): Promise<number>;\n /** Removes everything held about the account. Returns how many rows. */\n erase(accountId: string): Promise<number>;\n}\n\nexport const NOTIFICATION_CODE = {\n badCategory: \"NOTIFICATIONS_BAD_CATEGORY\",\n badInput: \"NOTIFICATIONS_BAD_INPUT\",\n locked: \"NOTIFICATIONS_LOCKED\",\n badConfig: \"NOTIFICATIONS_BAD_CONFIG\",\n} as const;\nexport type NotificationCode = (typeof NOTIFICATION_CODE)[keyof typeof NOTIFICATION_CODE];\n\nexport class NotificationError extends Error {\n constructor(\n readonly code: NotificationCode,\n message: string,\n ) {\n super(message);\n this.name = \"NotificationError\";\n }\n}\n"],"mappings":";AAWO,IAAM,UAAU,EAAE,KAAK,OAAO,MAAM,QAAQ,OAAO,QAAQ;AAE3D,IAAM,WAA+B,CAAC,QAAQ,KAAK,QAAQ,MAAM,QAAQ,KAAK;AAG9E,IAAM,WAAW,EAAE,OAAO,SAAS,MAAM,QAAQ,MAAM,OAAO;AAG9D,IAAM,WAAW,EAAE,KAAK,OAAO,SAAS,WAAW,KAAK,MAAM;AAsE9D,IAAM,oBAAoB;AAAA,EAC/B,aAAa;AAAA,EACb,UAAU;AAAA,EACV,QAAQ;AAAA,EACR,WAAW;AACb;AAGO,IAAM,oBAAN,cAAgC,MAAM;AAAA,EAC3C,YACW,MACT,SACA;AACA,UAAM,OAAO;AAHJ;AAIT,SAAK,OAAO;AAAA,EACd;AAAA,EALW;AAMb;","names":[]}
@@ -0,0 +1,168 @@
1
+ import { N as NotificationStore, C as Channel, a as NotificationConfig, b as CategoryDefaults, D as Delivery, P as Platform, I as Item, c as Device } from './port-DptCmYwy.js';
2
+ export { d as CHANNEL, e as CHANNELS, f as DELIVERY, g as NOTIFICATION_CODE, h as NotificationCode, i as NotificationError, j as PLATFORM } from './port-DptCmYwy.js';
3
+ import * as _krizaka_db from '@krizaka/db';
4
+ import { Clock, Query } from '@krizaka/db';
5
+ export { Clock, Query } from '@krizaka/db';
6
+
7
+ /** An in-memory store, for unit tests and demos: the behaviour of the database's (the contract kit runs on both). `record` is one synchronous step, so it is atomic. */
8
+ declare function memoryNotificationStore(): NotificationStore;
9
+
10
+ declare const NOTIFICATION_BOUNDS: {
11
+ readonly burstMax: readonly [1, 1000];
12
+ readonly burstWindowSeconds: readonly [1, 604800];
13
+ readonly retentionDays: readonly [1, 3650];
14
+ };
15
+ declare const NOTIFICATION_DEFAULTS: {
16
+ readonly burstMax: 20;
17
+ readonly burstWindowSeconds: 3600;
18
+ readonly retentionDays: 180;
19
+ };
20
+ /** A stable UUID (version 5 shaped) from a seed: the same fact appended twice is one outbox message. */
21
+ declare function uuidOf(seed: string): string;
22
+ /** @throws NotificationError when a category name, a burst cap or the retention is out of bounds. */
23
+ declare function checkConfig(config: NotificationConfig): NotificationConfig;
24
+ declare function categoryOf(config: NotificationConfig, name: string): CategoryDefaults;
25
+ /** The channels on for an account: the category's defaults, overridden by the account's choices, unless the category is locked. */
26
+ declare function resolveChannels(category: CategoryDefaults, chosen: ReadonlyMap<Channel, boolean>): Record<Channel, boolean>;
27
+ /** What an account sees in its settings: every category, every channel, and why. */
28
+ interface PreferenceView {
29
+ category: string;
30
+ locked: boolean;
31
+ channels: Record<Channel, {
32
+ enabled: boolean;
33
+ source: "default" | "account" | "locked";
34
+ }>;
35
+ }
36
+ declare function viewOf(config: NotificationConfig, rows: readonly {
37
+ category: string;
38
+ channel: Channel;
39
+ enabled: boolean;
40
+ }[]): PreferenceView[];
41
+ interface NotifyInput {
42
+ accountId: string;
43
+ category: string;
44
+ /** The template the e-mail and the push are rendered from (`new-follower`): lower-case words joined by hyphens. */
45
+ template: string;
46
+ /** The words' variables: strings only, the shape of Java's `NotificationRequest`. Never a secret. */
47
+ variables?: Record<string, string>;
48
+ /** The recipient's language; only the language is used. */
49
+ locale?: string;
50
+ /** Where e-mail goes. Without it the e-mail channel is skipped (the product resolves the address, this module holds none). */
51
+ email?: string;
52
+ /** What the bell shows besides the template and variables (a link, an actor id): JSON, small. */
53
+ payload?: Record<string, unknown>;
54
+ /** One event notified twice is one notification: the same key for the same fact. */
55
+ dedupeKey?: string;
56
+ }
57
+ /** @throws NotificationError on the first thing wrong with an input. */
58
+ declare function checkInput(input: NotifyInput): void;
59
+
60
+ /** The event the outbox carries, the name Java's `krizaka-notifications-api` gives its request. */
61
+ declare const NOTIFICATION_EVENT: {
62
+ readonly requested: "evt.notification.requested.v1";
63
+ };
64
+ /** One topic per delivery, so a worker claims only what it delivers (`notifications.email`, `notifications.push`, `notifications.live`). */
65
+ declare const NOTIFICATION_TOPIC: Record<Delivery, string>;
66
+ /** What the module needs of `@krizaka/outbox` (its `Outbox` satisfies it): append in the caller's transaction. */
67
+ interface NotificationOutbox {
68
+ append(tx: Query, message: {
69
+ topic: string;
70
+ type: string;
71
+ payload: unknown;
72
+ id: string;
73
+ }): Promise<{
74
+ id: string;
75
+ appended: boolean;
76
+ }>;
77
+ }
78
+ interface NotificationDeps {
79
+ clock: Clock;
80
+ outbox: NotificationOutbox;
81
+ /** The categories and the retention, read at each call so the admin's change applies at once. */
82
+ config: NotificationConfig | (() => NotificationConfig | Promise<NotificationConfig>);
83
+ /** The store over a query (a transaction or a pool). Default: the PostgreSQL store on schema `notifications`. */
84
+ storeFor?: (query: Query) => NotificationStore;
85
+ /** The Postgres schema, when the product renamed the module's (used by the default store). */
86
+ schema?: string;
87
+ }
88
+ /** The request one delivery worker receives: Java's `NotificationRequest` (channel, recipient, template, locale, variables) plus the ids. */
89
+ interface NotificationRequest {
90
+ channel: Delivery;
91
+ /** An e-mail address (EMAIL), a device token (PUSH) or an account id (LIVE). */
92
+ recipient: string;
93
+ template: string;
94
+ locale: string | null;
95
+ variables: Record<string, string>;
96
+ notificationId: string;
97
+ accountId: string;
98
+ category: string;
99
+ /** PUSH only. */
100
+ platform?: Platform;
101
+ }
102
+ interface Notified {
103
+ /** `null` for a duplicate (same `dedupeKey`): nothing was written or appended. */
104
+ notificationId: string | null;
105
+ duplicate: boolean;
106
+ /** Past the category's burst cap: the item is kept, the channels were not used. */
107
+ capped: boolean;
108
+ /** The channels that were used. */
109
+ channels: Delivery[];
110
+ }
111
+ interface Notifications {
112
+ /**
113
+ * Writes the bell item and appends one outbox message per enabled channel, in `tx` (use the caller's transaction: it is what makes
114
+ * the burst cap hold under concurrency, and the messages leave only if the work commits).
115
+ */
116
+ notify(tx: Query, input: NotifyInput): Promise<Notified>;
117
+ /** The bell, newest first. */
118
+ list(query: Query, accountId: string, options?: {
119
+ unreadOnly?: boolean;
120
+ limit?: number;
121
+ before?: {
122
+ createdAt: Date;
123
+ id: string;
124
+ };
125
+ }): Promise<Item[]>;
126
+ unreadCount(query: Query, accountId: string): Promise<number>;
127
+ /** Marks the account's own unread items read (those in `ids`, or all). Returns how many. */
128
+ markRead(query: Query, accountId: string, ids?: readonly string[]): Promise<number>;
129
+ /** The account's matrix: every category, every channel, and whether it is the default, the account's choice or locked. */
130
+ preferences(query: Query, accountId: string): Promise<PreferenceView[]>;
131
+ /** @throws NotificationError `NOTIFICATIONS_LOCKED` for a locked category, `NOTIFICATIONS_BAD_CATEGORY` for an unknown one. */
132
+ setPreference(query: Query, accountId: string, category: string, channel: Channel, enabled: boolean): Promise<void>;
133
+ /** The whole matrix at once (a preset applied): the locked categories are left as they are. */
134
+ setMatrix(query: Query, accountId: string, matrix: Record<string, Partial<Record<Channel, boolean>>>): Promise<void>;
135
+ registerDevice(query: Query, accountId: string, device: {
136
+ platform: Platform;
137
+ token: string;
138
+ }): Promise<void>;
139
+ /** The push service refused the token: it is never used again until registered anew. */
140
+ revokeDevice(query: Query, token: string): Promise<boolean>;
141
+ devices(query: Query, accountId: string): Promise<Device[]>;
142
+ /** Removes the items older than the retention. */
143
+ purge(query: Query): Promise<number>;
144
+ /** Erasure (Loi 25): everything held about the account. */
145
+ erase(query: Query, accountId: string): Promise<number>;
146
+ }
147
+ /**
148
+ * The notifications of a product.
149
+ * @param deps the clock, the outbox, the configuration, and optionally the store and schema
150
+ * @returns the notification functions
151
+ */
152
+ declare function createNotifications(deps: NotificationDeps): Notifications;
153
+
154
+ /**
155
+ * The PostgreSQL store of « notifications » over a `Query`, in schema `notifications` (or the one the product renamed it to).
156
+ * `record` takes a transaction-level advisory lock per account and category, so the burst cap holds under concurrency ONLY inside the
157
+ * caller's transaction: pass `tx`.
158
+ */
159
+ declare function dbNotificationStore(query: Query, schema?: string): NotificationStore;
160
+
161
+ /**
162
+ * The tables of « notifications », in their own Postgres schema: `item` (the bell, and the history the burst caps count), `preference`
163
+ * (the account's explicit choices: account by category by channel) and `device` (the push tokens). The one product table referenced is
164
+ * the owner (`owner`, the account table): a deleted account takes its rows with it. Version 1 is frozen once released: a change is version 2.
165
+ */
166
+ declare const notificationsSchema: _krizaka_db.ModuleSchema;
167
+
168
+ export { CategoryDefaults, Channel, Delivery, Device, Item, NOTIFICATION_BOUNDS, NOTIFICATION_DEFAULTS, NOTIFICATION_EVENT, NOTIFICATION_TOPIC, NotificationConfig, type NotificationDeps, type NotificationOutbox, type NotificationRequest, NotificationStore, type Notifications, type Notified, type NotifyInput, Platform, type PreferenceView, categoryOf, checkConfig, checkInput, createNotifications, dbNotificationStore, memoryNotificationStore, notificationsSchema, resolveChannels, uuidOf, viewOf };