@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 +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/dist/index.js
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import {
|
|
2
|
+
NOTIFICATION_BOUNDS,
|
|
3
|
+
NOTIFICATION_DEFAULTS,
|
|
4
|
+
categoryOf,
|
|
5
|
+
checkConfig,
|
|
6
|
+
checkInput,
|
|
7
|
+
resolveChannels,
|
|
8
|
+
uuidOf,
|
|
9
|
+
viewOf
|
|
10
|
+
} from "./chunk-2MH7PZDT.js";
|
|
11
|
+
import {
|
|
12
|
+
CHANNEL,
|
|
13
|
+
CHANNELS,
|
|
14
|
+
DELIVERY,
|
|
15
|
+
NOTIFICATION_CODE,
|
|
16
|
+
NotificationError,
|
|
17
|
+
PLATFORM
|
|
18
|
+
} from "./chunk-FQYNUK64.js";
|
|
19
|
+
|
|
20
|
+
// src/memory.ts
|
|
21
|
+
var copy = (v) => ({ ...v });
|
|
22
|
+
var newestFirst = (a, b) => b.createdAt.valueOf() - a.createdAt.valueOf() || b.id.localeCompare(a.id);
|
|
23
|
+
function memoryNotificationStore() {
|
|
24
|
+
const items = /* @__PURE__ */ new Map();
|
|
25
|
+
const prefs = /* @__PURE__ */ new Map();
|
|
26
|
+
const devices = /* @__PURE__ */ new Map();
|
|
27
|
+
return {
|
|
28
|
+
async record(item, burst) {
|
|
29
|
+
const mine = [...items.values()].filter((i) => i.accountId === item.accountId);
|
|
30
|
+
if (item.dedupeKey !== null && mine.some((i) => i.dedupeKey === item.dedupeKey)) return { duplicate: true, capped: false };
|
|
31
|
+
const recent = mine.filter((i) => i.category === item.category && i.createdAt >= burst.since).length;
|
|
32
|
+
items.set(item.id, copy(item));
|
|
33
|
+
return { duplicate: false, capped: recent >= burst.max };
|
|
34
|
+
},
|
|
35
|
+
async preferences(accountId) {
|
|
36
|
+
return [...prefs.entries()].filter(([key]) => key.startsWith(`${accountId}|`)).map(([, row]) => copy(row));
|
|
37
|
+
},
|
|
38
|
+
async setPreference(accountId, category, channel, enabled) {
|
|
39
|
+
prefs.set(`${accountId}|${category}|${channel}`, { category, channel, enabled });
|
|
40
|
+
},
|
|
41
|
+
async list(accountId, { unreadOnly, limit, before }) {
|
|
42
|
+
return [...items.values()].filter((i) => i.accountId === accountId && i.inBell && (!unreadOnly || i.readAt === null)).sort(newestFirst).filter((i) => !before || i.createdAt < before.createdAt || i.createdAt.valueOf() === before.createdAt.valueOf() && i.id < before.id).slice(0, limit).map(copy);
|
|
43
|
+
},
|
|
44
|
+
async unreadCount(accountId) {
|
|
45
|
+
return [...items.values()].filter((i) => i.accountId === accountId && i.inBell && i.readAt === null).length;
|
|
46
|
+
},
|
|
47
|
+
async markRead(accountId, ids, now) {
|
|
48
|
+
let n = 0;
|
|
49
|
+
for (const item of items.values()) {
|
|
50
|
+
if (item.accountId !== accountId || !item.inBell || item.readAt !== null || ids !== null && !ids.includes(item.id)) continue;
|
|
51
|
+
item.readAt = now;
|
|
52
|
+
n += 1;
|
|
53
|
+
}
|
|
54
|
+
return n;
|
|
55
|
+
},
|
|
56
|
+
async upsertDevice(device) {
|
|
57
|
+
const known = devices.get(device.token);
|
|
58
|
+
devices.set(device.token, { ...device, id: known?.id ?? device.id, createdAt: known?.createdAt ?? device.createdAt, revokedAt: null });
|
|
59
|
+
},
|
|
60
|
+
async devices(accountId) {
|
|
61
|
+
return [...devices.values()].filter((d) => d.accountId === accountId && d.revokedAt === null).map(({ revokedAt: _revoked, ...d }) => d);
|
|
62
|
+
},
|
|
63
|
+
async revokeDevice(token, now) {
|
|
64
|
+
const d = devices.get(token);
|
|
65
|
+
if (!d || d.revokedAt !== null) return false;
|
|
66
|
+
d.revokedAt = now;
|
|
67
|
+
return true;
|
|
68
|
+
},
|
|
69
|
+
async purgeBefore(before) {
|
|
70
|
+
let n = 0;
|
|
71
|
+
for (const [id, item] of items) {
|
|
72
|
+
if (item.createdAt >= before) continue;
|
|
73
|
+
items.delete(id);
|
|
74
|
+
n += 1;
|
|
75
|
+
}
|
|
76
|
+
return n;
|
|
77
|
+
},
|
|
78
|
+
async erase(accountId) {
|
|
79
|
+
let n = 0;
|
|
80
|
+
for (const [id, item] of items) if (item.accountId === accountId && items.delete(id)) n += 1;
|
|
81
|
+
for (const key of [...prefs.keys()]) if (key.startsWith(`${accountId}|`) && prefs.delete(key)) n += 1;
|
|
82
|
+
for (const [token, d] of devices) if (d.accountId === accountId && devices.delete(token)) n += 1;
|
|
83
|
+
return n;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// src/notifications.ts
|
|
89
|
+
import { randomUUID } from "crypto";
|
|
90
|
+
import { subHours, subSeconds } from "@krizaka/time";
|
|
91
|
+
|
|
92
|
+
// src/persistence.ts
|
|
93
|
+
import { ident } from "@krizaka/db";
|
|
94
|
+
var toItem = (r) => ({ id: r.id, accountId: r.account_id, category: r.category, template: r.template, payload: r.payload, dedupeKey: r.dedupe_key, inBell: r.in_bell, createdAt: r.created_at, readAt: r.read_at });
|
|
95
|
+
var toDevice = (r) => ({ id: r.id, accountId: r.account_id, platform: r.platform, token: r.token, createdAt: r.created_at, lastSeenAt: r.last_seen_at });
|
|
96
|
+
function dbNotificationStore(query, schema = "notifications") {
|
|
97
|
+
const s = ident(schema);
|
|
98
|
+
return {
|
|
99
|
+
async record(item, burst) {
|
|
100
|
+
await query(`SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, [`${s}:${item.accountId}:${item.category}`]);
|
|
101
|
+
const [seen] = await query(`SELECT count(*)::int AS n FROM ${s}.item WHERE account_id = $1 AND category = $2 AND created_at >= $3`, [item.accountId, item.category, burst.since]);
|
|
102
|
+
const inserted = await query(
|
|
103
|
+
`INSERT INTO ${s}.item (id, account_id, category, template, payload, dedupe_key, in_bell, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
|
|
104
|
+
ON CONFLICT (account_id, dedupe_key) WHERE dedupe_key IS NOT NULL DO NOTHING RETURNING 1`,
|
|
105
|
+
[item.id, item.accountId, item.category, item.template, JSON.stringify(item.payload), item.dedupeKey, item.inBell, item.createdAt]
|
|
106
|
+
);
|
|
107
|
+
return inserted.length === 0 ? { duplicate: true, capped: false } : { duplicate: false, capped: (seen?.n ?? 0) >= burst.max };
|
|
108
|
+
},
|
|
109
|
+
async preferences(accountId) {
|
|
110
|
+
return query(`SELECT category, channel, enabled FROM ${s}.preference WHERE account_id = $1`, [accountId]);
|
|
111
|
+
},
|
|
112
|
+
async setPreference(accountId, category, channel, enabled, now) {
|
|
113
|
+
await query(`INSERT INTO ${s}.preference (account_id, category, channel, enabled, updated_at) VALUES ($1,$2,$3,$4,$5) ON CONFLICT (account_id, category, channel) DO UPDATE SET enabled = $4, updated_at = $5`, [accountId, category, channel, enabled, now]);
|
|
114
|
+
},
|
|
115
|
+
async list(accountId, { unreadOnly, limit, before }) {
|
|
116
|
+
const rows = await query(
|
|
117
|
+
`SELECT * FROM ${s}.item WHERE account_id = $1 AND in_bell AND ($2::boolean = false OR read_at IS NULL)
|
|
118
|
+
AND ($3::timestamptz IS NULL OR (created_at, id) < ($3::timestamptz, $4::uuid))
|
|
119
|
+
ORDER BY created_at DESC, id DESC LIMIT $5`,
|
|
120
|
+
[accountId, unreadOnly, before?.createdAt ?? null, before?.id ?? null, limit]
|
|
121
|
+
);
|
|
122
|
+
return rows.map(toItem);
|
|
123
|
+
},
|
|
124
|
+
async unreadCount(accountId) {
|
|
125
|
+
const [row] = await query(`SELECT count(*)::int AS n FROM ${s}.item WHERE account_id = $1 AND in_bell AND read_at IS NULL`, [accountId]);
|
|
126
|
+
return row?.n ?? 0;
|
|
127
|
+
},
|
|
128
|
+
async markRead(accountId, ids, now) {
|
|
129
|
+
const rows = await query(`UPDATE ${s}.item SET read_at = $3 WHERE account_id = $1 AND in_bell AND read_at IS NULL AND ($2::uuid[] IS NULL OR id = ANY($2::uuid[])) RETURNING 1`, [accountId, ids ? [...ids] : null, now]);
|
|
130
|
+
return rows.length;
|
|
131
|
+
},
|
|
132
|
+
async upsertDevice(d) {
|
|
133
|
+
await query(
|
|
134
|
+
`INSERT INTO ${s}.device (id, account_id, platform, token, created_at, last_seen_at) VALUES ($1,$2,$3,$4,$5,$6)
|
|
135
|
+
ON CONFLICT (token) DO UPDATE SET account_id = $2, platform = $3, last_seen_at = $6, revoked_at = NULL`,
|
|
136
|
+
[d.id, d.accountId, d.platform, d.token, d.createdAt, d.lastSeenAt]
|
|
137
|
+
);
|
|
138
|
+
},
|
|
139
|
+
async devices(accountId) {
|
|
140
|
+
return (await query(`SELECT id, account_id, platform, token, created_at, last_seen_at FROM ${s}.device WHERE account_id = $1 AND revoked_at IS NULL ORDER BY created_at, id`, [accountId])).map(toDevice);
|
|
141
|
+
},
|
|
142
|
+
async revokeDevice(token, now) {
|
|
143
|
+
return (await query(`UPDATE ${s}.device SET revoked_at = $2 WHERE token = $1 AND revoked_at IS NULL RETURNING 1`, [token, now])).length > 0;
|
|
144
|
+
},
|
|
145
|
+
async purgeBefore(before) {
|
|
146
|
+
return (await query(`DELETE FROM ${s}.item WHERE created_at < $1 RETURNING 1`, [before])).length;
|
|
147
|
+
},
|
|
148
|
+
async erase(accountId) {
|
|
149
|
+
const items = await query(`DELETE FROM ${s}.item WHERE account_id = $1 RETURNING 1`, [accountId]);
|
|
150
|
+
const prefs = await query(`DELETE FROM ${s}.preference WHERE account_id = $1 RETURNING 1`, [accountId]);
|
|
151
|
+
const devs = await query(`DELETE FROM ${s}.device WHERE account_id = $1 RETURNING 1`, [accountId]);
|
|
152
|
+
return items.length + prefs.length + devs.length;
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// src/notifications.ts
|
|
158
|
+
var NOTIFICATION_EVENT = { requested: "evt.notification.requested.v1" };
|
|
159
|
+
var NOTIFICATION_TOPIC = { [DELIVERY.email]: "notifications.email", [DELIVERY.push]: "notifications.push", [DELIVERY.live]: "notifications.live" };
|
|
160
|
+
var platforms = new Set(Object.values(PLATFORM));
|
|
161
|
+
function createNotifications(deps) {
|
|
162
|
+
const storeFor = deps.storeFor ?? ((query) => dbNotificationStore(query, deps.schema ?? "notifications"));
|
|
163
|
+
const config = async () => checkConfig(typeof deps.config === "function" ? await deps.config() : deps.config);
|
|
164
|
+
const asMap = (rows, category) => new Map(rows.filter((r) => r.category === category).map((r) => [r.channel, r.enabled]));
|
|
165
|
+
return {
|
|
166
|
+
async notify(tx, input) {
|
|
167
|
+
checkInput(input);
|
|
168
|
+
const cfg = await config();
|
|
169
|
+
const category = categoryOf(cfg, input.category);
|
|
170
|
+
const now = deps.clock.now();
|
|
171
|
+
const store = storeFor(tx);
|
|
172
|
+
const channels = resolveChannels(category, asMap(await store.preferences(input.accountId), input.category));
|
|
173
|
+
const item = { id: randomUUID(), accountId: input.accountId, category: input.category, template: input.template, payload: { variables: input.variables ?? {}, ...input.payload ?? {} }, dedupeKey: input.dedupeKey ?? null, inBell: channels.app, createdAt: now, readAt: null };
|
|
174
|
+
const { duplicate, capped } = await store.record(item, { max: category.burstMax, since: subSeconds(now, category.burstWindowSeconds) });
|
|
175
|
+
if (duplicate) return { notificationId: null, duplicate: true, capped: false, channels: [] };
|
|
176
|
+
const used = [];
|
|
177
|
+
if (!capped) {
|
|
178
|
+
const base = { template: input.template, locale: input.locale ?? null, variables: input.variables ?? {}, notificationId: item.id, accountId: input.accountId, category: input.category };
|
|
179
|
+
const send = async (request) => {
|
|
180
|
+
await deps.outbox.append(tx, { topic: NOTIFICATION_TOPIC[request.channel], type: NOTIFICATION_EVENT.requested, payload: request, id: uuidOf(`${item.id}:${request.channel}:${request.recipient}`) });
|
|
181
|
+
if (!used.includes(request.channel)) used.push(request.channel);
|
|
182
|
+
};
|
|
183
|
+
if (channels.app) await send({ ...base, channel: DELIVERY.live, recipient: input.accountId });
|
|
184
|
+
if (channels.email && input.email) await send({ ...base, channel: DELIVERY.email, recipient: input.email });
|
|
185
|
+
if (channels.push) for (const device of await store.devices(input.accountId)) await send({ ...base, channel: DELIVERY.push, recipient: device.token, platform: device.platform });
|
|
186
|
+
}
|
|
187
|
+
return { notificationId: item.id, duplicate: false, capped, channels: used };
|
|
188
|
+
},
|
|
189
|
+
list: (query, accountId, options = {}) => storeFor(query).list(accountId, { unreadOnly: options.unreadOnly ?? false, limit: options.limit ?? 30, ...options.before ? { before: options.before } : {} }),
|
|
190
|
+
unreadCount: (query, accountId) => storeFor(query).unreadCount(accountId),
|
|
191
|
+
markRead: (query, accountId, ids) => storeFor(query).markRead(accountId, ids ?? null, deps.clock.now()),
|
|
192
|
+
async preferences(query, accountId) {
|
|
193
|
+
return viewOf(await config(), await storeFor(query).preferences(accountId));
|
|
194
|
+
},
|
|
195
|
+
async setPreference(query, accountId, category, channel, enabled) {
|
|
196
|
+
const def = categoryOf(await config(), category);
|
|
197
|
+
if (def.locked) throw new NotificationError(NOTIFICATION_CODE.locked, `category \xAB ${category} \xBB cannot be switched off`);
|
|
198
|
+
if (!Object.values(CHANNEL).includes(channel)) throw new NotificationError(NOTIFICATION_CODE.badInput, `unknown channel \xAB ${channel} \xBB`);
|
|
199
|
+
await storeFor(query).setPreference(accountId, category, channel, enabled, deps.clock.now());
|
|
200
|
+
},
|
|
201
|
+
async setMatrix(query, accountId, matrix) {
|
|
202
|
+
const cfg = await config();
|
|
203
|
+
for (const category of Object.keys(matrix)) categoryOf(cfg, category);
|
|
204
|
+
const store = storeFor(query);
|
|
205
|
+
for (const [category, row] of Object.entries(matrix)) {
|
|
206
|
+
if (cfg.categories[category].locked) continue;
|
|
207
|
+
for (const [channel, enabled] of Object.entries(row)) {
|
|
208
|
+
if (!Object.values(CHANNEL).includes(channel)) throw new NotificationError(NOTIFICATION_CODE.badInput, `unknown channel \xAB ${channel} \xBB`);
|
|
209
|
+
if (typeof enabled === "boolean") await store.setPreference(accountId, category, channel, enabled, deps.clock.now());
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
async registerDevice(query, accountId, device) {
|
|
214
|
+
if (!platforms.has(device.platform) || device.token.trim() === "" || device.token.length > 4096) throw new NotificationError(NOTIFICATION_CODE.badInput, "a device is a platform (ios, android, web) and a token");
|
|
215
|
+
const now = deps.clock.now();
|
|
216
|
+
await storeFor(query).upsertDevice({ id: randomUUID(), accountId, platform: device.platform, token: device.token, createdAt: now, lastSeenAt: now });
|
|
217
|
+
},
|
|
218
|
+
revokeDevice: (query, token) => storeFor(query).revokeDevice(token, deps.clock.now()),
|
|
219
|
+
devices: (query, accountId) => storeFor(query).devices(accountId),
|
|
220
|
+
async purge(query) {
|
|
221
|
+
const cfg = await config();
|
|
222
|
+
return storeFor(query).purgeBefore(subHours(deps.clock.now(), cfg.retentionDays * 24));
|
|
223
|
+
},
|
|
224
|
+
erase: (query, accountId) => storeFor(query).erase(accountId)
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// src/schema.ts
|
|
229
|
+
import { defineModule } from "@krizaka/db";
|
|
230
|
+
var notificationsSchema = defineModule({
|
|
231
|
+
module: "notifications",
|
|
232
|
+
tables: ["owner"],
|
|
233
|
+
migrations: [
|
|
234
|
+
{
|
|
235
|
+
version: 1,
|
|
236
|
+
name: "items, preferences and devices",
|
|
237
|
+
sql: (c) => `
|
|
238
|
+
CREATE TABLE ${c.schema}.item (
|
|
239
|
+
id uuid PRIMARY KEY,
|
|
240
|
+
account_id uuid NOT NULL REFERENCES ${c.tables.owner}(id) ON DELETE CASCADE,
|
|
241
|
+
category text NOT NULL CHECK (category ~ '^[a-z][a-zA-Z0-9]{0,39}$'),
|
|
242
|
+
template text NOT NULL CHECK (template ~ '^[a-z0-9]+(-[a-z0-9]+)*$'),
|
|
243
|
+
payload jsonb NOT NULL,
|
|
244
|
+
dedupe_key text CHECK (dedupe_key IS NULL OR char_length(dedupe_key) BETWEEN 1 AND 200),
|
|
245
|
+
in_bell boolean NOT NULL,
|
|
246
|
+
created_at timestamptz NOT NULL,
|
|
247
|
+
read_at timestamptz
|
|
248
|
+
);
|
|
249
|
+
CREATE UNIQUE INDEX item_dedupe ON ${c.schema}.item (account_id, dedupe_key) WHERE dedupe_key IS NOT NULL;
|
|
250
|
+
CREATE INDEX item_bell ON ${c.schema}.item (account_id, created_at DESC, id DESC) WHERE in_bell;
|
|
251
|
+
CREATE INDEX item_burst ON ${c.schema}.item (account_id, category, created_at);
|
|
252
|
+
CREATE INDEX item_created ON ${c.schema}.item (created_at);
|
|
253
|
+
CREATE TABLE ${c.schema}.preference (
|
|
254
|
+
account_id uuid NOT NULL REFERENCES ${c.tables.owner}(id) ON DELETE CASCADE,
|
|
255
|
+
category text NOT NULL CHECK (category ~ '^[a-z][a-zA-Z0-9]{0,39}$'),
|
|
256
|
+
channel text NOT NULL CHECK (channel IN ('app', 'push', 'email')),
|
|
257
|
+
enabled boolean NOT NULL,
|
|
258
|
+
updated_at timestamptz NOT NULL,
|
|
259
|
+
PRIMARY KEY (account_id, category, channel)
|
|
260
|
+
);
|
|
261
|
+
CREATE TABLE ${c.schema}.device (
|
|
262
|
+
id uuid PRIMARY KEY,
|
|
263
|
+
account_id uuid NOT NULL REFERENCES ${c.tables.owner}(id) ON DELETE CASCADE,
|
|
264
|
+
platform text NOT NULL CHECK (platform IN ('ios', 'android', 'web')),
|
|
265
|
+
token text NOT NULL UNIQUE CHECK (char_length(token) BETWEEN 1 AND 4096),
|
|
266
|
+
created_at timestamptz NOT NULL,
|
|
267
|
+
last_seen_at timestamptz NOT NULL,
|
|
268
|
+
revoked_at timestamptz
|
|
269
|
+
);
|
|
270
|
+
CREATE INDEX device_account ON ${c.schema}.device (account_id) WHERE revoked_at IS NULL;`
|
|
271
|
+
}
|
|
272
|
+
]
|
|
273
|
+
});
|
|
274
|
+
export {
|
|
275
|
+
CHANNEL,
|
|
276
|
+
CHANNELS,
|
|
277
|
+
DELIVERY,
|
|
278
|
+
NOTIFICATION_BOUNDS,
|
|
279
|
+
NOTIFICATION_CODE,
|
|
280
|
+
NOTIFICATION_DEFAULTS,
|
|
281
|
+
NOTIFICATION_EVENT,
|
|
282
|
+
NOTIFICATION_TOPIC,
|
|
283
|
+
NotificationError,
|
|
284
|
+
PLATFORM,
|
|
285
|
+
categoryOf,
|
|
286
|
+
checkConfig,
|
|
287
|
+
checkInput,
|
|
288
|
+
createNotifications,
|
|
289
|
+
dbNotificationStore,
|
|
290
|
+
memoryNotificationStore,
|
|
291
|
+
notificationsSchema,
|
|
292
|
+
resolveChannels,
|
|
293
|
+
uuidOf,
|
|
294
|
+
viewOf
|
|
295
|
+
};
|
|
296
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/memory.ts","../src/notifications.ts","../src/persistence.ts","../src/schema.ts"],"sourcesContent":["import type { Channel, Device, Item, NotificationStore } from \"./port\";\n\nconst copy = <T extends object>(v: T): T => ({ ...v });\nconst newestFirst = (a: Item, b: Item) => b.createdAt.valueOf() - a.createdAt.valueOf() || b.id.localeCompare(a.id);\n\n/** 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. */\nexport function memoryNotificationStore(): NotificationStore {\n const items = new Map<string, Item>();\n const prefs = new Map<string, { category: string; channel: Channel; enabled: boolean }>();\n const devices = new Map<string, Device & { revokedAt: Date | null }>();\n return {\n async record(item, burst) {\n const mine = [...items.values()].filter((i) => i.accountId === item.accountId);\n if (item.dedupeKey !== null && mine.some((i) => i.dedupeKey === item.dedupeKey)) return { duplicate: true, capped: false };\n const recent = mine.filter((i) => i.category === item.category && i.createdAt >= burst.since).length;\n items.set(item.id, copy(item));\n return { duplicate: false, capped: recent >= burst.max };\n },\n async preferences(accountId) {\n return [...prefs.entries()].filter(([key]) => key.startsWith(`${accountId}|`)).map(([, row]) => copy(row));\n },\n async setPreference(accountId, category, channel, enabled) {\n prefs.set(`${accountId}|${category}|${channel}`, { category, channel, enabled });\n },\n async list(accountId, { unreadOnly, limit, before }) {\n return [...items.values()]\n .filter((i) => i.accountId === accountId && i.inBell && (!unreadOnly || i.readAt === null))\n .sort(newestFirst)\n .filter((i) => !before || i.createdAt < before.createdAt || (i.createdAt.valueOf() === before.createdAt.valueOf() && i.id < before.id))\n .slice(0, limit)\n .map(copy);\n },\n async unreadCount(accountId) {\n return [...items.values()].filter((i) => i.accountId === accountId && i.inBell && i.readAt === null).length;\n },\n async markRead(accountId, ids, now) {\n let n = 0;\n for (const item of items.values()) {\n if (item.accountId !== accountId || !item.inBell || item.readAt !== null || (ids !== null && !ids.includes(item.id))) continue;\n item.readAt = now;\n n += 1;\n }\n return n;\n },\n async upsertDevice(device) {\n const known = devices.get(device.token);\n devices.set(device.token, { ...device, id: known?.id ?? device.id, createdAt: known?.createdAt ?? device.createdAt, revokedAt: null });\n },\n async devices(accountId) {\n return [...devices.values()].filter((d) => d.accountId === accountId && d.revokedAt === null).map(({ revokedAt: _revoked, ...d }) => d);\n },\n async revokeDevice(token, now) {\n const d = devices.get(token);\n if (!d || d.revokedAt !== null) return false;\n d.revokedAt = now;\n return true;\n },\n async purgeBefore(before) {\n let n = 0;\n for (const [id, item] of items) {\n if (item.createdAt >= before) continue;\n items.delete(id);\n n += 1;\n }\n return n;\n },\n async erase(accountId) {\n let n = 0;\n for (const [id, item] of items) if (item.accountId === accountId && items.delete(id)) n += 1;\n for (const key of [...prefs.keys()]) if (key.startsWith(`${accountId}|`) && prefs.delete(key)) n += 1;\n for (const [token, d] of devices) if (d.accountId === accountId && devices.delete(token)) n += 1;\n return n;\n },\n };\n}\n","import { randomUUID } from \"node:crypto\";\n\nimport { subHours, subSeconds } from \"@krizaka/time\";\n\nimport { dbNotificationStore } from \"./persistence\";\nimport { CHANNEL, type Channel, type Clock, DELIVERY, type Delivery, type Device, type Item, NOTIFICATION_CODE, type NotificationConfig, NotificationError, type NotificationStore, PLATFORM, type Platform, type Query } from \"./port\";\nimport { categoryOf, checkConfig, checkInput, type NotifyInput, type PreferenceView, resolveChannels, uuidOf, viewOf } from \"./rules\";\n\n/** The event the outbox carries, the name Java's `krizaka-notifications-api` gives its request. */\nexport const NOTIFICATION_EVENT = { requested: \"evt.notification.requested.v1\" } as const;\n\n/** One topic per delivery, so a worker claims only what it delivers (`notifications.email`, `notifications.push`, `notifications.live`). */\nexport const NOTIFICATION_TOPIC: Record<Delivery, string> = { [DELIVERY.email]: \"notifications.email\", [DELIVERY.push]: \"notifications.push\", [DELIVERY.live]: \"notifications.live\" };\n\n/** What the module needs of `@krizaka/outbox` (its `Outbox` satisfies it): append in the caller's transaction. */\nexport interface NotificationOutbox {\n append(tx: Query, message: { topic: string; type: string; payload: unknown; id: string }): Promise<{ id: string; appended: boolean }>;\n}\n\nexport interface NotificationDeps {\n clock: Clock;\n outbox: NotificationOutbox;\n /** The categories and the retention, read at each call so the admin's change applies at once. */\n config: NotificationConfig | (() => NotificationConfig | Promise<NotificationConfig>);\n /** The store over a query (a transaction or a pool). Default: the PostgreSQL store on schema `notifications`. */\n storeFor?: (query: Query) => NotificationStore;\n /** The Postgres schema, when the product renamed the module's (used by the default store). */\n schema?: string;\n}\n\n/** The request one delivery worker receives: Java's `NotificationRequest` (channel, recipient, template, locale, variables) plus the ids. */\nexport interface NotificationRequest {\n channel: Delivery;\n /** An e-mail address (EMAIL), a device token (PUSH) or an account id (LIVE). */\n recipient: string;\n template: string;\n locale: string | null;\n variables: Record<string, string>;\n notificationId: string;\n accountId: string;\n category: string;\n /** PUSH only. */\n platform?: Platform;\n}\n\nexport interface Notified {\n /** `null` for a duplicate (same `dedupeKey`): nothing was written or appended. */\n notificationId: string | null;\n duplicate: boolean;\n /** Past the category's burst cap: the item is kept, the channels were not used. */\n capped: boolean;\n /** The channels that were used. */\n channels: Delivery[];\n}\n\nexport interface Notifications {\n /**\n * Writes the bell item and appends one outbox message per enabled channel, in `tx` (use the caller's transaction: it is what makes\n * the burst cap hold under concurrency, and the messages leave only if the work commits).\n */\n notify(tx: Query, input: NotifyInput): Promise<Notified>;\n /** The bell, newest first. */\n list(query: Query, accountId: string, options?: { unreadOnly?: boolean; limit?: number; before?: { createdAt: Date; id: string } }): Promise<Item[]>;\n unreadCount(query: Query, accountId: string): Promise<number>;\n /** Marks the account's own unread items read (those in `ids`, or all). Returns how many. */\n markRead(query: Query, accountId: string, ids?: readonly string[]): Promise<number>;\n /** The account's matrix: every category, every channel, and whether it is the default, the account's choice or locked. */\n preferences(query: Query, accountId: string): Promise<PreferenceView[]>;\n /** @throws NotificationError `NOTIFICATIONS_LOCKED` for a locked category, `NOTIFICATIONS_BAD_CATEGORY` for an unknown one. */\n setPreference(query: Query, accountId: string, category: string, channel: Channel, enabled: boolean): Promise<void>;\n /** The whole matrix at once (a preset applied): the locked categories are left as they are. */\n setMatrix(query: Query, accountId: string, matrix: Record<string, Partial<Record<Channel, boolean>>>): Promise<void>;\n registerDevice(query: Query, accountId: string, device: { platform: Platform; token: string }): Promise<void>;\n /** The push service refused the token: it is never used again until registered anew. */\n revokeDevice(query: Query, token: string): Promise<boolean>;\n devices(query: Query, accountId: string): Promise<Device[]>;\n /** Removes the items older than the retention. */\n purge(query: Query): Promise<number>;\n /** Erasure (Loi 25): everything held about the account. */\n erase(query: Query, accountId: string): Promise<number>;\n}\n\nconst platforms = new Set<string>(Object.values(PLATFORM));\n\n/**\n * The notifications of a product.\n * @param deps the clock, the outbox, the configuration, and optionally the store and schema\n * @returns the notification functions\n */\nexport function createNotifications(deps: NotificationDeps): Notifications {\n const storeFor = deps.storeFor ?? ((query: Query) => dbNotificationStore(query, deps.schema ?? \"notifications\"));\n const config = async () => checkConfig(typeof deps.config === \"function\" ? await deps.config() : deps.config);\n const asMap = (rows: { category: string; channel: Channel; enabled: boolean }[], category: string) => new Map(rows.filter((r) => r.category === category).map((r) => [r.channel, r.enabled] as const));\n return {\n async notify(tx, input) {\n checkInput(input);\n const cfg = await config();\n const category = categoryOf(cfg, input.category);\n const now = deps.clock.now();\n const store = storeFor(tx);\n const channels = resolveChannels(category, asMap(await store.preferences(input.accountId), input.category));\n const item: Item = { id: randomUUID(), accountId: input.accountId, category: input.category, template: input.template, payload: { variables: input.variables ?? {}, ...(input.payload ?? {}) }, dedupeKey: input.dedupeKey ?? null, inBell: channels.app, createdAt: now, readAt: null };\n const { duplicate, capped } = await store.record(item, { max: category.burstMax, since: subSeconds(now, category.burstWindowSeconds) });\n if (duplicate) return { notificationId: null, duplicate: true, capped: false, channels: [] };\n const used: Delivery[] = [];\n if (!capped) {\n const base = { template: input.template, locale: input.locale ?? null, variables: input.variables ?? {}, notificationId: item.id, accountId: input.accountId, category: input.category };\n const send = async (request: NotificationRequest) => {\n await deps.outbox.append(tx, { topic: NOTIFICATION_TOPIC[request.channel], type: NOTIFICATION_EVENT.requested, payload: request, id: uuidOf(`${item.id}:${request.channel}:${request.recipient}`) });\n if (!used.includes(request.channel)) used.push(request.channel);\n };\n if (channels.app) await send({ ...base, channel: DELIVERY.live, recipient: input.accountId });\n if (channels.email && input.email) await send({ ...base, channel: DELIVERY.email, recipient: input.email });\n if (channels.push) for (const device of await store.devices(input.accountId)) await send({ ...base, channel: DELIVERY.push, recipient: device.token, platform: device.platform });\n }\n return { notificationId: item.id, duplicate: false, capped, channels: used };\n },\n list: (query, accountId, options = {}) => storeFor(query).list(accountId, { unreadOnly: options.unreadOnly ?? false, limit: options.limit ?? 30, ...(options.before ? { before: options.before } : {}) }),\n unreadCount: (query, accountId) => storeFor(query).unreadCount(accountId),\n markRead: (query, accountId, ids) => storeFor(query).markRead(accountId, ids ?? null, deps.clock.now()),\n async preferences(query, accountId) {\n return viewOf(await config(), await storeFor(query).preferences(accountId));\n },\n async setPreference(query, accountId, category, channel, enabled) {\n const def = categoryOf(await config(), category);\n if (def.locked) throw new NotificationError(NOTIFICATION_CODE.locked, `category « ${category} » cannot be switched off`);\n if (!Object.values(CHANNEL).includes(channel)) throw new NotificationError(NOTIFICATION_CODE.badInput, `unknown channel « ${channel} »`);\n await storeFor(query).setPreference(accountId, category, channel, enabled, deps.clock.now());\n },\n async setMatrix(query, accountId, matrix) {\n const cfg = await config();\n for (const category of Object.keys(matrix)) categoryOf(cfg, category);\n const store = storeFor(query);\n for (const [category, row] of Object.entries(matrix)) {\n if (cfg.categories[category]!.locked) continue;\n for (const [channel, enabled] of Object.entries(row)) {\n if (!Object.values(CHANNEL).includes(channel as Channel)) throw new NotificationError(NOTIFICATION_CODE.badInput, `unknown channel « ${channel} »`);\n if (typeof enabled === \"boolean\") await store.setPreference(accountId, category, channel as Channel, enabled, deps.clock.now());\n }\n }\n },\n async registerDevice(query, accountId, device) {\n if (!platforms.has(device.platform) || device.token.trim() === \"\" || device.token.length > 4096) throw new NotificationError(NOTIFICATION_CODE.badInput, \"a device is a platform (ios, android, web) and a token\");\n const now = deps.clock.now();\n await storeFor(query).upsertDevice({ id: randomUUID(), accountId, platform: device.platform, token: device.token, createdAt: now, lastSeenAt: now });\n },\n revokeDevice: (query, token) => storeFor(query).revokeDevice(token, deps.clock.now()),\n devices: (query, accountId) => storeFor(query).devices(accountId),\n async purge(query) {\n const cfg = await config();\n return storeFor(query).purgeBefore(subHours(deps.clock.now(), cfg.retentionDays * 24));\n },\n erase: (query, accountId) => storeFor(query).erase(accountId),\n };\n}\n","import { ident, type Query } from \"@krizaka/db\";\n\nimport type { Channel, Device, Item, NotificationStore, Platform } from \"./port\";\n\ninterface ItemRow {\n id: string;\n account_id: string;\n category: string;\n template: string;\n payload: Record<string, unknown>;\n dedupe_key: string | null;\n in_bell: boolean;\n created_at: Date;\n read_at: Date | null;\n}\ninterface DeviceRow {\n id: string;\n account_id: string;\n platform: Platform;\n token: string;\n created_at: Date;\n last_seen_at: Date;\n}\n\nconst toItem = (r: ItemRow): Item => ({ id: r.id, accountId: r.account_id, category: r.category, template: r.template, payload: r.payload, dedupeKey: r.dedupe_key, inBell: r.in_bell, createdAt: r.created_at, readAt: r.read_at });\nconst toDevice = (r: DeviceRow): Device => ({ id: r.id, accountId: r.account_id, platform: r.platform, token: r.token, createdAt: r.created_at, lastSeenAt: r.last_seen_at });\n\n/**\n * The PostgreSQL store of « notifications » over a `Query`, in schema `notifications` (or the one the product renamed it to).\n * `record` takes a transaction-level advisory lock per account and category, so the burst cap holds under concurrency ONLY inside the\n * caller's transaction: pass `tx`.\n */\nexport function dbNotificationStore(query: Query, schema = \"notifications\"): NotificationStore {\n const s = ident(schema);\n return {\n async record(item, burst) {\n await query(`SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, [`${s}:${item.accountId}:${item.category}`]);\n const [seen] = await query<{ n: number }>(`SELECT count(*)::int AS n FROM ${s}.item WHERE account_id = $1 AND category = $2 AND created_at >= $3`, [item.accountId, item.category, burst.since]);\n const inserted = await query(\n `INSERT INTO ${s}.item (id, account_id, category, template, payload, dedupe_key, in_bell, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)\n ON CONFLICT (account_id, dedupe_key) WHERE dedupe_key IS NOT NULL DO NOTHING RETURNING 1`,\n [item.id, item.accountId, item.category, item.template, JSON.stringify(item.payload), item.dedupeKey, item.inBell, item.createdAt],\n );\n return inserted.length === 0 ? { duplicate: true, capped: false } : { duplicate: false, capped: (seen?.n ?? 0) >= burst.max };\n },\n async preferences(accountId) {\n return query<{ category: string; channel: Channel; enabled: boolean }>(`SELECT category, channel, enabled FROM ${s}.preference WHERE account_id = $1`, [accountId]);\n },\n async setPreference(accountId, category, channel, enabled, now) {\n await query(`INSERT INTO ${s}.preference (account_id, category, channel, enabled, updated_at) VALUES ($1,$2,$3,$4,$5) ON CONFLICT (account_id, category, channel) DO UPDATE SET enabled = $4, updated_at = $5`, [accountId, category, channel, enabled, now]);\n },\n async list(accountId, { unreadOnly, limit, before }) {\n const rows = await query<ItemRow>(\n `SELECT * FROM ${s}.item WHERE account_id = $1 AND in_bell AND ($2::boolean = false OR read_at IS NULL)\n AND ($3::timestamptz IS NULL OR (created_at, id) < ($3::timestamptz, $4::uuid))\n ORDER BY created_at DESC, id DESC LIMIT $5`,\n [accountId, unreadOnly, before?.createdAt ?? null, before?.id ?? null, limit],\n );\n return rows.map(toItem);\n },\n async unreadCount(accountId) {\n const [row] = await query<{ n: number }>(`SELECT count(*)::int AS n FROM ${s}.item WHERE account_id = $1 AND in_bell AND read_at IS NULL`, [accountId]);\n return row?.n ?? 0;\n },\n async markRead(accountId, ids, now) {\n const rows = await query(`UPDATE ${s}.item SET read_at = $3 WHERE account_id = $1 AND in_bell AND read_at IS NULL AND ($2::uuid[] IS NULL OR id = ANY($2::uuid[])) RETURNING 1`, [accountId, ids ? [...ids] : null, now]);\n return rows.length;\n },\n async upsertDevice(d) {\n await query(\n `INSERT INTO ${s}.device (id, account_id, platform, token, created_at, last_seen_at) VALUES ($1,$2,$3,$4,$5,$6)\n ON CONFLICT (token) DO UPDATE SET account_id = $2, platform = $3, last_seen_at = $6, revoked_at = NULL`,\n [d.id, d.accountId, d.platform, d.token, d.createdAt, d.lastSeenAt],\n );\n },\n async devices(accountId) {\n return (await query<DeviceRow>(`SELECT id, account_id, platform, token, created_at, last_seen_at FROM ${s}.device WHERE account_id = $1 AND revoked_at IS NULL ORDER BY created_at, id`, [accountId])).map(toDevice);\n },\n async revokeDevice(token, now) {\n return (await query(`UPDATE ${s}.device SET revoked_at = $2 WHERE token = $1 AND revoked_at IS NULL RETURNING 1`, [token, now])).length > 0;\n },\n async purgeBefore(before) {\n return (await query(`DELETE FROM ${s}.item WHERE created_at < $1 RETURNING 1`, [before])).length;\n },\n async erase(accountId) {\n const items = await query(`DELETE FROM ${s}.item WHERE account_id = $1 RETURNING 1`, [accountId]);\n const prefs = await query(`DELETE FROM ${s}.preference WHERE account_id = $1 RETURNING 1`, [accountId]);\n const devs = await query(`DELETE FROM ${s}.device WHERE account_id = $1 RETURNING 1`, [accountId]);\n return items.length + prefs.length + devs.length;\n },\n };\n}\n","import { defineModule } from \"@krizaka/db\";\n\n/**\n * The tables of « notifications », in their own Postgres schema: `item` (the bell, and the history the burst caps count), `preference`\n * (the account's explicit choices: account by category by channel) and `device` (the push tokens). The one product table referenced is\n * 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.\n */\nexport const notificationsSchema = defineModule({\n module: \"notifications\",\n tables: [\"owner\"],\n migrations: [\n {\n version: 1,\n name: \"items, preferences and devices\",\n sql: (c) => `\nCREATE TABLE ${c.schema}.item (\n id uuid PRIMARY KEY,\n account_id uuid NOT NULL REFERENCES ${c.tables.owner}(id) ON DELETE CASCADE,\n category text NOT NULL CHECK (category ~ '^[a-z][a-zA-Z0-9]{0,39}$'),\n template text NOT NULL CHECK (template ~ '^[a-z0-9]+(-[a-z0-9]+)*$'),\n payload jsonb NOT NULL,\n dedupe_key text CHECK (dedupe_key IS NULL OR char_length(dedupe_key) BETWEEN 1 AND 200),\n in_bell boolean NOT NULL,\n created_at timestamptz NOT NULL,\n read_at timestamptz\n);\nCREATE UNIQUE INDEX item_dedupe ON ${c.schema}.item (account_id, dedupe_key) WHERE dedupe_key IS NOT NULL;\nCREATE INDEX item_bell ON ${c.schema}.item (account_id, created_at DESC, id DESC) WHERE in_bell;\nCREATE INDEX item_burst ON ${c.schema}.item (account_id, category, created_at);\nCREATE INDEX item_created ON ${c.schema}.item (created_at);\nCREATE TABLE ${c.schema}.preference (\n account_id uuid NOT NULL REFERENCES ${c.tables.owner}(id) ON DELETE CASCADE,\n category text NOT NULL CHECK (category ~ '^[a-z][a-zA-Z0-9]{0,39}$'),\n channel text NOT NULL CHECK (channel IN ('app', 'push', 'email')),\n enabled boolean NOT NULL,\n updated_at timestamptz NOT NULL,\n PRIMARY KEY (account_id, category, channel)\n);\nCREATE TABLE ${c.schema}.device (\n id uuid PRIMARY KEY,\n account_id uuid NOT NULL REFERENCES ${c.tables.owner}(id) ON DELETE CASCADE,\n platform text NOT NULL CHECK (platform IN ('ios', 'android', 'web')),\n token text NOT NULL UNIQUE CHECK (char_length(token) BETWEEN 1 AND 4096),\n created_at timestamptz NOT NULL,\n last_seen_at timestamptz NOT NULL,\n revoked_at timestamptz\n);\nCREATE INDEX device_account ON ${c.schema}.device (account_id) WHERE revoked_at IS NULL;`,\n },\n ],\n});\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ports of « notifications »: the bell (an item per notification), the account's preferences (a matrix of categories by
|
|
3
|
+
* channels), the phones registered for push, and one outbox message per enabled channel. Generic: no push or realtime import (the
|
|
4
|
+
* workers that deliver a channel are the product's, or the Java service's for e-mail), no database client, no wall clock.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The channels an account switches. `app` is the bell and the live event of the open pages (one switch, like Orochia's matrix);
|
|
9
|
+
* `push` goes to the account's registered phones; `email` to its address.
|
|
10
|
+
*/
|
|
11
|
+
declare const CHANNEL: {
|
|
12
|
+
readonly app: "app";
|
|
13
|
+
readonly push: "push";
|
|
14
|
+
readonly email: "email";
|
|
15
|
+
};
|
|
16
|
+
type Channel = (typeof CHANNEL)[keyof typeof CHANNEL];
|
|
17
|
+
declare const CHANNELS: readonly Channel[];
|
|
18
|
+
/** What the outbox carries: the `channel` of Java's `evt.notification.requested.v1` (EMAIL) and two more for the web (PUSH, LIVE). */
|
|
19
|
+
declare const DELIVERY: {
|
|
20
|
+
readonly email: "EMAIL";
|
|
21
|
+
readonly push: "PUSH";
|
|
22
|
+
readonly live: "LIVE";
|
|
23
|
+
};
|
|
24
|
+
type Delivery = (typeof DELIVERY)[keyof typeof DELIVERY];
|
|
25
|
+
declare const PLATFORM: {
|
|
26
|
+
readonly ios: "ios";
|
|
27
|
+
readonly android: "android";
|
|
28
|
+
readonly web: "web";
|
|
29
|
+
};
|
|
30
|
+
type Platform = (typeof PLATFORM)[keyof typeof PLATFORM];
|
|
31
|
+
/** A category as the product declares it. */
|
|
32
|
+
interface CategoryDefaults {
|
|
33
|
+
/** Cannot be switched off (security, the account): its channels follow `defaults`, whatever the account chose. */
|
|
34
|
+
locked: boolean;
|
|
35
|
+
/** Which channels are on until the account chooses. */
|
|
36
|
+
defaults: Record<Channel, boolean>;
|
|
37
|
+
/** After `burstMax` notifications of this category to one account within `burstWindowSeconds`, the next ones are bell-only. */
|
|
38
|
+
burstMax: number;
|
|
39
|
+
burstWindowSeconds: number;
|
|
40
|
+
}
|
|
41
|
+
/** The categories (declared by the product, tuned by the admin) and the retention. Read from the settings at each call. */
|
|
42
|
+
interface NotificationConfig {
|
|
43
|
+
categories: Record<string, CategoryDefaults>;
|
|
44
|
+
/** Days an item is kept before `purge`. */
|
|
45
|
+
retentionDays: number;
|
|
46
|
+
}
|
|
47
|
+
/** One line of the bell. */
|
|
48
|
+
interface Item {
|
|
49
|
+
id: string;
|
|
50
|
+
accountId: string;
|
|
51
|
+
category: string;
|
|
52
|
+
template: string;
|
|
53
|
+
payload: Record<string, unknown>;
|
|
54
|
+
dedupeKey: string | null;
|
|
55
|
+
/** Shown in the bell. An item whose app channel was off is kept (the burst count and the history) but not shown. */
|
|
56
|
+
inBell: boolean;
|
|
57
|
+
createdAt: Date;
|
|
58
|
+
readAt: Date | null;
|
|
59
|
+
}
|
|
60
|
+
interface Device {
|
|
61
|
+
id: string;
|
|
62
|
+
accountId: string;
|
|
63
|
+
platform: Platform;
|
|
64
|
+
token: string;
|
|
65
|
+
createdAt: Date;
|
|
66
|
+
lastSeenAt: Date;
|
|
67
|
+
}
|
|
68
|
+
/** Where the state lives. Every adapter runs `notificationStoreContract` (`@krizaka/notifications/testkit`). */
|
|
69
|
+
interface NotificationStore {
|
|
70
|
+
/**
|
|
71
|
+
* Writes the item and says whether it exceeds the burst cap, ATOMICALLY per account and category (the database serialises
|
|
72
|
+
* concurrent callers with a transaction-level advisory lock, so the cap holds under concurrency: pass the caller's `tx`).
|
|
73
|
+
* `duplicate` when the account already has an item with this `dedupeKey`: nothing is written.
|
|
74
|
+
*/
|
|
75
|
+
record(item: Item, burst: {
|
|
76
|
+
max: number;
|
|
77
|
+
since: Date;
|
|
78
|
+
}): Promise<{
|
|
79
|
+
duplicate: boolean;
|
|
80
|
+
capped: boolean;
|
|
81
|
+
}>;
|
|
82
|
+
/** The account's explicit choices. */
|
|
83
|
+
preferences(accountId: string): Promise<{
|
|
84
|
+
category: string;
|
|
85
|
+
channel: Channel;
|
|
86
|
+
enabled: boolean;
|
|
87
|
+
}[]>;
|
|
88
|
+
setPreference(accountId: string, category: string, channel: Channel, enabled: boolean, now: Date): Promise<void>;
|
|
89
|
+
/** Items shown in the bell, newest first; `before` is the cursor (the last item of the previous page). */
|
|
90
|
+
list(accountId: string, options: {
|
|
91
|
+
unreadOnly: boolean;
|
|
92
|
+
limit: number;
|
|
93
|
+
before?: {
|
|
94
|
+
createdAt: Date;
|
|
95
|
+
id: string;
|
|
96
|
+
};
|
|
97
|
+
}): Promise<Item[]>;
|
|
98
|
+
unreadCount(accountId: string): Promise<number>;
|
|
99
|
+
/** 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. */
|
|
100
|
+
markRead(accountId: string, ids: readonly string[] | null, now: Date): Promise<number>;
|
|
101
|
+
/** A token belongs to one account: registering it again moves it, and brings back a revoked one. */
|
|
102
|
+
upsertDevice(device: Device): Promise<void>;
|
|
103
|
+
devices(accountId: string): Promise<Device[]>;
|
|
104
|
+
/** The push service said the token is dead. */
|
|
105
|
+
revokeDevice(token: string, now: Date): Promise<boolean>;
|
|
106
|
+
purgeBefore(before: Date): Promise<number>;
|
|
107
|
+
/** Removes everything held about the account. Returns how many rows. */
|
|
108
|
+
erase(accountId: string): Promise<number>;
|
|
109
|
+
}
|
|
110
|
+
declare const NOTIFICATION_CODE: {
|
|
111
|
+
readonly badCategory: "NOTIFICATIONS_BAD_CATEGORY";
|
|
112
|
+
readonly badInput: "NOTIFICATIONS_BAD_INPUT";
|
|
113
|
+
readonly locked: "NOTIFICATIONS_LOCKED";
|
|
114
|
+
readonly badConfig: "NOTIFICATIONS_BAD_CONFIG";
|
|
115
|
+
};
|
|
116
|
+
type NotificationCode = (typeof NOTIFICATION_CODE)[keyof typeof NOTIFICATION_CODE];
|
|
117
|
+
declare class NotificationError extends Error {
|
|
118
|
+
readonly code: NotificationCode;
|
|
119
|
+
constructor(code: NotificationCode, message: string);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export { type Channel as C, type Delivery as D, type Item as I, type NotificationStore as N, type Platform as P, type NotificationConfig as a, type CategoryDefaults as b, type Device as c, CHANNEL as d, CHANNELS as e, DELIVERY as f, NOTIFICATION_CODE as g, type NotificationCode as h, NotificationError as i, PLATFORM as j };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Registry } from '@krizaka/settings';
|
|
2
|
+
import { C as Channel, a as NotificationConfig } from './port-DptCmYwy.js';
|
|
3
|
+
|
|
4
|
+
/** What a product declares for a category; the admin tunes the values (except `locked`, which is code). */
|
|
5
|
+
interface CategoryDeclaration {
|
|
6
|
+
/** Cannot be switched off by the account or the admin (security, the account). Its channels are exactly `defaults`. */
|
|
7
|
+
locked?: boolean;
|
|
8
|
+
defaults: Record<Channel, boolean>;
|
|
9
|
+
burst?: {
|
|
10
|
+
max: number;
|
|
11
|
+
windowSeconds: number;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
type Declared = Record<string, CategoryDeclaration>;
|
|
15
|
+
/**
|
|
16
|
+
* The settings of « notifications » for the categories a product declares, a fragment it adds to its registry
|
|
17
|
+
* (`@krizaka/notifications/settings`): for each category the channels on by default (`notifications.<category>.<channel>`, not for a
|
|
18
|
+
* locked category) and its burst cap (`notifications.<category>.burstMax`, `burstWindowSeconds`), plus `notifications.retentionDays`.
|
|
19
|
+
* The admin changes them without a redeploy.
|
|
20
|
+
* @throws NotificationError when a category name is not a camel-case word
|
|
21
|
+
*/
|
|
22
|
+
declare function notificationSettings(declared: Declared): Registry;
|
|
23
|
+
type Reader = (key: string) => boolean | number | undefined | Promise<boolean | number | undefined>;
|
|
24
|
+
/**
|
|
25
|
+
* The module's configuration as `createNotifications` takes it, from the product's accessor (`(key) => service.get(key, ctx)`).
|
|
26
|
+
* A key the accessor does not know falls back to what the product declared.
|
|
27
|
+
*/
|
|
28
|
+
declare function readNotificationConfig(declared: Declared, read: Reader): Promise<NotificationConfig>;
|
|
29
|
+
|
|
30
|
+
export { type CategoryDeclaration, type Declared, notificationSettings, readNotificationConfig };
|
package/dist/settings.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CATEGORY_NAME,
|
|
3
|
+
NOTIFICATION_BOUNDS,
|
|
4
|
+
NOTIFICATION_DEFAULTS
|
|
5
|
+
} from "./chunk-2MH7PZDT.js";
|
|
6
|
+
import {
|
|
7
|
+
CHANNELS,
|
|
8
|
+
NOTIFICATION_CODE,
|
|
9
|
+
NotificationError
|
|
10
|
+
} from "./chunk-FQYNUK64.js";
|
|
11
|
+
|
|
12
|
+
// src/settings.ts
|
|
13
|
+
import { flag, integer } from "@krizaka/settings";
|
|
14
|
+
var burstOf = (d) => d.burst ?? { max: NOTIFICATION_DEFAULTS.burstMax, windowSeconds: NOTIFICATION_DEFAULTS.burstWindowSeconds };
|
|
15
|
+
function notificationSettings(declared) {
|
|
16
|
+
const registry = {
|
|
17
|
+
"notifications.retentionDays": integer("notifications", NOTIFICATION_DEFAULTS.retentionDays, { min: NOTIFICATION_BOUNDS.retentionDays[0], max: NOTIFICATION_BOUNDS.retentionDays[1], unit: "days" })
|
|
18
|
+
};
|
|
19
|
+
for (const [name, d] of Object.entries(declared)) {
|
|
20
|
+
if (!CATEGORY_NAME.test(name)) throw new NotificationError(NOTIFICATION_CODE.badConfig, `category \xAB ${name} \xBB must be a camel-case word`);
|
|
21
|
+
const group = `notifications.${name}`;
|
|
22
|
+
if (!d.locked) for (const channel of CHANNELS) registry[`notifications.${name}.${channel}`] = flag(group, d.defaults[channel]);
|
|
23
|
+
const burst = burstOf(d);
|
|
24
|
+
registry[`notifications.${name}.burstMax`] = integer(group, burst.max, { min: NOTIFICATION_BOUNDS.burstMax[0], max: NOTIFICATION_BOUNDS.burstMax[1], unit: "notifications" });
|
|
25
|
+
registry[`notifications.${name}.burstWindowSeconds`] = integer(group, burst.windowSeconds, { min: NOTIFICATION_BOUNDS.burstWindowSeconds[0], max: NOTIFICATION_BOUNDS.burstWindowSeconds[1], unit: "seconds" });
|
|
26
|
+
}
|
|
27
|
+
return registry;
|
|
28
|
+
}
|
|
29
|
+
async function readNotificationConfig(declared, read) {
|
|
30
|
+
const number = async (key, fallback) => {
|
|
31
|
+
const value = await read(key);
|
|
32
|
+
return typeof value === "number" ? value : fallback;
|
|
33
|
+
};
|
|
34
|
+
const categories = {};
|
|
35
|
+
for (const [name, d] of Object.entries(declared)) {
|
|
36
|
+
const defaults = { ...d.defaults };
|
|
37
|
+
if (!d.locked) {
|
|
38
|
+
for (const channel of CHANNELS) {
|
|
39
|
+
const value = await read(`notifications.${name}.${channel}`);
|
|
40
|
+
if (typeof value === "boolean") defaults[channel] = value;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const burst = burstOf(d);
|
|
44
|
+
categories[name] = { locked: d.locked ?? false, defaults, burstMax: await number(`notifications.${name}.burstMax`, burst.max), burstWindowSeconds: await number(`notifications.${name}.burstWindowSeconds`, burst.windowSeconds) };
|
|
45
|
+
}
|
|
46
|
+
return { categories, retentionDays: await number("notifications.retentionDays", NOTIFICATION_DEFAULTS.retentionDays) };
|
|
47
|
+
}
|
|
48
|
+
export {
|
|
49
|
+
notificationSettings,
|
|
50
|
+
readNotificationConfig
|
|
51
|
+
};
|
|
52
|
+
//# sourceMappingURL=settings.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/settings.ts"],"sourcesContent":["import { flag, integer, type Registry } from \"@krizaka/settings\";\n\nimport { CHANNELS, type NotificationConfig } from \"./port\";\nimport { type Channel, NOTIFICATION_CODE, NotificationError } from \"./port\";\nimport { CATEGORY_NAME, NOTIFICATION_BOUNDS, NOTIFICATION_DEFAULTS } from \"./rules\";\n\n/** What a product declares for a category; the admin tunes the values (except `locked`, which is code). */\nexport interface CategoryDeclaration {\n /** Cannot be switched off by the account or the admin (security, the account). Its channels are exactly `defaults`. */\n locked?: boolean;\n defaults: Record<Channel, boolean>;\n burst?: { max: number; windowSeconds: number };\n}\n\nexport type Declared = Record<string, CategoryDeclaration>;\n\nconst burstOf = (d: CategoryDeclaration) => d.burst ?? { max: NOTIFICATION_DEFAULTS.burstMax, windowSeconds: NOTIFICATION_DEFAULTS.burstWindowSeconds };\n\n/**\n * The settings of « notifications » for the categories a product declares, a fragment it adds to its registry\n * (`@krizaka/notifications/settings`): for each category the channels on by default (`notifications.<category>.<channel>`, not for a\n * locked category) and its burst cap (`notifications.<category>.burstMax`, `burstWindowSeconds`), plus `notifications.retentionDays`.\n * The admin changes them without a redeploy.\n * @throws NotificationError when a category name is not a camel-case word\n */\nexport function notificationSettings(declared: Declared): Registry {\n const registry: Registry = {\n \"notifications.retentionDays\": integer(\"notifications\", NOTIFICATION_DEFAULTS.retentionDays, { min: NOTIFICATION_BOUNDS.retentionDays[0], max: NOTIFICATION_BOUNDS.retentionDays[1], unit: \"days\" }),\n };\n for (const [name, d] of Object.entries(declared)) {\n if (!CATEGORY_NAME.test(name)) throw new NotificationError(NOTIFICATION_CODE.badConfig, `category « ${name} » must be a camel-case word`);\n const group = `notifications.${name}`;\n if (!d.locked) for (const channel of CHANNELS) registry[`notifications.${name}.${channel}`] = flag(group, d.defaults[channel]);\n const burst = burstOf(d);\n registry[`notifications.${name}.burstMax`] = integer(group, burst.max, { min: NOTIFICATION_BOUNDS.burstMax[0], max: NOTIFICATION_BOUNDS.burstMax[1], unit: \"notifications\" });\n registry[`notifications.${name}.burstWindowSeconds`] = integer(group, burst.windowSeconds, { min: NOTIFICATION_BOUNDS.burstWindowSeconds[0], max: NOTIFICATION_BOUNDS.burstWindowSeconds[1], unit: \"seconds\" });\n }\n return registry;\n}\n\ntype Reader = (key: string) => boolean | number | undefined | Promise<boolean | number | undefined>;\n\n/**\n * The module's configuration as `createNotifications` takes it, from the product's accessor (`(key) => service.get(key, ctx)`).\n * A key the accessor does not know falls back to what the product declared.\n */\nexport async function readNotificationConfig(declared: Declared, read: Reader): Promise<NotificationConfig> {\n const number = async (key: string, fallback: number) => {\n const value = await read(key);\n return typeof value === \"number\" ? value : fallback;\n };\n const categories: NotificationConfig[\"categories\"] = {};\n for (const [name, d] of Object.entries(declared)) {\n const defaults = { ...d.defaults };\n if (!d.locked) {\n for (const channel of CHANNELS) {\n const value = await read(`notifications.${name}.${channel}`);\n if (typeof value === \"boolean\") defaults[channel] = value;\n }\n }\n const burst = burstOf(d);\n categories[name] = { locked: d.locked ?? false, defaults, burstMax: await number(`notifications.${name}.burstMax`, burst.max), burstWindowSeconds: await number(`notifications.${name}.burstWindowSeconds`, burst.windowSeconds) };\n }\n return { categories, retentionDays: await number(\"notifications.retentionDays\", NOTIFICATION_DEFAULTS.retentionDays) };\n}\n"],"mappings":";;;;;;;;;;;;AAAA,SAAS,MAAM,eAA8B;AAgB7C,IAAM,UAAU,CAAC,MAA2B,EAAE,SAAS,EAAE,KAAK,sBAAsB,UAAU,eAAe,sBAAsB,mBAAmB;AAS/I,SAAS,qBAAqB,UAA8B;AACjE,QAAM,WAAqB;AAAA,IACzB,+BAA+B,QAAQ,iBAAiB,sBAAsB,eAAe,EAAE,KAAK,oBAAoB,cAAc,CAAC,GAAG,KAAK,oBAAoB,cAAc,CAAC,GAAG,MAAM,OAAO,CAAC;AAAA,EACrM;AACA,aAAW,CAAC,MAAM,CAAC,KAAK,OAAO,QAAQ,QAAQ,GAAG;AAChD,QAAI,CAAC,cAAc,KAAK,IAAI,EAAG,OAAM,IAAI,kBAAkB,kBAAkB,WAAW,iBAAc,IAAI,iCAA8B;AACxI,UAAM,QAAQ,iBAAiB,IAAI;AACnC,QAAI,CAAC,EAAE,OAAQ,YAAW,WAAW,SAAU,UAAS,iBAAiB,IAAI,IAAI,OAAO,EAAE,IAAI,KAAK,OAAO,EAAE,SAAS,OAAO,CAAC;AAC7H,UAAM,QAAQ,QAAQ,CAAC;AACvB,aAAS,iBAAiB,IAAI,WAAW,IAAI,QAAQ,OAAO,MAAM,KAAK,EAAE,KAAK,oBAAoB,SAAS,CAAC,GAAG,KAAK,oBAAoB,SAAS,CAAC,GAAG,MAAM,gBAAgB,CAAC;AAC5K,aAAS,iBAAiB,IAAI,qBAAqB,IAAI,QAAQ,OAAO,MAAM,eAAe,EAAE,KAAK,oBAAoB,mBAAmB,CAAC,GAAG,KAAK,oBAAoB,mBAAmB,CAAC,GAAG,MAAM,UAAU,CAAC;AAAA,EAChN;AACA,SAAO;AACT;AAQA,eAAsB,uBAAuB,UAAoB,MAA2C;AAC1G,QAAM,SAAS,OAAO,KAAa,aAAqB;AACtD,UAAM,QAAQ,MAAM,KAAK,GAAG;AAC5B,WAAO,OAAO,UAAU,WAAW,QAAQ;AAAA,EAC7C;AACA,QAAM,aAA+C,CAAC;AACtD,aAAW,CAAC,MAAM,CAAC,KAAK,OAAO,QAAQ,QAAQ,GAAG;AAChD,UAAM,WAAW,EAAE,GAAG,EAAE,SAAS;AACjC,QAAI,CAAC,EAAE,QAAQ;AACb,iBAAW,WAAW,UAAU;AAC9B,cAAM,QAAQ,MAAM,KAAK,iBAAiB,IAAI,IAAI,OAAO,EAAE;AAC3D,YAAI,OAAO,UAAU,UAAW,UAAS,OAAO,IAAI;AAAA,MACtD;AAAA,IACF;AACA,UAAM,QAAQ,QAAQ,CAAC;AACvB,eAAW,IAAI,IAAI,EAAE,QAAQ,EAAE,UAAU,OAAO,UAAU,UAAU,MAAM,OAAO,iBAAiB,IAAI,aAAa,MAAM,GAAG,GAAG,oBAAoB,MAAM,OAAO,iBAAiB,IAAI,uBAAuB,MAAM,aAAa,EAAE;AAAA,EACnO;AACA,SAAO,EAAE,YAAY,eAAe,MAAM,OAAO,+BAA+B,sBAAsB,aAAa,EAAE;AACvH;","names":[]}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { I as Item, N as NotificationStore } from './port-DptCmYwy.js';
|
|
2
|
+
|
|
3
|
+
declare const T0: Date;
|
|
4
|
+
declare const at: (seconds: number) => Date;
|
|
5
|
+
/** An item for tests; the account ids are UUIDs because the database's are. */
|
|
6
|
+
declare const itemOf: (accountId: string, over?: Partial<Item>) => Item;
|
|
7
|
+
/** What every `NotificationStore` adapter must do: the same behaviour behind the same port. `make` gives an empty store whose accounts exist. */
|
|
8
|
+
declare function notificationStoreContract(name: string, make: () => NotificationStore | Promise<NotificationStore>, accountId: () => string | Promise<string>): void;
|
|
9
|
+
|
|
10
|
+
export { T0, at, itemOf, notificationStoreContract };
|