pi-roundtable-web 0.25.0 → 0.26.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +54 -0
- package/README.md +165 -12
- package/dist/account-zk4efb87.css +102 -0
- package/dist/chunk-account-ghq105t4.js +1 -0
- package/dist/chunk-action-menu-me0g9qsc.js +1 -0
- package/dist/{chunk-agent-0pm9p3nn.js → chunk-agent-rdv7ekrt.js} +1 -1
- package/dist/chunk-arranged-rooms-81rqhx9x.js +1 -0
- package/dist/chunk-character-creator-bd426yjw.js +1 -0
- package/dist/chunk-codeeditor-ajhgd33t.js +2 -0
- package/dist/chunk-confirm-p3mts8mx.js +1 -0
- package/dist/{chunk-connectors-anhghy3k.js → chunk-connectors-bkv3xn83.js} +1 -1
- package/dist/chunk-conversations-0e0vbxwr.js +1 -0
- package/dist/{chunk-drag-q4fx9b22.js → chunk-drag-peyb8t0k.js} +1 -1
- package/dist/{chunk-entry-he1embgp.js → chunk-entry-mv7q8br0.js} +1 -1
- package/dist/chunk-folder-list-era3tksh.js +1 -0
- package/dist/chunk-folds-hvws0bd5.js +1 -0
- package/dist/chunk-gate-hc7dd0fx.js +1 -0
- package/dist/chunk-group-scene-dkbpmbh5.js +1 -0
- package/dist/chunk-group-table-fqmxgdfw.js +1 -0
- package/dist/chunk-host-icons-4xde9ntk.js +1 -0
- package/dist/chunk-host-picture-014yzk20.js +1 -0
- package/dist/chunk-host-state-t56e5sdy.js +1 -0
- package/dist/chunk-host-ui-p1zy9nmp.js +1 -0
- package/dist/chunk-index-0fmmvgee.js +2 -0
- package/dist/chunk-index-0q15ysrb.js +1 -0
- package/dist/chunk-index-0xh24y25.js +2 -0
- package/dist/chunk-index-2yebd9tb.js +426 -0
- package/dist/chunk-index-4t0r3t3b.js +2 -0
- package/dist/chunk-index-5yzfrf7b.js +138 -0
- package/dist/{chunk-index-d5abvq7v.js → chunk-index-6twb7ccd.js} +2 -2
- package/dist/chunk-index-73gh7kgr.js +920 -0
- package/dist/chunk-index-7xffskfa.js +8 -0
- package/dist/chunk-index-aqw7cy5j.js +1 -0
- package/dist/chunk-index-b6ca02e8.js +2 -0
- package/dist/chunk-index-b8nvhcx5.js +2 -0
- package/dist/{chunk-index-bvfqjh77.js → chunk-index-cvbdf2ca.js} +2 -2
- package/dist/chunk-index-czdketze.js +2 -0
- package/dist/{chunk-index-9jqcskb4.js → chunk-index-dt9775ka.js} +5 -5
- package/dist/chunk-index-eg55mqqe.js +1 -0
- package/dist/chunk-index-gr6e0hse.js +2 -0
- package/dist/chunk-index-gyary7np.js +13 -0
- package/dist/chunk-index-j214tczc.js +1 -0
- package/dist/chunk-index-jd6632ef.js +2 -0
- package/dist/chunk-index-jw7dtshr.js +2 -0
- package/dist/{chunk-index-krtk6nn2.js → chunk-index-k7t14rwa.js} +2 -2
- package/dist/chunk-index-kwcx62wb.js +4 -0
- package/dist/chunk-index-m7fvw92f.js +2 -0
- package/dist/chunk-index-mmg788a1.js +2 -0
- package/dist/chunk-index-mq0dacwr.js +2 -0
- package/dist/chunk-index-p53ak25v.js +2 -0
- package/dist/{chunk-index-27qca4mr.js → chunk-index-pk1yxkv4.js} +2 -2
- package/dist/{chunk-index-9b1kt7qw.css → chunk-index-rbcz9jd4.css} +1 -1
- package/dist/{chunk-index-s681nny9.js → chunk-index-rmxbdmn2.js} +2 -2
- package/dist/chunk-index-rnxj3z8k.js +2 -0
- package/dist/chunk-index-snzzfhk3.js +2 -0
- package/dist/chunk-index-v3bmr0nw.js +2 -0
- package/dist/chunk-index-vcyzwtk2.js +2 -0
- package/dist/{chunk-index-zfd00hwb.js → chunk-index-w2j3njb6.js} +2 -2
- package/dist/chunk-index-w31c4yjv.js +2 -0
- package/dist/chunk-index-wrm8w165.js +2 -0
- package/dist/chunk-index-xh0mg9cw.js +2 -0
- package/dist/chunk-index-ybtvvx85.js +2 -0
- package/dist/chunk-index-yzsgsvnp.js +2 -0
- package/dist/{chunk-index-18gxy79n.js → chunk-index-z9x4cnzf.js} +2 -2
- package/dist/chunk-index-zrvg23jd.js +2 -0
- package/dist/chunk-index-zynq0b0w.js +50 -0
- package/dist/chunk-lang-css-88v3hp3d.js +1 -0
- package/dist/{chunk-lang-dockerfile-d5mzxs60.js → chunk-lang-dockerfile-k728ewsf.js} +1 -1
- package/dist/{chunk-lang-go-830fmhba.js → chunk-lang-go-wjwgay4v.js} +3 -3
- package/dist/chunk-lang-html-07d47es4.js +1 -0
- package/dist/chunk-lang-javascript-fmhhgzwd.js +1 -0
- package/dist/{chunk-lang-json-5eaarz7j.js → chunk-lang-json-7gd89mvs.js} +1 -1
- package/dist/chunk-lang-markdown-b6qbe8bt.js +6 -0
- package/dist/{chunk-lang-python-fjw84w10.js → chunk-lang-python-3v56y31b.js} +2 -2
- package/dist/{chunk-lang-rust-5rbp2zdk.js → chunk-lang-rust-kh0z74d1.js} +1 -1
- package/dist/{chunk-lang-shell-vzagpym3.js → chunk-lang-shell-9zv0zvbx.js} +1 -1
- package/dist/{chunk-lang-sql-0c2w9c2y.js → chunk-lang-sql-yw6bf3t7.js} +2 -2
- package/dist/{chunk-lang-toml-yr9fv6eb.js → chunk-lang-toml-tg1r96nq.js} +1 -1
- package/dist/chunk-lang-typescript-etjjvr2g.js +1 -0
- package/dist/{chunk-lang-xml-eze08p42.js → chunk-lang-xml-ev1mndd4.js} +1 -1
- package/dist/{chunk-lang-yaml-c4b6ft3y.js → chunk-lang-yaml-fz5qpb3r.js} +1 -1
- package/dist/chunk-layout-actions-48ecgtkg.js +1 -0
- package/dist/{chunk-menu-items-jrwxycr1.js → chunk-menu-items-1epy3h0t.js} +2 -2
- package/dist/chunk-mode-toggle-fxmfxsa4.js +1 -0
- package/dist/chunk-note-row-kd0fy7ee.js +75 -0
- package/dist/chunk-notes-yb8f057c.js +1 -0
- package/dist/chunk-notice-bell-wr4f1rph.js +1 -0
- package/dist/chunk-notices-x07da5bb.js +1 -0
- package/dist/chunk-onboarding-csrkbh9z.js +1 -0
- package/dist/chunk-overview-avc2d30t.js +1 -0
- package/dist/chunk-palette-9nhp95bz.js +1 -0
- package/dist/chunk-placement-items-q694dpgk.js +1 -0
- package/dist/chunk-profile-6qemf3m7.js +1 -0
- package/dist/chunk-prompt-history-7enzp2p3.js +1 -0
- package/dist/chunk-prompts-nv6mrn0q.js +1 -0
- package/dist/chunk-room-confirms-zz7n61y7.js +1 -0
- package/dist/chunk-room-menu-rr6nzap1.js +1 -0
- package/dist/chunk-rooms-menu-button-zbtwj47d.js +1 -0
- package/dist/chunk-schedules-rw57nr0m.js +1 -0
- package/dist/{chunk-seat-menu-jv4s7hmj.js → chunk-seat-menu-ys5v9abz.js} +1 -1
- package/dist/chunk-sheet-hosts-jhvaacst.js +1 -0
- package/dist/chunk-skill-editor-sz8an8qr.js +8 -0
- package/dist/chunk-skills-tnr1h16h.js +1 -0
- package/dist/chunk-switcher-list-17enhrse.js +1 -0
- package/dist/chunk-tab-dot-r6brdbmw.js +1 -0
- package/dist/chunk-table-r12cja6p.js +1 -0
- package/dist/chunk-table-seats-g3t392zz.js +1 -0
- package/dist/{chunk-table2d-4sje1fr0.js → chunk-table2d-p3ezsdcj.js} +1 -1
- package/dist/{chunk-table3d-h64skazp.js → chunk-table3d-4x2n2b00.js} +23 -23
- package/dist/chunk-thread-actions-qc6ezkha.js +1 -0
- package/dist/chunk-transcript-n2s6dher.js +1 -0
- package/dist/chunk-where-f3scdydc.js +1 -0
- package/dist/chunk-workspace-xs023bxm.js +1 -0
- package/dist/chunk-zh-tw-efvmk3fd.js +1 -0
- package/dist/index.html +1 -1
- package/dist/sw.js +1 -1
- package/package.json +5 -4
- package/src/api-types.ts +356 -4
- package/src/app/app.ts +201 -32
- package/src/app/desk.ts +47 -22
- package/src/app/files.ts +73 -7
- package/src/app/presence.ts +2 -2
- package/src/app/protocol.ts +63 -1
- package/src/app/room-log.ts +153 -14
- package/src/app/rooms.ts +39 -0
- package/src/app/routes.ts +168 -28
- package/src/app/socket.ts +349 -81
- package/src/app/sockets.ts +108 -20
- package/src/app/surface.ts +72 -36
- package/src/app/threads.ts +31 -2
- package/src/app/uploads.ts +33 -7
- package/src/auth/authenticator.ts +77 -8
- package/src/auth/login-page.ts +4 -2
- package/src/auth/next.ts +52 -0
- package/src/auth/oidc-pages.ts +62 -0
- package/src/auth/oidc.ts +649 -0
- package/src/auth/sessions.ts +193 -0
- package/src/authz.ts +173 -0
- package/src/console-api.ts +122 -25
- package/src/console-server.ts +102 -12
- package/src/gate.ts +90 -0
- package/src/host/access.ts +161 -0
- package/src/host/cards.ts +112 -0
- package/src/host/context.ts +188 -0
- package/src/host/forms.ts +240 -0
- package/src/host/frames.ts +349 -0
- package/src/host/host-app.ts +178 -0
- package/src/host/host-context.ts +49 -0
- package/src/host/host-routes.ts +396 -0
- package/src/host/pages.ts +266 -0
- package/src/host/reads.ts +155 -0
- package/src/host/retention.ts +41 -0
- package/src/host/room-views.ts +137 -0
- package/src/host/rooms-service.ts +429 -0
- package/src/host/stored-json.ts +13 -0
- package/src/host/views.ts +106 -0
- package/src/i18n/zh-tw.ts +139 -0
- package/src/index.ts +15 -1
- package/src/notices/api.ts +43 -0
- package/src/notices/mount.ts +292 -0
- package/src/notices/service.ts +294 -0
- package/src/notices/store.ts +298 -0
- package/src/notices/urls.ts +38 -0
- package/src/onboarding/api.ts +32 -0
- package/src/onboarding/service.ts +149 -0
- package/src/onboarding/steps.ts +75 -0
- package/src/onboarding/waivers.ts +40 -0
- package/src/options.ts +534 -34
- package/src/page-presentation.ts +47 -2
- package/src/profile/api.ts +175 -0
- package/src/profile/links.ts +162 -0
- package/src/push/api.ts +189 -52
- package/src/push/dispatch.ts +156 -0
- package/src/push/drain.ts +139 -0
- package/src/push/outbox.ts +218 -0
- package/src/push/prefs.ts +118 -0
- package/src/push/sender.ts +7 -2
- package/src/push/triggers.ts +56 -5
- package/src/push/vapid.ts +19 -0
- package/src/push/web-push-transport.ts +106 -0
- package/src/push-sender/index.ts +14 -0
- package/src/push-sender/run.ts +118 -0
- package/src/push-sender/session.ts +54 -0
- package/src/pwa.ts +32 -4
- package/src/visitors.ts +94 -6
- package/src/web-app.ts +718 -0
- package/src/web-plugin.ts +279 -26
- package/dist/chunk-action-menu-qe839g4b.js +0 -1
- package/dist/chunk-arranged-rooms-enpfswmg.js +0 -1
- package/dist/chunk-character-creator-6yf29xwx.js +0 -1
- package/dist/chunk-codeeditor-gdbdg7rp.js +0 -2
- package/dist/chunk-confirm-xrw7d732.js +0 -1
- package/dist/chunk-conversations-ry228aes.js +0 -1
- package/dist/chunk-folder-list-5cp8stna.js +0 -1
- package/dist/chunk-folds-hff104tk.js +0 -1
- package/dist/chunk-group-scene-h228jdat.js +0 -1
- package/dist/chunk-index-0p5h2kdz.js +0 -2
- package/dist/chunk-index-0pt08yd9.js +0 -1
- package/dist/chunk-index-12bw9yj8.js +0 -2
- package/dist/chunk-index-2ad17eh4.js +0 -2
- package/dist/chunk-index-31wh5twj.js +0 -52
- package/dist/chunk-index-4dy94pbz.js +0 -2
- package/dist/chunk-index-4ecjtdy8.js +0 -13
- package/dist/chunk-index-4v1b7ks9.js +0 -2
- package/dist/chunk-index-5d8yjj4m.js +0 -2
- package/dist/chunk-index-89ajmxh7.js +0 -2
- package/dist/chunk-index-b9ny4623.js +0 -2
- package/dist/chunk-index-e1dr6g10.js +0 -2
- package/dist/chunk-index-gpkzhq1g.js +0 -2
- package/dist/chunk-index-hk3xyy20.js +0 -2
- package/dist/chunk-index-jm23vn03.js +0 -4
- package/dist/chunk-index-k7gh0jvz.js +0 -2
- package/dist/chunk-index-ky42ev0b.js +0 -2
- package/dist/chunk-index-pa2yyqh0.js +0 -2
- package/dist/chunk-index-pcvt668q.js +0 -2
- package/dist/chunk-index-pqw27eye.js +0 -1
- package/dist/chunk-index-wmbmmrd4.js +0 -302
- package/dist/chunk-index-ymbjrsp6.js +0 -2
- package/dist/chunk-lang-css-t85r8rz0.js +0 -1
- package/dist/chunk-lang-html-n6jparge.js +0 -1
- package/dist/chunk-lang-javascript-xj00cp97.js +0 -1
- package/dist/chunk-lang-markdown-zxx8eke8.js +0 -6
- package/dist/chunk-lang-typescript-8f6yywt3.js +0 -1
- package/dist/chunk-layout-actions-bv0chj41.js +0 -1
- package/dist/chunk-notes-9b7pgqfe.js +0 -1
- package/dist/chunk-overview-8yw71esz.js +0 -1
- package/dist/chunk-palette-frms7axe.js +0 -1
- package/dist/chunk-placement-items-fvfy9m0y.js +0 -1
- package/dist/chunk-prompt-history-yrqehads.js +0 -1
- package/dist/chunk-prompts-g6609tqx.js +0 -1
- package/dist/chunk-room-confirms-0a2y0925.js +0 -1
- package/dist/chunk-room-menu-g11q97br.js +0 -1
- package/dist/chunk-rooms-menu-button-h9qge48n.js +0 -1
- package/dist/chunk-schedules-ezmacrbj.js +0 -1
- package/dist/chunk-sheet-hosts-b0azyfhb.js +0 -1
- package/dist/chunk-skill-editor-h6k6whwg.js +0 -8
- package/dist/chunk-skills-gqcfhq56.js +0 -1
- package/dist/chunk-switcher-list-mcpamqnj.js +0 -1
- package/dist/chunk-table-s8cde1xz.js +0 -1
- package/dist/chunk-table-seats-wqkjn8bc.js +0 -1
- package/dist/chunk-transcript-c59c6mpm.js +0 -1
- package/dist/chunk-workspace-0cyspkk4.js +0 -1
- package/dist/chunk-zh-tw-nc93jm10.js +0 -1
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { ConfigError, type Logger } from "pi-roundtable";
|
|
2
|
+
import type { NoticeView, ServerFrame } from "../api-types.ts";
|
|
3
|
+
import type { PushDispatch } from "../push/dispatch.ts";
|
|
4
|
+
import type { Notices, OpenTarget } from "../web-app.ts";
|
|
5
|
+
import type { NoticeFor, NoticeStore } from "./store.ts";
|
|
6
|
+
import { openUrl, type RoutingOptions } from "./urls.ts";
|
|
7
|
+
|
|
8
|
+
const MAX_TAG = 200;
|
|
9
|
+
const MAX_TITLE = 200;
|
|
10
|
+
const MAX_BODY = 2000;
|
|
11
|
+
const MAX_RECIPIENTS = 500;
|
|
12
|
+
const PAGE_ID = /^[a-z0-9-]{1,40}$/;
|
|
13
|
+
|
|
14
|
+
export interface NoticeServiceOptions {
|
|
15
|
+
store: NoticeStore;
|
|
16
|
+
dispatch: PushDispatch;
|
|
17
|
+
/** Puts a frame on the open sockets of one person. */
|
|
18
|
+
deliver(frame: ServerFrame, principalId: string): void;
|
|
19
|
+
routing: RoutingOptions;
|
|
20
|
+
logger: Logger;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A notice as the bell shows it. */
|
|
24
|
+
export function viewOf(notice: NoticeFor): NoticeView {
|
|
25
|
+
return {
|
|
26
|
+
tag: notice.tag,
|
|
27
|
+
title: notice.title,
|
|
28
|
+
...(notice.body ? { body: notice.body } : {}),
|
|
29
|
+
...(notice.open
|
|
30
|
+
? {
|
|
31
|
+
open:
|
|
32
|
+
"room" in notice.open
|
|
33
|
+
? {
|
|
34
|
+
room: notice.open.room,
|
|
35
|
+
...(notice.open.message === undefined
|
|
36
|
+
? {}
|
|
37
|
+
: { message: notice.open.message }),
|
|
38
|
+
}
|
|
39
|
+
: {
|
|
40
|
+
page: notice.open.page,
|
|
41
|
+
...(notice.open.query
|
|
42
|
+
? { query: { ...notice.open.query } }
|
|
43
|
+
: {}),
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
: {}),
|
|
47
|
+
urgency: notice.urgency,
|
|
48
|
+
ackable: notice.ackable,
|
|
49
|
+
raisedAt: notice.raisedAt.toISOString(),
|
|
50
|
+
...(notice.ackedAt ? { acked: true as const } : {}),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const refuse = (message: string): never => {
|
|
55
|
+
throw new ConfigError(`notices: ${message}`);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function checkOpen(open: OpenTarget | undefined): void {
|
|
59
|
+
if (!open) return;
|
|
60
|
+
if ("room" in open) {
|
|
61
|
+
if (typeof open.room !== "string" || open.room.length === 0)
|
|
62
|
+
refuse("open.room must be a room key");
|
|
63
|
+
if (
|
|
64
|
+
open.message !== undefined &&
|
|
65
|
+
!(Number.isSafeInteger(open.message) && open.message >= 0)
|
|
66
|
+
)
|
|
67
|
+
refuse("open.message must be a message id");
|
|
68
|
+
} else if (!PAGE_ID.test(open.page))
|
|
69
|
+
refuse("open.page must match [a-z0-9-]{1,40}");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The notices of the host: person-addressed, with state. A notice is a row the bell shows to its
|
|
74
|
+
* addressees and, when push is on, a push to their devices; the badge is the count of those that
|
|
75
|
+
* ask for an answer and have none. Everything a person can do to it (read, answer) is checked
|
|
76
|
+
* against the addressee list, which only the host changes.
|
|
77
|
+
*/
|
|
78
|
+
export class NoticeService implements Notices {
|
|
79
|
+
readonly #options: NoticeServiceOptions;
|
|
80
|
+
readonly #listeners = new Set<(tag: string, principalId: string) => void>();
|
|
81
|
+
|
|
82
|
+
constructor(options: NoticeServiceOptions) {
|
|
83
|
+
this.#options = options;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async raise(notice: Parameters<Notices["raise"]>[0]): Promise<void> {
|
|
87
|
+
const { store } = this.#options;
|
|
88
|
+
const to = [...new Set(notice.to)];
|
|
89
|
+
if (notice.tag.length === 0 || notice.tag.length > MAX_TAG)
|
|
90
|
+
refuse(`the tag must be 1 to ${MAX_TAG} characters`);
|
|
91
|
+
if (to.length === 0 || to.length > MAX_RECIPIENTS)
|
|
92
|
+
refuse(`a notice goes to 1 to ${MAX_RECIPIENTS} people`);
|
|
93
|
+
if (notice.title.length === 0 || notice.title.length > MAX_TITLE)
|
|
94
|
+
refuse(`the title must be 1 to ${MAX_TITLE} characters`);
|
|
95
|
+
if ((notice.body?.length ?? 0) > MAX_BODY)
|
|
96
|
+
refuse(`the body is longer than ${MAX_BODY} characters`);
|
|
97
|
+
checkOpen(notice.open);
|
|
98
|
+
this.#checkVia(notice.via);
|
|
99
|
+
await store.raise(
|
|
100
|
+
{
|
|
101
|
+
tag: notice.tag,
|
|
102
|
+
title: notice.title,
|
|
103
|
+
...(notice.body ? { body: notice.body } : {}),
|
|
104
|
+
...(notice.open ? { open: notice.open } : {}),
|
|
105
|
+
urgency: notice.urgency,
|
|
106
|
+
ackable: notice.ackable === true,
|
|
107
|
+
},
|
|
108
|
+
to,
|
|
109
|
+
);
|
|
110
|
+
await Promise.all(
|
|
111
|
+
to.map((principalId) =>
|
|
112
|
+
this.#tell(notice.tag, principalId, {
|
|
113
|
+
push: notice.push !== false,
|
|
114
|
+
...(notice.via ? { via: notice.via } : {}),
|
|
115
|
+
}),
|
|
116
|
+
),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async renotify(
|
|
121
|
+
tag: string,
|
|
122
|
+
add: readonly string[] = [],
|
|
123
|
+
options: { via?: readonly string[] } = {},
|
|
124
|
+
): Promise<void> {
|
|
125
|
+
this.#checkVia(options.via);
|
|
126
|
+
const people = await this.#options.store.renotify(tag, [...new Set(add)]);
|
|
127
|
+
if (!people) return;
|
|
128
|
+
await Promise.all(
|
|
129
|
+
people.map((principalId) =>
|
|
130
|
+
this.#tell(tag, principalId, {
|
|
131
|
+
push: true,
|
|
132
|
+
...(options.via ? { via: options.via } : {}),
|
|
133
|
+
}),
|
|
134
|
+
),
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async resolve(
|
|
139
|
+
tag: string,
|
|
140
|
+
by?: { principalId?: string; reason: string },
|
|
141
|
+
): Promise<void> {
|
|
142
|
+
const { store } = this.#options;
|
|
143
|
+
const before = new Map<string, NoticeFor | undefined>();
|
|
144
|
+
const open = (await store.pending(tag))?.to ?? [];
|
|
145
|
+
for (const principalId of open)
|
|
146
|
+
before.set(principalId, await store.of(tag, principalId));
|
|
147
|
+
const people = await store.resolve(tag, by);
|
|
148
|
+
if (!people) return;
|
|
149
|
+
await Promise.all(
|
|
150
|
+
people.map((principalId) =>
|
|
151
|
+
this.#cleared(tag, principalId, before.get(principalId)),
|
|
152
|
+
),
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Tells one addressee's pages and devices a notice is gone. */
|
|
157
|
+
async #cleared(
|
|
158
|
+
tag: string,
|
|
159
|
+
principalId: string,
|
|
160
|
+
was: NoticeFor | undefined,
|
|
161
|
+
): Promise<void> {
|
|
162
|
+
const { store, dispatch, routing } = this.#options;
|
|
163
|
+
this.#options.deliver({ type: "notice_resolved", tag }, principalId);
|
|
164
|
+
const count = await this.#sendBadge(principalId);
|
|
165
|
+
// Only a device that was pushed this notice has something to close.
|
|
166
|
+
if (await store.delivered(tag, principalId))
|
|
167
|
+
await dispatch.clear(
|
|
168
|
+
principalId,
|
|
169
|
+
tag,
|
|
170
|
+
openUrl(was?.open, routing),
|
|
171
|
+
count,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
pending(tag: string): ReturnType<Notices["pending"]> {
|
|
176
|
+
return this.#options.store.pending(tag);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
onAck(listener: (tag: string, principalId: string) => void): () => void {
|
|
180
|
+
this.#listeners.add(listener);
|
|
181
|
+
return () => this.#listeners.delete(listener);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── What the person does ──
|
|
185
|
+
|
|
186
|
+
/** The person's open notices and their count. */
|
|
187
|
+
async listFor(
|
|
188
|
+
principalId: string,
|
|
189
|
+
): Promise<{ notices: NoticeView[]; badge: number }> {
|
|
190
|
+
const { store } = this.#options;
|
|
191
|
+
const [notices, badge] = await Promise.all([
|
|
192
|
+
store.openFor(principalId),
|
|
193
|
+
store.unhandled(principalId),
|
|
194
|
+
]);
|
|
195
|
+
return { notices: notices.map(viewOf), badge };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The person handled a notice; false when it is not theirs, is closed, or asks for no answer. */
|
|
199
|
+
async ack(tag: string, principalId: string): Promise<boolean> {
|
|
200
|
+
const { store, dispatch, routing } = this.#options;
|
|
201
|
+
if (!(await store.ack(tag, principalId))) return false;
|
|
202
|
+
const notice = await store.of(tag, principalId);
|
|
203
|
+
if (notice)
|
|
204
|
+
this.#options.deliver(
|
|
205
|
+
{ type: "notice", notice: viewOf(notice) },
|
|
206
|
+
principalId,
|
|
207
|
+
);
|
|
208
|
+
const count = await this.#sendBadge(principalId);
|
|
209
|
+
// Their other devices stop showing it.
|
|
210
|
+
if (await store.delivered(tag, principalId))
|
|
211
|
+
await dispatch.clear(
|
|
212
|
+
principalId,
|
|
213
|
+
tag,
|
|
214
|
+
openUrl(notice?.open, routing),
|
|
215
|
+
count,
|
|
216
|
+
);
|
|
217
|
+
for (const listener of this.#listeners) {
|
|
218
|
+
try {
|
|
219
|
+
listener(tag, principalId);
|
|
220
|
+
} catch (error) {
|
|
221
|
+
this.#options.logger.warn({ err: error, tag }, "an ack listener threw");
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Records a receipt from the service worker; false when the notice is not addressed to the person. */
|
|
228
|
+
receipt(
|
|
229
|
+
tag: string,
|
|
230
|
+
principalId: string,
|
|
231
|
+
event: "received" | "opened",
|
|
232
|
+
): Promise<boolean> {
|
|
233
|
+
return this.#options.store.receipt(tag, principalId, event);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The count the app icon shows for the person: their notices not handled. */
|
|
237
|
+
badge(principalId: string): Promise<number> {
|
|
238
|
+
return this.#options.store.unhandled(principalId);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The frames a socket gets when it opens: every notice that is open for the person, and the count. */
|
|
242
|
+
async frames(principalId: string): Promise<ServerFrame[]> {
|
|
243
|
+
const { notices, badge } = await this.listFor(principalId);
|
|
244
|
+
return [
|
|
245
|
+
...notices.map((notice): ServerFrame => ({ type: "notice", notice })),
|
|
246
|
+
{ type: "badge", count: badge },
|
|
247
|
+
];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ── Inside ──
|
|
251
|
+
|
|
252
|
+
#checkVia(via: readonly string[] | undefined): void {
|
|
253
|
+
if (!via) return;
|
|
254
|
+
const known = this.#options.dispatch.transports;
|
|
255
|
+
if (via.length === 0) refuse("via names no transport");
|
|
256
|
+
for (const name of via)
|
|
257
|
+
if (!known.includes(name))
|
|
258
|
+
refuse(
|
|
259
|
+
`via names ${JSON.stringify(name)}, but the sender has ${known.join(", ")}; list a host transport in push.transports`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async #sendBadge(principalId: string): Promise<number> {
|
|
264
|
+
const count = await this.#options.store.unhandled(principalId);
|
|
265
|
+
this.#options.deliver({ type: "badge", count }, principalId);
|
|
266
|
+
return count;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Shows a notice to one addressee and, when asked, pushes it to their devices. */
|
|
270
|
+
async #tell(
|
|
271
|
+
tag: string,
|
|
272
|
+
principalId: string,
|
|
273
|
+
options: { push: boolean; via?: readonly string[] },
|
|
274
|
+
): Promise<void> {
|
|
275
|
+
const { store, dispatch, routing } = this.#options;
|
|
276
|
+
const notice = await store.of(tag, principalId);
|
|
277
|
+
if (!notice) return;
|
|
278
|
+
this.#options.deliver(
|
|
279
|
+
{ type: "notice", notice: viewOf(notice) },
|
|
280
|
+
principalId,
|
|
281
|
+
);
|
|
282
|
+
const count = await this.#sendBadge(principalId);
|
|
283
|
+
if (!options.push) return;
|
|
284
|
+
await dispatch.notice(principalId, {
|
|
285
|
+
tag,
|
|
286
|
+
title: notice.title,
|
|
287
|
+
...(notice.body ? { body: notice.body } : {}),
|
|
288
|
+
open: openUrl(notice.open, routing),
|
|
289
|
+
urgency: notice.urgency,
|
|
290
|
+
badge: count,
|
|
291
|
+
...(options.via ? { via: options.via } : {}),
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
|
+
import type { Migration } from "pi-roundtable";
|
|
3
|
+
import type { OpenTarget } from "../web-app.ts";
|
|
4
|
+
|
|
5
|
+
/** Where a tap on a notice goes. */
|
|
6
|
+
export type NoticeOpen = OpenTarget;
|
|
7
|
+
|
|
8
|
+
/** A notice as it is stored. */
|
|
9
|
+
export interface StoredNotice {
|
|
10
|
+
tag: string;
|
|
11
|
+
title: string;
|
|
12
|
+
body?: string;
|
|
13
|
+
open?: NoticeOpen;
|
|
14
|
+
urgency: "normal" | "high";
|
|
15
|
+
ackable: boolean;
|
|
16
|
+
raisedAt: Date;
|
|
17
|
+
resolvedAt?: Date;
|
|
18
|
+
resolvedBy?: string;
|
|
19
|
+
reason?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** One notice with the state of the person it is shown to. */
|
|
23
|
+
export interface NoticeFor extends StoredNotice {
|
|
24
|
+
ackedAt?: Date;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface NoticeRow {
|
|
28
|
+
tag: string;
|
|
29
|
+
title: string;
|
|
30
|
+
body: string | null;
|
|
31
|
+
open: NoticeOpen | string | null;
|
|
32
|
+
urgency: "normal" | "high";
|
|
33
|
+
ackable: boolean;
|
|
34
|
+
raised_at: Date;
|
|
35
|
+
resolved_at: Date | null;
|
|
36
|
+
resolved_by: string | null;
|
|
37
|
+
reason: string | null;
|
|
38
|
+
acked_at?: Date | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A jsonb column as the driver hands it back: parsed, or as text. */
|
|
42
|
+
function openOf(value: NoticeRow["open"]): NoticeOpen | undefined {
|
|
43
|
+
if (value === null) return undefined;
|
|
44
|
+
if (typeof value !== "string") return value;
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(value) as NoticeOpen;
|
|
47
|
+
} catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const noticeOf = (row: NoticeRow): StoredNotice => {
|
|
53
|
+
const open = openOf(row.open);
|
|
54
|
+
return {
|
|
55
|
+
tag: row.tag,
|
|
56
|
+
title: row.title,
|
|
57
|
+
...(row.body === null ? {} : { body: row.body }),
|
|
58
|
+
...(open ? { open } : {}),
|
|
59
|
+
urgency: row.urgency,
|
|
60
|
+
ackable: row.ackable,
|
|
61
|
+
raisedAt: row.raised_at,
|
|
62
|
+
...(row.resolved_at ? { resolvedAt: row.resolved_at } : {}),
|
|
63
|
+
...(row.resolved_by ? { resolvedBy: row.resolved_by } : {}),
|
|
64
|
+
...(row.reason ? { reason: row.reason } : {}),
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Resolved notices are kept this long, in days, and then deleted. */
|
|
69
|
+
const KEEP_RESOLVED_DAYS = 30;
|
|
70
|
+
|
|
71
|
+
/** What `pending` reports for a notice. */
|
|
72
|
+
export interface NoticeState {
|
|
73
|
+
raisedAt: Date;
|
|
74
|
+
to: readonly string[];
|
|
75
|
+
acked: readonly { principalId: string; at: Date }[];
|
|
76
|
+
received: readonly string[];
|
|
77
|
+
opened: readonly string[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The notices (`web_notices`) and who each one is addressed to and how they answered (`web_notice_recipients`). */
|
|
81
|
+
export class NoticeStore {
|
|
82
|
+
readonly #sql: SQL;
|
|
83
|
+
|
|
84
|
+
constructor(sql: SQL) {
|
|
85
|
+
this.#sql = sql;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
static readonly migration: Migration = {
|
|
89
|
+
name: "web-notices",
|
|
90
|
+
up: async (sql) => {
|
|
91
|
+
await sql`
|
|
92
|
+
CREATE TABLE IF NOT EXISTS web_notices (
|
|
93
|
+
tag text PRIMARY KEY,
|
|
94
|
+
title text NOT NULL,
|
|
95
|
+
body text,
|
|
96
|
+
open jsonb,
|
|
97
|
+
urgency text NOT NULL,
|
|
98
|
+
ackable boolean NOT NULL DEFAULT false,
|
|
99
|
+
raised_at timestamptz NOT NULL DEFAULT now(),
|
|
100
|
+
resolved_at timestamptz,
|
|
101
|
+
resolved_by text,
|
|
102
|
+
reason text
|
|
103
|
+
)`;
|
|
104
|
+
await sql`
|
|
105
|
+
CREATE TABLE IF NOT EXISTS web_notice_recipients (
|
|
106
|
+
tag text NOT NULL REFERENCES web_notices (tag) ON DELETE CASCADE,
|
|
107
|
+
principal_id text NOT NULL,
|
|
108
|
+
added_at timestamptz NOT NULL DEFAULT now(),
|
|
109
|
+
acked_at timestamptz,
|
|
110
|
+
received_at timestamptz,
|
|
111
|
+
opened_at timestamptz,
|
|
112
|
+
PRIMARY KEY (tag, principal_id)
|
|
113
|
+
)`;
|
|
114
|
+
await sql`CREATE INDEX IF NOT EXISTS web_notice_recipients_principal ON web_notice_recipients (principal_id)`;
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Raises a notice, or raises it again: the notice is replaced and reopened, and its recipients
|
|
120
|
+
* become exactly `to`, each starting unanswered.
|
|
121
|
+
*/
|
|
122
|
+
async raise(
|
|
123
|
+
notice: Omit<
|
|
124
|
+
StoredNotice,
|
|
125
|
+
"raisedAt" | "resolvedAt" | "resolvedBy" | "reason"
|
|
126
|
+
>,
|
|
127
|
+
to: readonly string[],
|
|
128
|
+
): Promise<void> {
|
|
129
|
+
await this.#sql.begin(async (tx) => {
|
|
130
|
+
await tx`
|
|
131
|
+
INSERT INTO web_notices (tag, title, body, open, urgency, ackable)
|
|
132
|
+
VALUES (${notice.tag}, ${notice.title}, ${notice.body ?? null},
|
|
133
|
+
${notice.open ? JSON.stringify(notice.open) : null}::jsonb,
|
|
134
|
+
${notice.urgency}, ${notice.ackable})
|
|
135
|
+
ON CONFLICT (tag) DO UPDATE SET
|
|
136
|
+
title = EXCLUDED.title, body = EXCLUDED.body, open = EXCLUDED.open,
|
|
137
|
+
urgency = EXCLUDED.urgency, ackable = EXCLUDED.ackable,
|
|
138
|
+
raised_at = now(), resolved_at = NULL, resolved_by = NULL, reason = NULL`;
|
|
139
|
+
await tx`DELETE FROM web_notice_recipients WHERE tag = ${notice.tag}`;
|
|
140
|
+
for (const principalId of to)
|
|
141
|
+
await tx`
|
|
142
|
+
INSERT INTO web_notice_recipients (tag, principal_id)
|
|
143
|
+
VALUES (${notice.tag}, ${principalId})`;
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Adds recipients to an open notice and starts the ones who have not handled it over; returns who now gets it again. */
|
|
148
|
+
async renotify(
|
|
149
|
+
tag: string,
|
|
150
|
+
add: readonly string[],
|
|
151
|
+
): Promise<readonly string[] | undefined> {
|
|
152
|
+
return this.#sql.begin(async (tx) => {
|
|
153
|
+
const [found]: { tag: string }[] = await tx`
|
|
154
|
+
SELECT tag FROM web_notices WHERE tag = ${tag} AND resolved_at IS NULL FOR UPDATE`;
|
|
155
|
+
if (!found) return undefined;
|
|
156
|
+
for (const principalId of add)
|
|
157
|
+
await tx`
|
|
158
|
+
INSERT INTO web_notice_recipients (tag, principal_id)
|
|
159
|
+
VALUES (${tag}, ${principalId}) ON CONFLICT DO NOTHING`;
|
|
160
|
+
const rows: { principal_id: string }[] = await tx`
|
|
161
|
+
SELECT principal_id FROM web_notice_recipients
|
|
162
|
+
WHERE tag = ${tag} AND acked_at IS NULL ORDER BY added_at, principal_id`;
|
|
163
|
+
return rows.map((row) => row.principal_id);
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Closes a notice; false when there is none open under the tag. */
|
|
168
|
+
async resolve(
|
|
169
|
+
tag: string,
|
|
170
|
+
by: { principalId?: string; reason: string } | undefined,
|
|
171
|
+
): Promise<readonly string[] | undefined> {
|
|
172
|
+
return this.#sql.begin(async (tx) => {
|
|
173
|
+
const rows: { tag: string }[] = await tx`
|
|
174
|
+
UPDATE web_notices SET resolved_at = now(), resolved_by = ${by?.principalId ?? null},
|
|
175
|
+
reason = ${by?.reason ?? null}
|
|
176
|
+
WHERE tag = ${tag} AND resolved_at IS NULL RETURNING tag`;
|
|
177
|
+
if (rows.length === 0) return undefined;
|
|
178
|
+
const to: { principal_id: string }[] = await tx`
|
|
179
|
+
SELECT principal_id FROM web_notice_recipients WHERE tag = ${tag}`;
|
|
180
|
+
return to.map((row) => row.principal_id);
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The notice as the person sees it, if it is addressed to them. */
|
|
185
|
+
async of(tag: string, principalId: string): Promise<NoticeFor | undefined> {
|
|
186
|
+
const [row]: NoticeRow[] = await this.#sql`
|
|
187
|
+
SELECT n.*, r.acked_at FROM web_notices n
|
|
188
|
+
JOIN web_notice_recipients r ON r.tag = n.tag
|
|
189
|
+
WHERE n.tag = ${tag} AND r.principal_id = ${principalId}`;
|
|
190
|
+
return row ? this.#forPerson(row) : undefined;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The notices not resolved that are addressed to the person, newest first. */
|
|
194
|
+
async openFor(principalId: string): Promise<NoticeFor[]> {
|
|
195
|
+
const rows: NoticeRow[] = await this.#sql`
|
|
196
|
+
SELECT n.*, r.acked_at FROM web_notices n
|
|
197
|
+
JOIN web_notice_recipients r ON r.tag = n.tag
|
|
198
|
+
WHERE r.principal_id = ${principalId} AND n.resolved_at IS NULL
|
|
199
|
+
ORDER BY n.raised_at DESC, n.tag
|
|
200
|
+
LIMIT 200`;
|
|
201
|
+
return rows.map((row) => this.#forPerson(row));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
#forPerson(row: NoticeRow): NoticeFor {
|
|
205
|
+
return {
|
|
206
|
+
...noticeOf(row),
|
|
207
|
+
...(row.acked_at ? { ackedAt: row.acked_at } : {}),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** How many notices the person has to handle: addressed to them, open, wanting an answer and not acked. */
|
|
212
|
+
async unhandled(principalId: string): Promise<number> {
|
|
213
|
+
const [found]: { n: number }[] = await this.#sql`
|
|
214
|
+
SELECT count(*)::int AS n FROM web_notices n
|
|
215
|
+
JOIN web_notice_recipients r ON r.tag = n.tag
|
|
216
|
+
WHERE r.principal_id = ${principalId} AND n.resolved_at IS NULL
|
|
217
|
+
AND n.ackable AND r.acked_at IS NULL`;
|
|
218
|
+
return found?.n ?? 0;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Marks a notice handled by the person; false when it is not addressed to them, not open, or not ackable. */
|
|
222
|
+
async ack(tag: string, principalId: string): Promise<boolean> {
|
|
223
|
+
const rows: unknown[] = await this.#sql`
|
|
224
|
+
UPDATE web_notice_recipients r SET acked_at = COALESCE(r.acked_at, now())
|
|
225
|
+
FROM web_notices n
|
|
226
|
+
WHERE n.tag = r.tag AND r.tag = ${tag} AND r.principal_id = ${principalId}
|
|
227
|
+
AND n.resolved_at IS NULL AND n.ackable
|
|
228
|
+
RETURNING r.tag`;
|
|
229
|
+
return rows.length > 0;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Records that the person's device received or opened the notice; false when the notice is not
|
|
234
|
+
* addressed to them. The first time of each is kept.
|
|
235
|
+
*/
|
|
236
|
+
async receipt(
|
|
237
|
+
tag: string,
|
|
238
|
+
principalId: string,
|
|
239
|
+
event: "received" | "opened",
|
|
240
|
+
): Promise<boolean> {
|
|
241
|
+
const rows: unknown[] =
|
|
242
|
+
event === "received"
|
|
243
|
+
? await this.#sql`
|
|
244
|
+
UPDATE web_notice_recipients SET received_at = COALESCE(received_at, now())
|
|
245
|
+
WHERE tag = ${tag} AND principal_id = ${principalId} RETURNING tag`
|
|
246
|
+
: await this.#sql`
|
|
247
|
+
UPDATE web_notice_recipients SET opened_at = COALESCE(opened_at, now())
|
|
248
|
+
WHERE tag = ${tag} AND principal_id = ${principalId} RETURNING tag`;
|
|
249
|
+
return rows.length > 0;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async pending(tag: string): Promise<NoticeState | undefined> {
|
|
253
|
+
const [notice]: { raised_at: Date }[] = await this.#sql`
|
|
254
|
+
SELECT raised_at FROM web_notices WHERE tag = ${tag} AND resolved_at IS NULL`;
|
|
255
|
+
if (!notice) return undefined;
|
|
256
|
+
const rows: {
|
|
257
|
+
principal_id: string;
|
|
258
|
+
acked_at: Date | null;
|
|
259
|
+
received_at: Date | null;
|
|
260
|
+
opened_at: Date | null;
|
|
261
|
+
}[] = await this.#sql`
|
|
262
|
+
SELECT principal_id, acked_at, received_at, opened_at FROM web_notice_recipients
|
|
263
|
+
WHERE tag = ${tag} ORDER BY added_at, principal_id`;
|
|
264
|
+
return {
|
|
265
|
+
raisedAt: notice.raised_at,
|
|
266
|
+
to: rows.map((row) => row.principal_id),
|
|
267
|
+
acked: rows.flatMap((row) =>
|
|
268
|
+
row.acked_at
|
|
269
|
+
? [{ principalId: row.principal_id, at: row.acked_at }]
|
|
270
|
+
: [],
|
|
271
|
+
),
|
|
272
|
+
received: rows
|
|
273
|
+
.filter((row) => row.received_at)
|
|
274
|
+
.map((row) => row.principal_id),
|
|
275
|
+
opened: rows
|
|
276
|
+
.filter((row) => row.opened_at)
|
|
277
|
+
.map((row) => row.principal_id),
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Whether a push for this tag was delivered to the person: they have a device that holds its notification. */
|
|
282
|
+
async delivered(tag: string, principalId: string): Promise<boolean> {
|
|
283
|
+
const [found]: { n: number }[] = await this.#sql`
|
|
284
|
+
SELECT count(*)::int AS n FROM web_push_outbox o
|
|
285
|
+
JOIN web_push_deliveries d ON d.outbox_id = o.id
|
|
286
|
+
WHERE o.tag = ${tag} AND o.principal_id = ${principalId}
|
|
287
|
+
AND o.kind = 'notice' AND d.result = 'sent'`;
|
|
288
|
+
return (found?.n ?? 0) > 0;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Deletes the notices resolved long ago; returns how many. */
|
|
292
|
+
async sweep(): Promise<number> {
|
|
293
|
+
const rows: unknown[] = await this.#sql`
|
|
294
|
+
DELETE FROM web_notices
|
|
295
|
+
WHERE resolved_at < now() - make_interval(days => ${KEEP_RESOLVED_DAYS}) RETURNING tag`;
|
|
296
|
+
return rows.length;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { OpenTarget } from "../web-app.ts";
|
|
2
|
+
|
|
3
|
+
/** How the page's routes are written in an address. */
|
|
4
|
+
export interface RoutingOptions {
|
|
5
|
+
/** The path the console is served under, without a trailing slash. */
|
|
6
|
+
mount: string;
|
|
7
|
+
/** `path` puts the route in the path; `hash` after a `#`. */
|
|
8
|
+
routing: "path" | "hash";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const queryOf = (
|
|
12
|
+
query: Readonly<Record<string, string>> | undefined,
|
|
13
|
+
): string => {
|
|
14
|
+
const entries = Object.entries(query ?? {});
|
|
15
|
+
if (entries.length === 0) return "";
|
|
16
|
+
return `?${new URLSearchParams(entries).toString()}`;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The address a tap on a notice or push opens, under the origin: a room (scrolled to a message
|
|
21
|
+
* when one is named), a page with its query, or the app itself when the target is absent. The
|
|
22
|
+
* service worker, and a cold start of the app, open exactly this address.
|
|
23
|
+
*/
|
|
24
|
+
export function openUrl(
|
|
25
|
+
open: OpenTarget | undefined,
|
|
26
|
+
routing: RoutingOptions,
|
|
27
|
+
): string {
|
|
28
|
+
const base =
|
|
29
|
+
routing.routing === "path" ? `${routing.mount}/` : `${routing.mount}/#/`;
|
|
30
|
+
if (!open) return `${routing.mount}/`;
|
|
31
|
+
if ("room" in open)
|
|
32
|
+
return `${base}room/${encodeURIComponent(open.room)}${queryOf(
|
|
33
|
+
open.message === undefined
|
|
34
|
+
? undefined
|
|
35
|
+
: { message: String(open.message) },
|
|
36
|
+
)}`;
|
|
37
|
+
return `${base}p/${encodeURIComponent(open.page)}${queryOf(open.query)}`;
|
|
38
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { ConsoleHttpError as HttpError, json } from "../http.ts";
|
|
2
|
+
import type { RouteArea } from "../web-app.ts";
|
|
3
|
+
import type { OnboardingService } from "./service.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `GET api/onboarding` lists the steps that apply to the person; `POST api/onboarding/<step>/waive`
|
|
7
|
+
* waives a required step that offers a waiver. The principal is the session's.
|
|
8
|
+
*/
|
|
9
|
+
export function onboardingArea(onboarding: OnboardingService): RouteArea {
|
|
10
|
+
return {
|
|
11
|
+
area: "onboarding",
|
|
12
|
+
async handle(request, parts, { viewer }) {
|
|
13
|
+
try {
|
|
14
|
+
const [step, action] = parts;
|
|
15
|
+
if (step === undefined && request.method === "GET")
|
|
16
|
+
return json(await onboarding.view(viewer));
|
|
17
|
+
if (step !== undefined && action === "waive" && parts.length === 2) {
|
|
18
|
+
if (request.method !== "POST")
|
|
19
|
+
throw new HttpError(405, "Method not allowed.");
|
|
20
|
+
if (!(await onboarding.waive(viewer.principalId, step)))
|
|
21
|
+
throw new HttpError(400, "That step cannot be waived.");
|
|
22
|
+
return json(await onboarding.view(viewer));
|
|
23
|
+
}
|
|
24
|
+
throw new HttpError(404, "There is no such API.");
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if (error instanceof HttpError)
|
|
27
|
+
return json({ error: error.message }, error.status);
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|