@krizaka/notifications 0.0.0-stage → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +37 -2
- package/dist/chunk-2MH7PZDT.js +88 -0
- package/dist/chunk-2MH7PZDT.js.map +1 -0
- package/dist/chunk-FQYNUK64.js +29 -0
- package/dist/chunk-FQYNUK64.js.map +1 -0
- package/dist/index.d.ts +168 -0
- package/dist/index.js +296 -0
- package/dist/index.js.map +1 -0
- package/dist/port-DptCmYwy.d.ts +122 -0
- package/dist/settings.d.ts +30 -0
- package/dist/settings.js +52 -0
- package/dist/settings.js.map +1 -0
- package/dist/testkit.d.ts +10 -0
- package/dist/testkit.js +117 -0
- package/dist/testkit.js.map +1 -0
- package/package.json +84 -3
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
|
-
#
|
|
1
|
+
# @krizaka/notifications
|
|
2
2
|
|
|
3
|
-
|
|
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":[]}
|
package/dist/index.d.ts
ADDED
|
@@ -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 };
|