pi-roundtable-web 0.24.2 → 0.26.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/CHANGELOG.md +48 -0
- package/README.md +195 -16
- package/dist/account-zk4efb87.css +102 -0
- package/dist/chunk-account-hw49gd0d.js +1 -0
- package/dist/chunk-action-menu-rm5cyqkj.js +1 -0
- package/dist/chunk-agent-b017vswv.js +1 -0
- package/dist/chunk-arranged-rooms-q5v3dm1r.js +1 -0
- package/dist/chunk-character-creator-5dxcvd16.js +1 -0
- package/dist/chunk-codeeditor-pyt09fwg.js +2 -0
- package/dist/chunk-confirm-521yqmta.js +1 -0
- package/dist/chunk-connectors-hrm06nx8.js +1 -0
- package/dist/chunk-conversations-e26rrf01.js +1 -0
- package/dist/chunk-drag-v41gx2mc.js +1 -0
- package/dist/chunk-entry-jxxb3ath.js +1 -0
- package/dist/chunk-folder-list-b97xk12b.js +1 -0
- package/dist/chunk-folds-b8q7eags.js +1 -0
- package/dist/chunk-gate-8k61stsc.js +1 -0
- package/dist/chunk-group-scene-dx6wt19m.js +1 -0
- package/dist/chunk-group-table-e4jr8r3a.js +1 -0
- package/dist/chunk-host-state-g7e7c0k5.js +1 -0
- package/dist/chunk-host-ui-x7k6n07v.js +939 -0
- package/dist/chunk-index-014rjv9k.js +2 -0
- package/dist/chunk-index-2d0kg096.js +2 -0
- package/dist/chunk-index-2qd81wbx.js +2 -0
- package/dist/chunk-index-2stynxw6.css +1 -0
- package/dist/chunk-index-459drsft.js +2 -0
- package/dist/chunk-index-4mfj596y.js +2 -0
- package/dist/chunk-index-66nskrdj.js +47 -0
- package/dist/chunk-index-awx6nnta.js +2 -0
- package/dist/chunk-index-bbsytycb.js +2 -0
- package/dist/chunk-index-c07p8k0x.js +4 -0
- package/dist/chunk-index-cq1e9xdz.js +2 -0
- package/dist/chunk-index-d1prgfbp.js +2 -0
- package/dist/chunk-index-dhpt8qr9.js +2 -0
- package/dist/chunk-index-e5sd30xy.js +2 -0
- package/dist/chunk-index-g351921r.js +1 -0
- package/dist/chunk-index-hby0ap91.js +2 -0
- package/dist/chunk-index-hssjbr1k.js +2 -0
- package/dist/chunk-index-k2qq1gdj.js +2 -0
- package/dist/chunk-index-ke9ynm0n.js +2 -0
- package/dist/chunk-index-km40bhsz.js +2 -0
- package/dist/chunk-index-kpscvg85.js +2 -0
- package/dist/chunk-index-m0d100mg.js +2 -0
- package/dist/chunk-index-mb72snk9.js +2 -0
- package/dist/chunk-index-mjqqver5.js +4 -0
- package/dist/chunk-index-nqfkwkfp.js +2 -0
- package/dist/chunk-index-nwqk447w.js +2 -0
- package/dist/chunk-index-pcfhpm8v.js +302 -0
- package/dist/chunk-index-q8h8xzbm.js +2 -0
- package/dist/chunk-index-r5vszgfs.js +2 -0
- package/dist/{chunk-index-mpm90318.js → chunk-index-rhtvcbsq.js} +2 -2
- package/dist/chunk-index-rjv7vrwd.js +13 -0
- package/dist/chunk-index-v23m38p6.js +50 -0
- package/dist/chunk-index-va0g7d88.js +1 -0
- package/dist/{chunk-index-6t68gbex.js → chunk-index-wnjp6wv0.js} +2 -2
- package/dist/chunk-index-xgkd15n3.js +2 -0
- package/dist/chunk-index-y8rnq8zf.js +14 -0
- package/dist/chunk-index-z3k18atc.js +2 -0
- package/dist/chunk-lang-css-6bepqpnx.js +1 -0
- package/dist/{chunk-lang-dockerfile-ey0nvhhm.js → chunk-lang-dockerfile-hrav55t7.js} +1 -1
- package/dist/{chunk-lang-go-403qsw9f.js → chunk-lang-go-x9ahv6e5.js} +3 -3
- package/dist/chunk-lang-html-8jc72zt4.js +1 -0
- package/dist/chunk-lang-javascript-2p2qtbnf.js +1 -0
- package/dist/chunk-lang-json-hsemyd0r.js +1 -0
- package/dist/chunk-lang-markdown-zkwb4c9n.js +6 -0
- package/dist/{chunk-lang-python-9jk5a49y.js → chunk-lang-python-tewx7006.js} +2 -2
- package/dist/chunk-lang-rust-0ydx84s1.js +1 -0
- package/dist/{chunk-lang-shell-cgq6q0kj.js → chunk-lang-shell-gq1tmdz4.js} +1 -1
- package/dist/chunk-lang-sql-xt1qyycg.js +2 -0
- package/dist/{chunk-lang-toml-xh45nm68.js → chunk-lang-toml-0hamc5fc.js} +1 -1
- package/dist/chunk-lang-typescript-82xf7z2e.js +1 -0
- package/dist/{chunk-lang-xml-1mh879j9.js → chunk-lang-xml-wf6h5pt2.js} +1 -1
- package/dist/{chunk-lang-yaml-jac98srt.js → chunk-lang-yaml-7vk521h1.js} +1 -1
- package/dist/chunk-layout-actions-zh7bezk7.js +1 -0
- package/dist/chunk-menu-items-1n9qargb.js +2 -0
- package/dist/chunk-notes-t15bh04m.js +1 -0
- package/dist/chunk-notice-bell-w3y0h0sr.js +1 -0
- package/dist/chunk-notices-fa3g7qze.js +1 -0
- package/dist/chunk-onboarding-34v3nr17.js +1 -0
- package/dist/chunk-overview-km5vq375.js +1 -0
- package/dist/chunk-palette-a8txdgd0.js +1 -0
- package/dist/chunk-placement-items-wgaxsbvs.js +1 -0
- package/dist/chunk-profile-ypxdbsk1.js +1 -0
- package/dist/chunk-prompt-history-wm9dsy6s.js +1 -0
- package/dist/chunk-prompts-7mhhq7zs.js +1 -0
- package/dist/chunk-room-confirms-t11a1gsw.js +1 -0
- package/dist/chunk-room-menu-2tm2xtfx.js +1 -0
- package/dist/chunk-rooms-menu-button-wxnsxjwc.js +1 -0
- package/dist/chunk-schedules-2ft6xxyw.js +1 -0
- package/dist/chunk-seat-menu-d3npdtx1.js +1 -0
- package/dist/chunk-sheet-hosts-vngvp7wv.js +1 -0
- package/dist/chunk-skill-editor-65n2hsq4.js +8 -0
- package/dist/chunk-skills-shkdwes8.js +1 -0
- package/dist/chunk-switcher-list-g6hhmr1t.js +1 -0
- package/dist/chunk-table-1b6bsq2y.js +1 -0
- package/dist/chunk-table-seats-vy1qhdks.js +1 -0
- package/dist/chunk-table2d-n26b7gd0.js +1 -0
- package/dist/chunk-table3d-8aysyf56.js +4220 -0
- package/dist/chunk-transcript-355faceq.js +1 -0
- package/dist/chunk-where-j5qdt8a4.js +1 -0
- package/dist/chunk-workspace-vrbs6zx1.js +1 -0
- package/dist/chunk-zh-tw-zeg600bk.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 +515 -6
- package/src/app/api-http.ts +66 -0
- package/src/app/app.ts +214 -33
- package/src/app/card-log.ts +6 -0
- package/src/app/desk.ts +47 -22
- package/src/app/files.ts +73 -7
- package/src/app/layout-routes.ts +239 -0
- package/src/app/layout.ts +133 -0
- package/src/app/presence.ts +2 -2
- package/src/app/prompt-routes.ts +167 -0
- package/src/app/protocol.ts +63 -1
- package/src/app/room-log.ts +148 -14
- package/src/app/rooms.ts +23 -0
- package/src/app/routes.ts +278 -156
- package/src/app/skill-doc-routes.ts +122 -0
- package/src/app/socket.ts +309 -77
- package/src/app/sockets.ts +108 -20
- package/src/app/surface.ts +72 -36
- package/src/app/team-routes.ts +457 -0
- package/src/app/threads.ts +14 -0
- package/src/app/uploads.ts +33 -7
- package/src/auth/authenticator.ts +71 -8
- package/src/auth/next.ts +52 -0
- package/src/auth/oidc-pages.ts +60 -0
- package/src/auth/oidc.ts +611 -0
- package/src/auth/sessions.ts +193 -0
- package/src/authz.ts +172 -0
- package/src/console-api.ts +122 -25
- package/src/console-server.ts +70 -6
- package/src/gate.ts +90 -0
- package/src/host/access.ts +161 -0
- package/src/host/cards.ts +99 -0
- package/src/host/context.ts +141 -0
- package/src/host/forms.ts +237 -0
- package/src/host/frames.ts +342 -0
- package/src/host/host-app.ts +176 -0
- package/src/host/host-context.ts +49 -0
- package/src/host/host-routes.ts +363 -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 +414 -0
- package/src/host/stored-json.ts +13 -0
- package/src/host/views.ts +103 -0
- package/src/i18n/zh-tw.ts +309 -10
- package/src/layout-order.ts +210 -0
- 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 +498 -34
- 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/mutes.ts +17 -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/visitors.ts +94 -6
- package/src/web-app.ts +674 -0
- package/src/web-plugin.ts +293 -26
- package/dist/chunk-action-menu-7v5fce2v.js +0 -1
- package/dist/chunk-agent-2rw4ydw2.js +0 -1
- package/dist/chunk-agent-sheet-2q24dkkf.js +0 -3
- package/dist/chunk-character-creator-8q6ympxj.js +0 -1
- package/dist/chunk-codeeditor-fgmq298t.js +0 -2
- package/dist/chunk-connectors-mr53r7fh.js +0 -1
- package/dist/chunk-conversations-4vy2z6w9.js +0 -1
- package/dist/chunk-entry-jg1eem82.js +0 -1
- package/dist/chunk-group-scene-na95b1te.js +0 -1
- package/dist/chunk-index-11rjg8a8.js +0 -1
- package/dist/chunk-index-1y50d6sj.js +0 -2
- package/dist/chunk-index-5y2r0eaj.js +0 -47
- package/dist/chunk-index-7fphxxzd.js +0 -14
- package/dist/chunk-index-80f1zkp6.css +0 -1
- package/dist/chunk-index-87h45frg.js +0 -2
- package/dist/chunk-index-bnwmssyn.js +0 -2
- package/dist/chunk-index-ckgbszwt.js +0 -51
- package/dist/chunk-index-fmjjekhd.js +0 -2
- package/dist/chunk-index-t1m65yn6.js +0 -13
- package/dist/chunk-index-t711t17m.js +0 -2
- package/dist/chunk-index-txv7qvyy.js +0 -2
- package/dist/chunk-index-wr3dg24d.js +0 -2
- package/dist/chunk-index-yx683d5n.js +0 -2
- package/dist/chunk-lang-css-fkdg3x8x.js +0 -1
- package/dist/chunk-lang-html-v8h9g977.js +0 -1
- package/dist/chunk-lang-javascript-ykb2vhaa.js +0 -1
- package/dist/chunk-lang-json-2rze3rpy.js +0 -1
- package/dist/chunk-lang-markdown-6w2htnfc.js +0 -6
- package/dist/chunk-lang-rust-24fggcqx.js +0 -1
- package/dist/chunk-lang-sql-gjywb9ke.js +0 -2
- package/dist/chunk-lang-typescript-qh6rebyb.js +0 -1
- package/dist/chunk-menu-items-zjpyj51b.js +0 -2
- package/dist/chunk-notes-sjba7kw3.js +0 -1
- package/dist/chunk-overview-26r77kb6.js +0 -1
- package/dist/chunk-room-confirms-851184ep.js +0 -1
- package/dist/chunk-room-menu-8y9c026y.js +0 -1
- package/dist/chunk-skills-56s4hdbn.js +0 -1
- package/dist/chunk-table-qykpvp32.js +0 -1
- package/dist/chunk-table2d-k60ressj.js +0 -1
- package/dist/chunk-table3d-bv2ajk39.js +0 -4220
- package/dist/chunk-transcript-vekz1mh6.js +0 -1
- package/dist/chunk-workspace-hevefd0d.js +0 -1
- package/dist/chunk-zh-tw-5r2wjre0.js +0 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { IdentityService, Logger, PersonProfiles } from "pi-roundtable";
|
|
2
|
+
import type { OnboardingStepView, OnboardingView } from "../api-types.ts";
|
|
3
|
+
import type { Gate } from "../gate.ts";
|
|
4
|
+
import type {
|
|
5
|
+
OnboardingStep,
|
|
6
|
+
StepStatus,
|
|
7
|
+
Viewer,
|
|
8
|
+
WebApp,
|
|
9
|
+
WebAppRegistry,
|
|
10
|
+
} from "../web-app.ts";
|
|
11
|
+
import type { OnboardingWaivers } from "./waivers.ts";
|
|
12
|
+
|
|
13
|
+
type OnboardingPort = WebApp["onboarding"];
|
|
14
|
+
|
|
15
|
+
/** The id of the notifications step. */
|
|
16
|
+
export const PUSH_STEP = "push";
|
|
17
|
+
/** The id of the step that links a provider's account. */
|
|
18
|
+
export const linkStep = (provider: string): string => `link:${provider}`;
|
|
19
|
+
|
|
20
|
+
export interface OnboardingServiceOptions {
|
|
21
|
+
registry: Pick<WebAppRegistry, "steps" | "step" | "prover">;
|
|
22
|
+
waivers: Pick<OnboardingWaivers, "waived" | "waive">;
|
|
23
|
+
gate: Pick<Gate, "changed">;
|
|
24
|
+
identity: Pick<IdentityService, "speakerFor" | "principal">;
|
|
25
|
+
profiles?: Pick<PersonProfiles, "of">;
|
|
26
|
+
push: WebApp["push"];
|
|
27
|
+
logger: Logger;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* What a person owes before the app opens, and how they settle it. The steps are the host's
|
|
32
|
+
* (`registerStep`) and the app's own (`link:<provider>`, `push`); a step is undone until its
|
|
33
|
+
* `done` says it is, and a required one may be waived only when it offers a waiver.
|
|
34
|
+
*/
|
|
35
|
+
export class OnboardingService implements OnboardingPort {
|
|
36
|
+
readonly #options: OnboardingServiceOptions;
|
|
37
|
+
|
|
38
|
+
constructor(options: OnboardingServiceOptions) {
|
|
39
|
+
this.#options = options;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The person as a step's callbacks see them, built from their id alone. */
|
|
43
|
+
async viewerFor(principalId: string): Promise<Viewer> {
|
|
44
|
+
const { identity, profiles } = this.#options;
|
|
45
|
+
let tier: Viewer["tier"] = "member";
|
|
46
|
+
try {
|
|
47
|
+
tier = (await identity.speakerFor(principalId)).tier;
|
|
48
|
+
} catch {
|
|
49
|
+
// No tier: they are a member as far as a step is concerned.
|
|
50
|
+
}
|
|
51
|
+
const name =
|
|
52
|
+
(await profiles?.of(principalId))?.name ??
|
|
53
|
+
(await identity.principal(principalId))?.displayName ??
|
|
54
|
+
principalId;
|
|
55
|
+
return { principalId, speakerId: principalId, tier, roles: [], name };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async status(principalId: string): Promise<StepStatus[]> {
|
|
59
|
+
return this.statuses(await this.viewerFor(principalId));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Every step that applies to the viewer, with whether it is done or waived. */
|
|
63
|
+
async statuses(viewer: Viewer): Promise<StepStatus[]> {
|
|
64
|
+
const found: StepStatus[] = [];
|
|
65
|
+
for (const step of this.#options.registry.steps()) {
|
|
66
|
+
try {
|
|
67
|
+
if (step.applies && !(await step.applies(viewer))) continue;
|
|
68
|
+
const done = await step.done(viewer);
|
|
69
|
+
const waived =
|
|
70
|
+
!done && step.required
|
|
71
|
+
? await this.#options.waivers.waived(viewer.principalId, step.id)
|
|
72
|
+
: false;
|
|
73
|
+
found.push(statusOf(step, done, waived));
|
|
74
|
+
} catch (error) {
|
|
75
|
+
// A step that cannot be read is undone.
|
|
76
|
+
this.#options.logger.warn(
|
|
77
|
+
{ step: step.id, err: error },
|
|
78
|
+
"an onboarding step was not read",
|
|
79
|
+
);
|
|
80
|
+
found.push(statusOf(step, false, false));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return found;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
changed(principalId: string): void {
|
|
87
|
+
this.#options.gate.changed(principalId);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The steps with the push state, as the onboarding page shows them. */
|
|
91
|
+
async view(viewer: Viewer): Promise<OnboardingView> {
|
|
92
|
+
const [steps, push] = await Promise.all([
|
|
93
|
+
this.statuses(viewer),
|
|
94
|
+
this.#options.push.status(viewer.principalId),
|
|
95
|
+
]);
|
|
96
|
+
return {
|
|
97
|
+
steps: steps.map(
|
|
98
|
+
(step): OnboardingStepView => ({
|
|
99
|
+
id: step.id,
|
|
100
|
+
label: step.label,
|
|
101
|
+
...(step.help ? { help: step.help } : {}),
|
|
102
|
+
...(step.builtin ? { builtin: step.builtin } : {}),
|
|
103
|
+
required: step.required,
|
|
104
|
+
open: step.open,
|
|
105
|
+
...(step.waiver ? { waiver: step.waiver } : {}),
|
|
106
|
+
done: step.done,
|
|
107
|
+
waived: step.waived,
|
|
108
|
+
}),
|
|
109
|
+
),
|
|
110
|
+
push: {
|
|
111
|
+
devices: push.devices,
|
|
112
|
+
...(push.lastReceiptAt
|
|
113
|
+
? { lastReceiptAt: push.lastReceiptAt.toISOString() }
|
|
114
|
+
: {}),
|
|
115
|
+
waived: push.waived,
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Waives a step for the person. Only a required step that offers a waiver can be; any other
|
|
122
|
+
* answers false. Recorded with who and when, then the gate is asked again.
|
|
123
|
+
*/
|
|
124
|
+
async waive(principalId: string, stepId: string): Promise<boolean> {
|
|
125
|
+
const step = this.#options.registry.step(stepId);
|
|
126
|
+
if (!step?.required || !step.waiver) return false;
|
|
127
|
+
await this.#options.waivers.waive(principalId, stepId);
|
|
128
|
+
this.changed(principalId);
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function statusOf(
|
|
134
|
+
step: OnboardingStep,
|
|
135
|
+
done: boolean,
|
|
136
|
+
waived: boolean,
|
|
137
|
+
): StepStatus {
|
|
138
|
+
return {
|
|
139
|
+
id: step.id,
|
|
140
|
+
label: step.label,
|
|
141
|
+
...(step.help ? { help: step.help } : {}),
|
|
142
|
+
...(step.builtin ? { builtin: step.builtin } : {}),
|
|
143
|
+
required: step.required,
|
|
144
|
+
done,
|
|
145
|
+
waived,
|
|
146
|
+
open: step.open,
|
|
147
|
+
...(step.required && step.waiver ? { waiver: step.waiver } : {}),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { IdentityService, Tier } from "pi-roundtable";
|
|
2
|
+
import type { PushOutbox } from "../push/outbox.ts";
|
|
3
|
+
import type { LinkProver, OnboardingStep, Viewer } from "../web-app.ts";
|
|
4
|
+
import { linkStep, PUSH_STEP } from "./service.ts";
|
|
5
|
+
|
|
6
|
+
export interface BuiltInStepsOptions {
|
|
7
|
+
/** `onboarding.links`: provider → required or nudge. */
|
|
8
|
+
links: Readonly<Record<string, "required" | "nudge">>;
|
|
9
|
+
/** `onboarding.push` and `onboarding.pushWaiver`. */
|
|
10
|
+
push: { mode: "required" | "nudge" | "off"; waiver: boolean };
|
|
11
|
+
/** The tiers whose people may receive push; the push step applies to these only. */
|
|
12
|
+
pushTiers: readonly Tier[];
|
|
13
|
+
prover(
|
|
14
|
+
provider: string,
|
|
15
|
+
): Pick<LinkProver, "provider" | "label" | "status"> | undefined;
|
|
16
|
+
identity: Pick<IdentityService, "identities">;
|
|
17
|
+
outbox: Pick<PushOutbox, "lastReceiptAt">;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The steps `onboarding` asks for. `link:<provider>` is done when the prover says the account is
|
|
22
|
+
* linked, or when the person has an identity of that provider from any source (a configuration, CLI
|
|
23
|
+
* or plugin link covers an owner, who never proves what the host already knows). `push` is done
|
|
24
|
+
* when a device of the person reported back within 30 days: a test push, or any notice.
|
|
25
|
+
*/
|
|
26
|
+
export function builtInSteps(options: BuiltInStepsOptions): OnboardingStep[] {
|
|
27
|
+
const steps: OnboardingStep[] = [];
|
|
28
|
+
for (const [provider, mode] of Object.entries(options.links)) {
|
|
29
|
+
steps.push({
|
|
30
|
+
id: linkStep(provider),
|
|
31
|
+
// Read when asked: a host registers its provers after the app has made its steps.
|
|
32
|
+
get label() {
|
|
33
|
+
return options.prover(provider)?.label ?? provider;
|
|
34
|
+
},
|
|
35
|
+
required: mode === "required",
|
|
36
|
+
builtin: "link",
|
|
37
|
+
open: { page: "profile" },
|
|
38
|
+
done: (viewer) => linked(options, provider, viewer),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
if (options.push.mode !== "off") {
|
|
42
|
+
const required = options.push.mode === "required";
|
|
43
|
+
steps.push({
|
|
44
|
+
id: PUSH_STEP,
|
|
45
|
+
label: "Turn on notifications",
|
|
46
|
+
required,
|
|
47
|
+
builtin: "push",
|
|
48
|
+
open: { page: "onboarding" },
|
|
49
|
+
applies: (viewer) => options.pushTiers.includes(viewer.tier),
|
|
50
|
+
done: async (viewer) =>
|
|
51
|
+
(await options.outbox.lastReceiptAt(viewer.principalId)) !== undefined,
|
|
52
|
+
...(required && options.push.waiver
|
|
53
|
+
? {
|
|
54
|
+
waiver: {
|
|
55
|
+
label: "Skip for now",
|
|
56
|
+
confirm:
|
|
57
|
+
"Skip turning on notifications? You will not hear when something needs you.",
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
: {}),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return steps;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function linked(
|
|
67
|
+
options: BuiltInStepsOptions,
|
|
68
|
+
provider: string,
|
|
69
|
+
viewer: Viewer,
|
|
70
|
+
): Promise<boolean> {
|
|
71
|
+
const prover = options.prover(provider);
|
|
72
|
+
if (prover && (await prover.status(viewer.principalId)).linked) return true;
|
|
73
|
+
const identities = await options.identity.identities(viewer.principalId);
|
|
74
|
+
return identities.some((link) => link.provider === provider);
|
|
75
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
|
+
import type { Migration } from "pi-roundtable";
|
|
3
|
+
import type { WaiverRead } from "../web-app.ts";
|
|
4
|
+
|
|
5
|
+
/** The required steps people waived, with who and when, in `web_onboarding`. */
|
|
6
|
+
export class OnboardingWaivers implements WaiverRead {
|
|
7
|
+
readonly #sql: SQL;
|
|
8
|
+
|
|
9
|
+
constructor(sql: SQL) {
|
|
10
|
+
this.#sql = sql;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
static readonly migration: Migration = {
|
|
14
|
+
name: "web-onboarding",
|
|
15
|
+
up: async (sql) => {
|
|
16
|
+
await sql`
|
|
17
|
+
CREATE TABLE IF NOT EXISTS web_onboarding (
|
|
18
|
+
principal_id text NOT NULL,
|
|
19
|
+
step text NOT NULL,
|
|
20
|
+
state text NOT NULL CHECK (state IN ('waived')),
|
|
21
|
+
at timestamptz NOT NULL DEFAULT now(),
|
|
22
|
+
PRIMARY KEY (principal_id, step)
|
|
23
|
+
)`;
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
async waived(principalId: string, step: string): Promise<boolean> {
|
|
28
|
+
const [found]: { n: number }[] = await this.#sql`
|
|
29
|
+
SELECT count(*)::int AS n FROM web_onboarding
|
|
30
|
+
WHERE principal_id = ${principalId} AND step = ${step} AND state = 'waived'`;
|
|
31
|
+
return (found?.n ?? 0) > 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Records that the person waived the step; waiving twice keeps the first time. */
|
|
35
|
+
async waive(principalId: string, step: string): Promise<void> {
|
|
36
|
+
await this.#sql`
|
|
37
|
+
INSERT INTO web_onboarding (principal_id, step, state)
|
|
38
|
+
VALUES (${principalId}, ${step}, 'waived') ON CONFLICT DO NOTHING`;
|
|
39
|
+
}
|
|
40
|
+
}
|