@protobase/server 0.45.17 → 0.45.19
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/dist/create-admin.js +10 -3
- package/dist/index.d.ts +12 -5
- package/dist/index.js +9 -2
- package/dist/jobs/current.d.ts +2 -0
- package/dist/jobs/job-progress.js +30 -0
- package/dist/jobs/job-types.d.ts +5 -0
- package/dist/jobs/run-job.d.ts +1 -0
- package/dist/jobs/run-job.js +5 -3
- package/dist/jobs/runtime.js +7 -1
- package/dist/notifications/caller.js +6 -6
- package/dist/notifications/deliver.js +119 -0
- package/dist/notifications/guarded-sender.js +13 -4
- package/dist/notifications/history-cursor.js +40 -0
- package/dist/notifications/history-request.js +40 -0
- package/dist/notifications/history-routes.d.ts +6 -0
- package/dist/notifications/history-routes.js +84 -0
- package/dist/notifications/history-sql.js +31 -0
- package/dist/notifications/history-store-of.d.ts +13 -0
- package/dist/notifications/history-store-of.js +13 -0
- package/dist/notifications/history-store.d.ts +29 -0
- package/dist/notifications/history-store.js +39 -0
- package/dist/notifications/history-url.js +18 -0
- package/dist/notifications/history.d.ts +34 -0
- package/dist/notifications/history.js +42 -0
- package/dist/notifications/options.d.ts +11 -0
- package/dist/notifications/options.js +32 -13
- package/dist/notifications/paths.d.ts +10 -0
- package/dist/notifications/paths.js +16 -1
- package/dist/notifications/plugin.d.ts +4 -2
- package/dist/notifications/plugin.js +4 -2
- package/dist/notifications/postgres-history-store.d.ts +15 -0
- package/dist/notifications/postgres-history-store.js +91 -0
- package/dist/notifications/push-job-name.js +5 -0
- package/dist/notifications/push-job.js +50 -0
- package/dist/notifications/push-payload.js +60 -0
- package/dist/notifications/routes.js +2 -2
- package/dist/notifications/schema.d.ts +4 -8
- package/dist/notifications/schema.js +34 -11
- package/dist/notifications/send-input.d.ts +28 -0
- package/dist/notifications/send-input.js +80 -0
- package/dist/notifications/send-notification.d.ts +11 -0
- package/dist/notifications/send-notification.js +19 -0
- package/dist/notifications/sender.d.ts +4 -3
- package/dist/notifications/store-call.js +32 -0
- package/dist/notifications/web-push/web-push-sender.d.ts +1 -1
- package/dist/notifications/web-push/web-push-sender.js +2 -2
- package/dist/package.js +1 -1
- package/package.json +4 -4
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { badRequest } from "../problem.js";
|
|
2
|
+
import "./history-store.js";
|
|
3
|
+
import { historyIdPattern } from "./history.js";
|
|
4
|
+
import { ownSignInUser } from "./caller.js";
|
|
5
|
+
import { historyCursor, historyPosition } from "./history-cursor.js";
|
|
6
|
+
import { jsonBody, jsonOnly, limitedBody, queryOf } from "./history-request.js";
|
|
7
|
+
import { historyUrl } from "./history-url.js";
|
|
8
|
+
import "./paths.js";
|
|
9
|
+
import { Hono } from "hono";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
//#region src/notifications/history-routes.ts
|
|
12
|
+
/** How many entries a page of the history holds when the request names no `limit`. */
|
|
13
|
+
const defaultHistoryPage = 20;
|
|
14
|
+
const ownSignIn = async (c, next) => {
|
|
15
|
+
c.set("historyUser", ownSignInUser(c.get("session")));
|
|
16
|
+
await next();
|
|
17
|
+
};
|
|
18
|
+
const noStore = { "Cache-Control": "no-store" };
|
|
19
|
+
const pageLimit = (limit) => {
|
|
20
|
+
if (limit === void 0) return 20;
|
|
21
|
+
const value = /^[1-9][0-9]{0,2}$/.test(limit) ? Number(limit) : 0;
|
|
22
|
+
if (value < 1 || value > 50) throw badRequest("invalid-limit", `The limit is a whole number from 1 to 50`);
|
|
23
|
+
return value;
|
|
24
|
+
};
|
|
25
|
+
const markAll = z.strictObject({ upTo: z.iso.datetime({ offset: true }) });
|
|
26
|
+
/**
|
|
27
|
+
* The caller's own notification history, mounted at `historyPath` behind the API's authenticator: `GET /` a page of
|
|
28
|
+
* it, newest first, `GET /unread` how many are unread, `POST /read` marks all read up to the newest the caller was
|
|
29
|
+
* shown, `POST /<id>/read` one. No request names a user: each reaches the signed-in user's own entries alone. `origins`
|
|
30
|
+
* are the app's own, which an absolute url may lead to.
|
|
31
|
+
*/
|
|
32
|
+
const historyRoutes = (store, origins) => {
|
|
33
|
+
const app = new Hono();
|
|
34
|
+
const answer = (entry) => ({
|
|
35
|
+
id: entry.id,
|
|
36
|
+
title: entry.title,
|
|
37
|
+
body: entry.body,
|
|
38
|
+
url: historyUrl(entry.url, origins),
|
|
39
|
+
data: entry.data,
|
|
40
|
+
createdAt: entry.createdAt.toISOString(),
|
|
41
|
+
readAt: entry.readAt === null ? null : entry.readAt.toISOString()
|
|
42
|
+
});
|
|
43
|
+
app.get("/", ownSignIn, async (c) => {
|
|
44
|
+
const userId = c.get("historyUser");
|
|
45
|
+
const query = queryOf(c, ["cursor", "limit"]);
|
|
46
|
+
const limit = pageLimit(query.limit);
|
|
47
|
+
const before = query.cursor === void 0 ? void 0 : historyPosition(query.cursor);
|
|
48
|
+
const entries = await store.list(userId, {
|
|
49
|
+
...before && { before },
|
|
50
|
+
limit
|
|
51
|
+
});
|
|
52
|
+
if (entries.length > limit || entries.some((entry) => entry.userId !== userId || !historyIdPattern.test(entry.id))) throw new Error("The history store answered list with entries that are not the caller's");
|
|
53
|
+
const last = entries.at(-1);
|
|
54
|
+
const next = last && entries.length === limit ? historyCursor(last) : null;
|
|
55
|
+
return c.json({
|
|
56
|
+
items: entries.map(answer),
|
|
57
|
+
next
|
|
58
|
+
}, 200, noStore);
|
|
59
|
+
});
|
|
60
|
+
app.get("/unread", ownSignIn, async (c) => {
|
|
61
|
+
queryOf(c, []);
|
|
62
|
+
const count = await store.countUnread(c.get("historyUser"));
|
|
63
|
+
if (!Number.isSafeInteger(count) || count < 0 || count > 100) throw new Error("The history store answered countUnread with no count");
|
|
64
|
+
return c.json({ count }, 200, noStore);
|
|
65
|
+
});
|
|
66
|
+
app.post("/read", ownSignIn, jsonOnly, limitedBody, async (c) => {
|
|
67
|
+
queryOf(c, []);
|
|
68
|
+
const parsed = markAll.safeParse(await jsonBody(c));
|
|
69
|
+
if (!parsed.success) throw badRequest("invalid-mark", "Send { \"upTo\": <createdAt of the newest notification shown> }");
|
|
70
|
+
await store.markAllRead(c.get("historyUser"), new Date(parsed.data.upTo));
|
|
71
|
+
return c.body(null, 204);
|
|
72
|
+
});
|
|
73
|
+
app.post(`/:id/read`, ownSignIn, jsonOnly, limitedBody, async (c) => {
|
|
74
|
+
queryOf(c, []);
|
|
75
|
+
const body = await jsonBody(c);
|
|
76
|
+
if (body !== void 0 && (typeof body !== "object" || body === null || Array.isArray(body) || Object.keys(body).length > 0)) throw badRequest("invalid-mark", "Marking one read takes no body");
|
|
77
|
+
const id = c.req.param("id");
|
|
78
|
+
if (historyIdPattern.test(id)) await store.markRead(c.get("historyUser"), id);
|
|
79
|
+
return c.body(null, 204);
|
|
80
|
+
});
|
|
81
|
+
return app;
|
|
82
|
+
};
|
|
83
|
+
//#endregion
|
|
84
|
+
export { defaultHistoryPage, historyRoutes };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { quoteIdent } from "../changes/capture-sql.js";
|
|
2
|
+
import { qualified } from "./schema-sql.js";
|
|
3
|
+
//#region src/notifications/history-sql.ts
|
|
4
|
+
/**
|
|
5
|
+
* The history table: with auth, its user column points at auth's users table and goes with the user. `read_at` is NULL
|
|
6
|
+
* while unread and nothing has a default. One index serves a user's newest-first pages, another their unread ones.
|
|
7
|
+
*/
|
|
8
|
+
const historyTableSql = ({ schema, table, columns }, user) => {
|
|
9
|
+
const c = Object.fromEntries(Object.entries(columns).map(([name, value]) => [name, quoteIdent(value)]));
|
|
10
|
+
const history = qualified({
|
|
11
|
+
schema,
|
|
12
|
+
table
|
|
13
|
+
});
|
|
14
|
+
return [
|
|
15
|
+
`create schema if not exists ${quoteIdent(schema)}`,
|
|
16
|
+
`create table ${history} (
|
|
17
|
+
${c.id} uuid primary key,
|
|
18
|
+
${c.user_id} ${user.type} not null${user.references ? ` references ${user.references} on delete cascade` : ""},
|
|
19
|
+
${c.title} text not null,
|
|
20
|
+
${c.body} text,
|
|
21
|
+
${c.url} text,
|
|
22
|
+
${c.data} jsonb,
|
|
23
|
+
${c.created_at} timestamptz not null,
|
|
24
|
+
${c.read_at} timestamptz
|
|
25
|
+
)`,
|
|
26
|
+
`create index on ${history} (${c.user_id}, ${c.created_at} desc, ${c.id} desc)`,
|
|
27
|
+
`create index on ${history} (${c.user_id}, ${c.created_at}) where ${c.read_at} is null`
|
|
28
|
+
];
|
|
29
|
+
};
|
|
30
|
+
//#endregion
|
|
31
|
+
export { historyTableSql };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { AdminAuth } from "../better-auth/create-auth.js";
|
|
2
|
+
import { HistoryStore } from "./history-store.js";
|
|
3
|
+
import { NotificationsOptions } from "./options.js";
|
|
4
|
+
import { Db } from "@protobase/query";
|
|
5
|
+
//#region src/notifications/history-store-of.d.ts
|
|
6
|
+
/**
|
|
7
|
+
* The history's store `notifications()` configures: the app's own `historyStore`, or the history table in `db`. An app
|
|
8
|
+
* that deletes users without Protobase's auth calls its `removeAllForUser` as it deletes one.
|
|
9
|
+
*/
|
|
10
|
+
export declare const historyStore: (db: Db, options?: NotificationsOptions) => HistoryStore;
|
|
11
|
+
/** Has `auth` remove a user's history from `store` when it deletes the user, before and once more after. */
|
|
12
|
+
export declare const removeHistoryWithUsers: (auth: AdminAuth, store: HistoryStore) => void;
|
|
13
|
+
//#endregion
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { postgresHistoryStore } from "./postgres-history-store.js";
|
|
2
|
+
//#region src/notifications/history-store-of.ts
|
|
3
|
+
/**
|
|
4
|
+
* The history's store `notifications()` configures: the app's own `historyStore`, or the history table in `db`. An app
|
|
5
|
+
* that deletes users without Protobase's auth calls its `removeAllForUser` as it deletes one.
|
|
6
|
+
*/
|
|
7
|
+
const historyStore = (db, options = {}) => options.historyStore ?? postgresHistoryStore(db, options);
|
|
8
|
+
/** Has `auth` remove a user's history from `store` when it deletes the user, before and once more after. */
|
|
9
|
+
const removeHistoryWithUsers = (auth, store) => {
|
|
10
|
+
auth.userDeletion.set("notifications-history", (userId) => store.removeAllForUser(userId));
|
|
11
|
+
};
|
|
12
|
+
//#endregion
|
|
13
|
+
export { historyStore, removeHistoryWithUsers };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { HistoryPosition, NewNotification, StoredNotification } from "./history.js";
|
|
2
|
+
//#region src/notifications/history-store.d.ts
|
|
3
|
+
/** The most unread notifications `countUnread` counts: a bell shows "99+" past it. */
|
|
4
|
+
export declare const maxUnreadCount = 100;
|
|
5
|
+
/**
|
|
6
|
+
* Where each user's inbox notifications are kept: the Postgres default (`postgresHistoryStore`), or a store of the
|
|
7
|
+
* app's own passed as `notifications({ historyStore })`. Plain data in and out, so any store can implement it. Every
|
|
8
|
+
* method takes the user and touches that user's entries alone; each throws on an empty user id or id.
|
|
9
|
+
*/
|
|
10
|
+
export type HistoryStore = {
|
|
11
|
+
/** Keeps the notification for `userId`, unread, created now, under a new id matching `historyIdPattern`. */
|
|
12
|
+
record: (userId: string, notification: NewNotification) => Promise<StoredNotification>;
|
|
13
|
+
/** At most `limit` of the user's entries, newest first by creation time then id, starting after `before`. */
|
|
14
|
+
list: (userId: string, page: {
|
|
15
|
+
before?: HistoryPosition;
|
|
16
|
+
limit: number;
|
|
17
|
+
}) => Promise<StoredNotification[]>;
|
|
18
|
+
/** How many of the user's entries are unread, counting up to `maxUnreadCount` at most. */
|
|
19
|
+
countUnread: (userId: string) => Promise<number>;
|
|
20
|
+
/** Marks the user's entry with this id read now, unless it was read already; false when the user has none such. */
|
|
21
|
+
markRead: (userId: string, id: string) => Promise<boolean>;
|
|
22
|
+
/** Marks the user's unread entries created at or before `upTo` read now; entries read already keep their time. */
|
|
23
|
+
markAllRead: (userId: string, upTo: Date) => Promise<void>;
|
|
24
|
+
/** Removes every entry of the user, as when the user is deleted. */
|
|
25
|
+
removeAllForUser: (userId: string) => Promise<void>;
|
|
26
|
+
};
|
|
27
|
+
/** The most entries `list` gives at once. */
|
|
28
|
+
export declare const maxHistoryPage = 50;
|
|
29
|
+
//#endregion
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
//#region src/notifications/history-store.ts
|
|
2
|
+
/** The most unread notifications `countUnread` counts: a bell shows "99+" past it. */
|
|
3
|
+
const maxUnreadCount = 100;
|
|
4
|
+
const methods = [
|
|
5
|
+
"record",
|
|
6
|
+
"list",
|
|
7
|
+
"countUnread",
|
|
8
|
+
"markRead",
|
|
9
|
+
"markAllRead",
|
|
10
|
+
"removeAllForUser"
|
|
11
|
+
];
|
|
12
|
+
/** The store, checked to have every method; a missing one is an error naming it. */
|
|
13
|
+
const checkHistoryStore = (store) => {
|
|
14
|
+
const missing = methods.find((method) => typeof store[method] !== "function");
|
|
15
|
+
if (missing !== void 0) throw new Error(`notifications({ historyStore }): the history store has no ${missing} method`);
|
|
16
|
+
return store;
|
|
17
|
+
};
|
|
18
|
+
/** The most entries `list` gives at once. */
|
|
19
|
+
const maxHistoryPage = 50;
|
|
20
|
+
/** The value, refused when empty: no store call may be read as "every user". */
|
|
21
|
+
const requiredText = (name, value) => {
|
|
22
|
+
if (typeof value !== "string" || value === "") throw new Error(`The history store needs a ${name}`);
|
|
23
|
+
return value;
|
|
24
|
+
};
|
|
25
|
+
/** The time, refused when it is no valid `Date`. */
|
|
26
|
+
const requiredTime = (name, value) => {
|
|
27
|
+
if (!(value instanceof Date) || Number.isNaN(value.getTime())) throw new Error(`The history store needs a ${name}`);
|
|
28
|
+
return value;
|
|
29
|
+
};
|
|
30
|
+
/** The page asked for, refused unless its limit is a whole number from 1 to `maxHistoryPage` and its position whole. */
|
|
31
|
+
const requiredPage = (page) => {
|
|
32
|
+
if (typeof page !== "object" || page === null || !Number.isInteger(page.limit) || page.limit < 1 || page.limit > 50) throw new Error(`The history store needs a page limit from 1 to 50`);
|
|
33
|
+
if (page.before === void 0) return page;
|
|
34
|
+
requiredTime("position time", page.before.createdAt);
|
|
35
|
+
requiredText("position id", page.before.id);
|
|
36
|
+
return page;
|
|
37
|
+
};
|
|
38
|
+
//#endregion
|
|
39
|
+
export { checkHistoryStore, maxHistoryPage, maxUnreadCount, requiredPage, requiredText, requiredTime };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
//#region src/notifications/history-url.ts
|
|
2
|
+
const ownPath = /^\/(?![/\\])[^\s\u0000-\u001f\u007f-\u009f]*$/;
|
|
3
|
+
const absolute = /^https?:\/\//i;
|
|
4
|
+
/**
|
|
5
|
+
* The url an entry of the history may lead to, as an answer shows it, or `null`: a path on the app, or an absolute
|
|
6
|
+
* `http` or `https` URL without user info whose origin is exactly one of `origins`, the app's own, written as the URL
|
|
7
|
+
* parser writes it. Anything else, another site, `javascript:`, `data:` or a scheme-relative URL, is `null`.
|
|
8
|
+
*/
|
|
9
|
+
const historyUrl = (url, origins) => {
|
|
10
|
+
if (url === null || url.length > 2048) return null;
|
|
11
|
+
if (ownPath.test(url)) return url;
|
|
12
|
+
if (!absolute.test(url) || !URL.canParse(url) || /[\s\u0000-\u001f\u007f-\u009f]/.test(url)) return null;
|
|
13
|
+
const parsed = new URL(url);
|
|
14
|
+
if (parsed.username !== "" || parsed.password !== "") return null;
|
|
15
|
+
return origins.some((origin) => URL.canParse(origin) && new URL(origin).origin === parsed.origin) ? parsed.href : null;
|
|
16
|
+
};
|
|
17
|
+
//#endregion
|
|
18
|
+
export { historyUrl };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { JsonObject } from "./json-value.js";
|
|
2
|
+
//#region src/notifications/history.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* A notification as the history keeps it for its user's inbox: what a panel shows of it. `title` and `body` are plain
|
|
5
|
+
* text; `url` is where it leads; `data` is the app's own JSON.
|
|
6
|
+
*/
|
|
7
|
+
export type NewNotification = {
|
|
8
|
+
title: string;
|
|
9
|
+
body: string | null;
|
|
10
|
+
url: string | null;
|
|
11
|
+
data: JsonObject | null;
|
|
12
|
+
};
|
|
13
|
+
/** A kept notification: whose it is, its id, when it was recorded and when its user read it, `null` while unread. */
|
|
14
|
+
export type StoredNotification = NewNotification & {
|
|
15
|
+
id: string;
|
|
16
|
+
userId: string;
|
|
17
|
+
createdAt: Date;
|
|
18
|
+
readAt: Date | null;
|
|
19
|
+
};
|
|
20
|
+
/** Where a page of the history ends: the last entry's creation time and id. The next page starts after it. */
|
|
21
|
+
export type HistoryPosition = {
|
|
22
|
+
createdAt: Date;
|
|
23
|
+
id: string;
|
|
24
|
+
};
|
|
25
|
+
/** What an entry id looks like in every history store: it goes into a path, so no `/`, `.`, `?`, `#` or `%`. */
|
|
26
|
+
export declare const historyIdPattern: RegExp;
|
|
27
|
+
/** The longest title, body and url, as a push message takes them, and the most bytes of `data` as JSON. */
|
|
28
|
+
export declare const historyLimits: {
|
|
29
|
+
readonly title: 256;
|
|
30
|
+
readonly body: 2048;
|
|
31
|
+
readonly url: 2048;
|
|
32
|
+
readonly dataBytes: 4096;
|
|
33
|
+
};
|
|
34
|
+
//#endregion
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { isJsonObject, jsonProblem } from "./json-value.js";
|
|
2
|
+
//#region src/notifications/history.ts
|
|
3
|
+
/** What an entry id looks like in every history store: it goes into a path, so no `/`, `.`, `?`, `#` or `%`. */
|
|
4
|
+
const historyIdPattern = /^[A-Za-z0-9_-]{1,64}$/;
|
|
5
|
+
/** The longest title, body and url, as a push message takes them, and the most bytes of `data` as JSON. */
|
|
6
|
+
const historyLimits = {
|
|
7
|
+
title: 256,
|
|
8
|
+
body: 2048,
|
|
9
|
+
url: 2048,
|
|
10
|
+
dataBytes: 4096
|
|
11
|
+
};
|
|
12
|
+
const noControls = /^[^\u0000-\u001f\u007f]*$/;
|
|
13
|
+
const noControlsButLines = /^[^\u0000-\u0008\u000b-\u001f\u007f]*$/;
|
|
14
|
+
const text = (value, max, pattern) => typeof value === "string" && value.length <= max && pattern.test(value);
|
|
15
|
+
/** The first field of the notification that is not within its format, or `undefined`. */
|
|
16
|
+
const notificationProblem = (notification) => {
|
|
17
|
+
if (typeof notification !== "object" || notification === null) return "notification";
|
|
18
|
+
if (!text(notification.title, historyLimits.title, noControls) || notification.title === "") return "title";
|
|
19
|
+
if (notification.body !== null && !text(notification.body, historyLimits.body, noControlsButLines)) return "body";
|
|
20
|
+
if (notification.url !== null && !text(notification.url, historyLimits.url, noControls)) return "url";
|
|
21
|
+
if (notification.data === null) return void 0;
|
|
22
|
+
if (!isJsonObject(notification.data) || jsonProblem(notification.data, "") !== void 0) return "data";
|
|
23
|
+
if (Buffer.byteLength(JSON.stringify(notification.data)) > historyLimits.dataBytes) return "data";
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* The notification as a new object with its four fields alone, or an error naming the first field at fault, never its
|
|
27
|
+
* value: a title of 1 to 256 characters, a body and a url of at most 2048, without control characters, and `data`
|
|
28
|
+
* plain JSON of at most 4096 bytes. Every history store records only what this accepts.
|
|
29
|
+
*/
|
|
30
|
+
const checkedNotification = (notification) => {
|
|
31
|
+
const problem = notificationProblem(notification);
|
|
32
|
+
if (problem !== void 0) throw new Error(`The history cannot keep this notification: its ${problem} is invalid`);
|
|
33
|
+
const { title, body, url, data } = notification;
|
|
34
|
+
return {
|
|
35
|
+
title,
|
|
36
|
+
body,
|
|
37
|
+
url,
|
|
38
|
+
data: data === null ? null : structuredClone(data)
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
//#endregion
|
|
42
|
+
export { checkedNotification, historyIdPattern, historyLimits, notificationProblem };
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { DeviceStore } from "./device-store.js";
|
|
2
|
+
import { HistoryStore } from "./history-store.js";
|
|
2
3
|
import { PushSender } from "./sender.js";
|
|
3
4
|
//#region src/notifications/options.d.ts
|
|
4
5
|
/** The columns of the devices table, by the name Protobase gives them. */
|
|
5
6
|
export declare const deviceColumns: readonly ["id", "user_id", "kind", "token", "p256dh", "auth", "environment", "app_id", "user_agent", "created_at", "last_seen_at"];
|
|
6
7
|
export type DeviceColumn = (typeof deviceColumns)[number];
|
|
8
|
+
/** The columns of the history table, by the name Protobase gives them. */
|
|
9
|
+
export declare const historyColumns: readonly ["id", "user_id", "title", "body", "url", "data", "created_at", "read_at"];
|
|
10
|
+
export type HistoryColumn = (typeof historyColumns)[number];
|
|
7
11
|
/** Notifications, turned on with `extends: [notifications()]`. */
|
|
8
12
|
export type NotificationsOptions = {
|
|
9
13
|
/** The bell in the top bar; default true. `false` keeps only the backend. */
|
|
@@ -17,6 +21,13 @@ export type NotificationsOptions = {
|
|
|
17
21
|
};
|
|
18
22
|
/** Keeps the devices somewhere else than the devices table, which is then not needed. */
|
|
19
23
|
store?: DeviceStore;
|
|
24
|
+
/** The history table: its name, default `history`, and its columns named otherwise than Protobase names them. */
|
|
25
|
+
history?: {
|
|
26
|
+
table?: string;
|
|
27
|
+
columns?: Partial<Record<HistoryColumn, string>>;
|
|
28
|
+
};
|
|
29
|
+
/** Keeps each user's notification history somewhere else than the history table, which is then not needed. */
|
|
30
|
+
historyStore?: HistoryStore;
|
|
20
31
|
/**
|
|
21
32
|
* Deliver notifications in place of the built-in senders, such as senders from a package; at most one for each kind
|
|
22
33
|
* of device. By default the built-in ones whose environment is set: Web Push with the `PROTOBASE_VAPID_*` variables.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { checkDeviceStore } from "./device-store.js";
|
|
2
|
+
import { checkHistoryStore } from "./history-store.js";
|
|
2
3
|
import { checkSenders } from "./sender.js";
|
|
3
4
|
//#region src/notifications/options.ts
|
|
4
5
|
/** The columns of the devices table, by the name Protobase gives them. */
|
|
@@ -15,31 +16,49 @@ const deviceColumns = [
|
|
|
15
16
|
"created_at",
|
|
16
17
|
"last_seen_at"
|
|
17
18
|
];
|
|
19
|
+
/** The columns of the history table, by the name Protobase gives them. */
|
|
20
|
+
const historyColumns = [
|
|
21
|
+
"id",
|
|
22
|
+
"user_id",
|
|
23
|
+
"title",
|
|
24
|
+
"body",
|
|
25
|
+
"url",
|
|
26
|
+
"data",
|
|
27
|
+
"created_at",
|
|
28
|
+
"read_at"
|
|
29
|
+
];
|
|
18
30
|
const namePattern = /^[a-z_][a-z0-9_]*$/;
|
|
19
31
|
const maxNameBytes = 63;
|
|
20
32
|
const checkedName = (option, name) => {
|
|
21
33
|
if (typeof name !== "string" || !namePattern.test(name) || Buffer.byteLength(name) > maxNameBytes) throw new Error(`notifications ${option} is not a valid name: use lowercase letters, digits and "_", at most ${maxNameBytes} characters`);
|
|
22
34
|
return name;
|
|
23
35
|
};
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (unknown !== void 0) throw new Error(`notifications devices.columns names "${unknown}", which is not a column of the devices table: ${deviceColumns.join(", ")}`);
|
|
29
|
-
const columns = Object.fromEntries(deviceColumns.map((column) => [column, checkedName(`devices.columns.${column}`, given[column] ?? column)]));
|
|
30
|
-
return {
|
|
31
|
-
schema: checkedName("schema", options.schema ?? "notifications"),
|
|
32
|
-
table: checkedName("devices.table", options.devices?.table ?? "devices"),
|
|
33
|
-
columns
|
|
34
|
-
};
|
|
36
|
+
const columnNames = (table, all, given = {}) => {
|
|
37
|
+
const unknown = Object.keys(given).find((name) => !all.includes(name));
|
|
38
|
+
if (unknown !== void 0) throw new Error(`notifications ${table}.columns names "${unknown}", which is not a column of the ${table} table: ${all.join(", ")}`);
|
|
39
|
+
return Object.fromEntries(all.map((column) => [column, checkedName(`${table}.columns.${column}`, given[column] ?? column)]));
|
|
35
40
|
};
|
|
36
|
-
/** The
|
|
41
|
+
/** The devices table's names, checked: a bad or unknown name is an error naming its option. */
|
|
42
|
+
const notificationsNames = (options = {}) => ({
|
|
43
|
+
schema: checkedName("schema", options.schema ?? "notifications"),
|
|
44
|
+
table: checkedName("devices.table", options.devices?.table ?? "devices"),
|
|
45
|
+
columns: columnNames("devices", deviceColumns, options.devices?.columns)
|
|
46
|
+
});
|
|
47
|
+
/** The history table's names, checked as the devices table's are. */
|
|
48
|
+
const historyNames = (options = {}) => ({
|
|
49
|
+
schema: checkedName("schema", options.schema ?? "notifications"),
|
|
50
|
+
table: checkedName("history.table", options.history?.table ?? "history"),
|
|
51
|
+
columns: columnNames("history", historyColumns, options.history?.columns)
|
|
52
|
+
});
|
|
53
|
+
/** The options, checked as `notifications()` takes them: the names, given stores' methods and the senders given. */
|
|
37
54
|
const checkNotificationsOptions = (options) => {
|
|
38
55
|
if (options.ui !== void 0 && typeof options.ui !== "boolean") throw new Error("notifications ui is true or false");
|
|
39
56
|
notificationsNames(options);
|
|
57
|
+
historyNames(options);
|
|
40
58
|
if (options.store !== void 0) checkDeviceStore(options.store);
|
|
59
|
+
if (options.historyStore !== void 0) checkHistoryStore(options.historyStore);
|
|
41
60
|
if (options.senders !== void 0) checkSenders(options.senders);
|
|
42
61
|
return options;
|
|
43
62
|
};
|
|
44
63
|
//#endregion
|
|
45
|
-
export { checkNotificationsOptions, deviceColumns, notificationsNames };
|
|
64
|
+
export { checkNotificationsOptions, deviceColumns, historyColumns, historyNames, notificationsNames };
|
|
@@ -3,4 +3,14 @@
|
|
|
3
3
|
export declare const deviceIdParam = "id";
|
|
4
4
|
/** The path of the caller's devices under the API's system path (`/api`), or of one of them by its id. */
|
|
5
5
|
export declare const devicesPath: (systemPath: string, id?: string) => string;
|
|
6
|
+
/** The name of the entry id in the path that marks one entry of the history read. */
|
|
7
|
+
export declare const historyIdParam = "id";
|
|
8
|
+
/**
|
|
9
|
+
* The path of the caller's notification history under the API's system path (`/api`): the list itself, `unread` its
|
|
10
|
+
* unread count, `read` marking all read, or `{ read: id }` marking one entry read. An id that could leave the history's
|
|
11
|
+
* path, any but letters, digits, `_` and `-`, is refused.
|
|
12
|
+
*/
|
|
13
|
+
export declare const historyPath: (systemPath: string, part?: "unread" | "read" | {
|
|
14
|
+
read: string;
|
|
15
|
+
}) => string;
|
|
6
16
|
//#endregion
|
|
@@ -1,7 +1,22 @@
|
|
|
1
|
+
import { historyIdPattern } from "./history.js";
|
|
1
2
|
//#region src/notifications/paths.ts
|
|
2
3
|
/** The name of the device id in a device's path. */
|
|
3
4
|
const deviceIdParam = "id";
|
|
4
5
|
/** The path of the caller's devices under the API's system path (`/api`), or of one of them by its id. */
|
|
5
6
|
const devicesPath = (systemPath, id) => `${systemPath}/notifications/devices${id === void 0 ? "" : `/${encodeURIComponent(id)}`}`;
|
|
7
|
+
/** The name of the entry id in the path that marks one entry of the history read. */
|
|
8
|
+
const historyIdParam = "id";
|
|
9
|
+
/**
|
|
10
|
+
* The path of the caller's notification history under the API's system path (`/api`): the list itself, `unread` its
|
|
11
|
+
* unread count, `read` marking all read, or `{ read: id }` marking one entry read. An id that could leave the history's
|
|
12
|
+
* path, any but letters, digits, `_` and `-`, is refused.
|
|
13
|
+
*/
|
|
14
|
+
const historyPath = (systemPath, part) => {
|
|
15
|
+
const base = `${systemPath}/notifications/history`;
|
|
16
|
+
if (part === void 0) return base;
|
|
17
|
+
if (typeof part === "string") return `${base}/${part}`;
|
|
18
|
+
if (!historyIdPattern.test(part.read)) throw new Error("A history entry id is letters, digits, \"_\" and \"-\" only");
|
|
19
|
+
return `${base}/${part.read}/read`;
|
|
20
|
+
};
|
|
6
21
|
//#endregion
|
|
7
|
-
export { deviceIdParam, devicesPath };
|
|
22
|
+
export { deviceIdParam, devicesPath, historyIdParam, historyPath };
|
|
@@ -4,8 +4,10 @@ import { ProjectConfig } from "../project-config.js";
|
|
|
4
4
|
/**
|
|
5
5
|
* Notifications: `extends: [notifications()]` in `protobase.config.ts`. It keeps each user's devices, in the
|
|
6
6
|
* `notifications.devices` table that `protobase migration` writes or in the `store` given, and serves the endpoints
|
|
7
|
-
* through which a signed-in user registers and removes their own (`devicesPath`). It
|
|
8
|
-
*
|
|
7
|
+
* through which a signed-in user registers and removes their own (`devicesPath`). It keeps each user's inbox
|
|
8
|
+
* notifications in the `notifications.history` table or the `historyStore` given, and serves the endpoints through which
|
|
9
|
+
* a signed-in user lists theirs and marks them read (`historyPath`). It delivers through `senders`, by default the
|
|
10
|
+
* built-in ones whose environment is set (see `notificationSenders`).
|
|
9
11
|
*/
|
|
10
12
|
export declare const notifications: (options?: NotificationsOptions) => ProjectConfig;
|
|
11
13
|
//#endregion
|
|
@@ -3,8 +3,10 @@ import { checkNotificationsOptions } from "./options.js";
|
|
|
3
3
|
/**
|
|
4
4
|
* Notifications: `extends: [notifications()]` in `protobase.config.ts`. It keeps each user's devices, in the
|
|
5
5
|
* `notifications.devices` table that `protobase migration` writes or in the `store` given, and serves the endpoints
|
|
6
|
-
* through which a signed-in user registers and removes their own (`devicesPath`). It
|
|
7
|
-
*
|
|
6
|
+
* through which a signed-in user registers and removes their own (`devicesPath`). It keeps each user's inbox
|
|
7
|
+
* notifications in the `notifications.history` table or the `historyStore` given, and serves the endpoints through which
|
|
8
|
+
* a signed-in user lists theirs and marks them read (`historyPath`). It delivers through `senders`, by default the
|
|
9
|
+
* built-in ones whose environment is set (see `notificationSenders`).
|
|
8
10
|
*/
|
|
9
11
|
const notifications = (options = {}) => ({
|
|
10
12
|
name: "protobase-notifications",
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { HistoryStore } from "./history-store.js";
|
|
2
|
+
import { NotificationsOptions } from "./options.js";
|
|
3
|
+
import { Db } from "@protobase/query";
|
|
4
|
+
//#region src/notifications/postgres-history-store.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* A database error of the history store, holding its SQLSTATE alone: the driver's own carries the failing row in its
|
|
7
|
+
* detail, which must never reach a log.
|
|
8
|
+
*/
|
|
9
|
+
export declare class HistoryStoreError extends Error {
|
|
10
|
+
readonly sqlState: string;
|
|
11
|
+
constructor(sqlState: string);
|
|
12
|
+
}
|
|
13
|
+
/** The default history store: the history table `protobase migration` writes, under the names the options give. */
|
|
14
|
+
export declare const postgresHistoryStore: (db: Db, options?: NotificationsOptions) => HistoryStore;
|
|
15
|
+
//#endregion
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { isDatabaseError } from "../error-handler.js";
|
|
2
|
+
import { requiredPage, requiredText, requiredTime } from "./history-store.js";
|
|
3
|
+
import { historyColumns, historyNames } from "./options.js";
|
|
4
|
+
import { checkedNotification } from "./history.js";
|
|
5
|
+
import { sql } from "kysely";
|
|
6
|
+
//#region src/notifications/postgres-history-store.ts
|
|
7
|
+
/**
|
|
8
|
+
* A database error of the history store, holding its SQLSTATE alone: the driver's own carries the failing row in its
|
|
9
|
+
* detail, which must never reach a log.
|
|
10
|
+
*/
|
|
11
|
+
var HistoryStoreError = class extends Error {
|
|
12
|
+
sqlState;
|
|
13
|
+
constructor(sqlState) {
|
|
14
|
+
super(`The history store's query failed (SQLSTATE ${sqlState})`);
|
|
15
|
+
this.sqlState = sqlState;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
19
|
+
const entryOf = (row) => ({
|
|
20
|
+
id: String(row.id),
|
|
21
|
+
userId: String(row.user_id),
|
|
22
|
+
title: row.title,
|
|
23
|
+
body: row.body,
|
|
24
|
+
url: row.url,
|
|
25
|
+
data: row.data,
|
|
26
|
+
createdAt: new Date(row.created_at),
|
|
27
|
+
readAt: row.read_at === null ? null : new Date(row.read_at)
|
|
28
|
+
});
|
|
29
|
+
/** The default history store: the history table `protobase migration` writes, under the names the options give. */
|
|
30
|
+
const postgresHistoryStore = (db, options = {}) => {
|
|
31
|
+
const { schema, table, columns } = historyNames(options);
|
|
32
|
+
const history = sql.id(schema, table);
|
|
33
|
+
const column = (name) => sql.id(columns[name]);
|
|
34
|
+
const selected = sql.join(historyColumns.map((name) => sql`${column(name)} as ${sql.id(name)}`));
|
|
35
|
+
const execute = (query) => query.execute(db).catch((error) => {
|
|
36
|
+
if (isDatabaseError(error)) throw new HistoryStoreError(error.code);
|
|
37
|
+
throw error;
|
|
38
|
+
});
|
|
39
|
+
const rows = async (query) => (await execute(query)).rows.map(entryOf);
|
|
40
|
+
const unread = (userId) => sql`${column("user_id")} = ${userId} and ${column("read_at")} is null`;
|
|
41
|
+
return {
|
|
42
|
+
record: async (userId, notification) => {
|
|
43
|
+
requiredText("user id", userId);
|
|
44
|
+
const { title, body, url, data } = checkedNotification(notification);
|
|
45
|
+
const values = {
|
|
46
|
+
id: crypto.randomUUID(),
|
|
47
|
+
user_id: userId,
|
|
48
|
+
title,
|
|
49
|
+
body,
|
|
50
|
+
url,
|
|
51
|
+
data: data === null ? null : JSON.stringify(data),
|
|
52
|
+
created_at: /* @__PURE__ */ new Date(),
|
|
53
|
+
read_at: null
|
|
54
|
+
};
|
|
55
|
+
const names = sql.join(historyColumns.map(column));
|
|
56
|
+
const given = sql.join(historyColumns.map((name) => name === "data" ? sql`${values.data}::jsonb` : sql`${values[name]}`));
|
|
57
|
+
const [stored] = await rows(sql`insert into ${history} (${names}) values (${given}) returning ${selected}`);
|
|
58
|
+
return stored;
|
|
59
|
+
},
|
|
60
|
+
list: async (userId, page) => {
|
|
61
|
+
requiredText("user id", userId);
|
|
62
|
+
const { before, limit } = requiredPage(page);
|
|
63
|
+
const after = before && uuidPattern.test(before.id) ? sql`and (${column("created_at")}, ${column("id")}) < (${before.createdAt}, ${before.id}::uuid)` : before ? sql`and ${column("created_at")} < ${before.createdAt}` : sql``;
|
|
64
|
+
return rows(sql`select ${selected} from ${history} where ${column("user_id")} = ${userId} ${after}
|
|
65
|
+
order by ${column("created_at")} desc, ${column("id")} desc limit ${limit}`);
|
|
66
|
+
},
|
|
67
|
+
countUnread: async (userId) => {
|
|
68
|
+
requiredText("user id", userId);
|
|
69
|
+
return (await execute(sql`select count(*)::int as count from (select from ${history} where ${unread(userId)} limit ${100}) as unread`)).rows[0].count;
|
|
70
|
+
},
|
|
71
|
+
markRead: async (userId, id) => {
|
|
72
|
+
requiredText("user id", userId);
|
|
73
|
+
requiredText("id", id);
|
|
74
|
+
if (!uuidPattern.test(id)) return false;
|
|
75
|
+
return (await execute(sql`with found as (select ${column("id")} from ${history} where ${column("user_id")} = ${userId} and ${column("id")} = ${id}::uuid),
|
|
76
|
+
marked as (update ${history} set ${column("read_at")} = ${/* @__PURE__ */ new Date()} where ${column("id")} in (select ${column("id")} from found) and ${column("read_at")} is null)
|
|
77
|
+
select exists (select from found) as found`)).rows[0].found;
|
|
78
|
+
},
|
|
79
|
+
markAllRead: async (userId, upTo) => {
|
|
80
|
+
requiredText("user id", userId);
|
|
81
|
+
requiredTime("time", upTo);
|
|
82
|
+
await execute(sql`update ${history} set ${column("read_at")} = ${/* @__PURE__ */ new Date()} where ${unread(userId)} and ${column("created_at")} <= ${upTo}`);
|
|
83
|
+
},
|
|
84
|
+
removeAllForUser: async (userId) => {
|
|
85
|
+
requiredText("user id", userId);
|
|
86
|
+
await execute(sql`delete from ${history} where ${column("user_id")} = ${userId}`);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
//#endregion
|
|
91
|
+
export { HistoryStoreError, postgresHistoryStore };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { defineJob } from "../jobs/define-job.js";
|
|
2
|
+
import { deviceStore } from "./device-store-of.js";
|
|
3
|
+
import { HistoryStoreError } from "./postgres-history-store.js";
|
|
4
|
+
import { historyStore } from "./history-store-of.js";
|
|
5
|
+
import { holdJob, replaceJobData, saveJobProgress } from "../jobs/job-progress.js";
|
|
6
|
+
import { deliver } from "./deliver.js";
|
|
7
|
+
import { noProgress, pushPayload } from "./push-payload.js";
|
|
8
|
+
import { pushJobName } from "./push-job-name.js";
|
|
9
|
+
//#region src/notifications/push-job.ts
|
|
10
|
+
const userGone = "23503";
|
|
11
|
+
/**
|
|
12
|
+
* Keeps the entry and the progress that marks it kept in one transaction, with the default store, so no try records
|
|
13
|
+
* it twice. An app's own store records outside it. When the user was deleted since the send, the entry is not kept
|
|
14
|
+
* and the user is marked all the same, after the transaction the failed insert ended.
|
|
15
|
+
*/
|
|
16
|
+
const recordOnce = (db, options, job) => async (userId, entry, progress) => {
|
|
17
|
+
try {
|
|
18
|
+
await db.transaction().execute(async (trx) => {
|
|
19
|
+
await historyStore(trx, options).record(userId, entry);
|
|
20
|
+
await saveJobProgress(trx, job, progress);
|
|
21
|
+
});
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (!(error instanceof HistoryStoreError && error.sqlState === userGone)) throw error;
|
|
24
|
+
await saveJobProgress(db, job, progress);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* The job that delivers what `sendNotification` sends, through `senders` (guarded, see `notificationSenders`) and the
|
|
29
|
+
* stores `options` configure. A try resumes where the last one stopped (see `deliver`); once delivered, the job keeps
|
|
30
|
+
* no message. A few tries only: a device that keeps asking for a retry is given up on the second time.
|
|
31
|
+
*/
|
|
32
|
+
const pushJob = (options, senders) => defineJob({
|
|
33
|
+
name: pushJobName,
|
|
34
|
+
payload: pushPayload,
|
|
35
|
+
maxAttempts: 5,
|
|
36
|
+
retryDelay: "1 minute",
|
|
37
|
+
run: async (payload, { db, job }) => {
|
|
38
|
+
if ("complete" in payload) return;
|
|
39
|
+
await deliver(payload, payload.progress ?? noProgress(), {
|
|
40
|
+
devices: deviceStore(db, options),
|
|
41
|
+
senders,
|
|
42
|
+
record: recordOnce(db, options, job),
|
|
43
|
+
save: (progress) => saveJobProgress(db, job, progress),
|
|
44
|
+
hold: () => holdJob(db, job),
|
|
45
|
+
complete: () => replaceJobData(db, job, { complete: true })
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
//#endregion
|
|
50
|
+
export { pushJob };
|