@protobase/server 0.59.2 → 0.60.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/dist/better-auth/store/migrations-table.js +1 -1
- package/dist/calls/admission.d.ts +4 -0
- package/dist/calls/admission.js +12 -0
- package/dist/calls/call-hub.js +170 -0
- package/dist/calls/call-member.d.ts +19 -6
- package/dist/calls/call-reads.d.ts +29 -0
- package/dist/calls/call-reads.js +79 -0
- package/dist/calls/call-signals-sql.d.ts +12 -0
- package/dist/calls/call-signals-sql.js +19 -0
- package/dist/calls/call-store.d.ts +30 -0
- package/dist/calls/call-store.js +112 -0
- package/dist/calls/call-sweep.js +26 -0
- package/dist/calls/call-watch.js +39 -0
- package/dist/calls/calls-access.d.ts +20 -5
- package/dist/calls/calls-access.js +38 -17
- package/dist/calls/calls-options.d.ts +9 -1
- package/dist/calls/calls-options.js +10 -2
- package/dist/calls/calls-runtime.d.ts +33 -0
- package/dist/calls/calls-runtime.js +94 -0
- package/dist/calls/calls-service-type.d.ts +11 -0
- package/dist/calls/calls-service.js +27 -0
- package/dist/calls/calls-sql.d.ts +7 -0
- package/dist/calls/calls-sql.js +66 -0
- package/dist/calls/calls-store.d.ts +23 -0
- package/dist/calls/calls-store.js +20 -0
- package/dist/calls/client-message.js +1 -0
- package/dist/calls/close-codes.js +17 -0
- package/dist/calls/livekit/livekit-session.js +1 -0
- package/dist/calls/member-relay.js +69 -17
- package/dist/calls/peer/peer-backend.js +68 -120
- package/dist/calls/record-calls.js +78 -0
- package/dist/create-admin.d.ts +4 -1
- package/dist/create-admin.js +21 -4
- package/dist/deps.d.ts +5 -0
- package/dist/functions/function-socket.d.ts +1 -0
- package/dist/functions/function-socket.js +14 -2
- package/dist/functions/routes.js +3 -4
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/internal.d.ts +2 -1
- package/dist/internal.js +2 -1
- package/dist/meta.d.ts +2 -0
- package/dist/meta.js +1 -0
- package/dist/notifications/caller.js +2 -2
- package/dist/notifications/deliver.js +14 -2
- package/dist/notifications/push-job.js +5 -3
- package/dist/notifications/schema.js +7 -4
- package/dist/package.js +1 -1
- package/dist/routes/record.js +6 -0
- package/dist/status/do-not-disturb.d.ts +9 -0
- package/dist/status/do-not-disturb.js +15 -0
- package/dist/status/options.d.ts +15 -0
- package/dist/status/options.js +25 -0
- package/dist/status/routes.js +38 -0
- package/dist/status/schema-sql.js +19 -0
- package/dist/status/schema.d.ts +12 -0
- package/dist/status/schema.js +33 -0
- package/dist/status/status-input.js +33 -0
- package/dist/status/status-store.js +39 -0
- package/dist/webhooks/attempt.js +6 -1
- package/package.json +4 -4
package/dist/meta.js
CHANGED
|
@@ -97,6 +97,7 @@ const createMeta = (deps, appName, appIcon) => {
|
|
|
97
97
|
...deps.bell && { bell: true },
|
|
98
98
|
...deps.webPushKey && { webPushKey: deps.webPushKey }
|
|
99
99
|
} },
|
|
100
|
+
...deps.status && { status: true },
|
|
100
101
|
...deps.locale && { locale: deps.locale },
|
|
101
102
|
...deps.files && deps.files.trustedHosts.length > 0 && { files: { trustedHosts: deps.files.trustedHosts } },
|
|
102
103
|
...plugins.length > 0 && { plugins },
|
|
@@ -5,8 +5,8 @@ import { forbidden } from "../problem.js";
|
|
|
5
5
|
* Refused: visitors, outside apps acting for the user (an MCP client), and every session whose authenticator does not
|
|
6
6
|
* mark it as the user's own sign-in (`Session.signIn`), staff signed in as the user included.
|
|
7
7
|
*/
|
|
8
|
-
const ownSignInUser = (session) => {
|
|
9
|
-
const refuse = () => forbidden("own-sign-in-only",
|
|
8
|
+
const ownSignInUser = (session, what = "reach their devices and notifications") => {
|
|
9
|
+
const refuse = () => forbidden("own-sign-in-only", `Only someone signed in as themselves can ${what}`);
|
|
10
10
|
if (session.anonymous || session.client || session.signIn?.staff !== false) throw refuse();
|
|
11
11
|
const userId = String(session.user.id);
|
|
12
12
|
if (userId === "" || session.user.id === void 0 || session.user.id === null) throw refuse();
|
|
@@ -16,7 +16,8 @@ const listed = (counts) => [...counts].map(([method, count]) => `${count} in ${m
|
|
|
16
16
|
* One try of a send, resuming from `given`: for a user send, each user's history entry when the send is for the inbox
|
|
17
17
|
* (once, as `record` marks it), then the user's devices as the store lists them now; for a device send, the devices
|
|
18
18
|
* with those ids now. A device the store gives that is not the user's, or not one asked for, gets nothing. With
|
|
19
|
-
* `liveSessions`, a device whose session no longer stands is removed instead of pushed to
|
|
19
|
+
* `liveSessions`, a device whose session no longer stands is removed instead of pushed to; with `quiet`, a device of a
|
|
20
|
+
* user on do not disturb gets nothing, now or on a later try, though the history keeps the entry. Each kind's
|
|
20
21
|
* devices go to its sender in one batch; a kind without a sender is skipped. An answer of `gone` removes the device,
|
|
21
22
|
* `retry` leaves it to the next try once and gives up on it the second time, and any other is done. A sender that
|
|
22
23
|
* throws leaves its batch to the next try. The try then throws, counting what is left, so the job retries it, or
|
|
@@ -70,8 +71,19 @@ const deliver = async ({ to, message }, given, steps) => {
|
|
|
70
71
|
}
|
|
71
72
|
return pending.filter(stands);
|
|
72
73
|
};
|
|
74
|
+
const undisturbed = async (devices) => {
|
|
75
|
+
const { quiet } = steps;
|
|
76
|
+
const pending = devices.filter((device) => !done.has(device.id));
|
|
77
|
+
if (!quiet || pending.length === 0) return pending;
|
|
78
|
+
const held = await quiet([...new Set(pending.map((device) => device.userId))]);
|
|
79
|
+
const kept = pending.filter((device) => !held.has(device.userId));
|
|
80
|
+
if (kept.length === pending.length) return kept;
|
|
81
|
+
for (const device of pending.filter((device) => held.has(device.userId))) markDone(device.id);
|
|
82
|
+
await steps.save(progress);
|
|
83
|
+
return kept;
|
|
84
|
+
};
|
|
73
85
|
const push = async (given) => {
|
|
74
|
-
const devices = await standing(given);
|
|
86
|
+
const devices = await undisturbed(await standing(given));
|
|
75
87
|
for (const device of devices) if (!steps.senders.some((sender) => sender.kinds.includes(device.kind))) markDone(device.id);
|
|
76
88
|
for (const sender of steps.senders) {
|
|
77
89
|
const batch = devices.filter((device) => sender.kinds.includes(device.kind) && !done.has(device.id));
|
|
@@ -26,11 +26,12 @@ const recordOnce = (db, options, job) => async (userId, entry, progress) => {
|
|
|
26
26
|
};
|
|
27
27
|
/**
|
|
28
28
|
* The job that delivers what `sendNotification` sends, through `senders` (guarded, see `notificationSenders`) and the
|
|
29
|
-
* stores `options` configure; with `liveSessions`, as with Protobase's auth, only to devices whose session stands
|
|
29
|
+
* stores `options` configure; with `liveSessions`, as with Protobase's auth, only to devices whose session stands, and
|
|
30
|
+
* with `quiet`, as with `status`, to no device of a user on do not disturb. A
|
|
30
31
|
* try resumes where the last one stopped (see `deliver`); once delivered, the job keeps no message. A few tries only: a
|
|
31
32
|
* device that keeps asking for a retry is given up on the second time.
|
|
32
33
|
*/
|
|
33
|
-
const pushJob = (options, senders, liveSessions) => defineJob({
|
|
34
|
+
const pushJob = (options, senders, liveSessions, quiet) => defineJob({
|
|
34
35
|
name: pushJobName,
|
|
35
36
|
payload: pushPayload,
|
|
36
37
|
maxAttempts: 5,
|
|
@@ -44,7 +45,8 @@ const pushJob = (options, senders, liveSessions) => defineJob({
|
|
|
44
45
|
save: (progress) => saveJobProgress(db, job, progress),
|
|
45
46
|
hold: () => holdJob(db, job),
|
|
46
47
|
complete: () => replaceJobData(db, job, { complete: true }),
|
|
47
|
-
...liveSessions && { liveSessions }
|
|
48
|
+
...liveSessions && { liveSessions },
|
|
49
|
+
...quiet && { quiet: (userIds) => quiet(db, userIds) }
|
|
48
50
|
});
|
|
49
51
|
}
|
|
50
52
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { migrationCommand } from "../changes/capture-schema.js";
|
|
2
2
|
import { historyNames, notificationsNames } from "./options.js";
|
|
3
|
-
import { storeSchema } from "../better-auth/store/store-names.js";
|
|
4
3
|
import { deviceSessionColumnSql, devicesTableSql, qualified } from "./schema-sql.js";
|
|
4
|
+
import { storeSchema } from "../better-auth/store/store-names.js";
|
|
5
5
|
import { historyTableSql } from "./history-sql.js";
|
|
6
6
|
import { sql } from "kysely";
|
|
7
7
|
//#region src/notifications/schema.ts
|
|
@@ -11,9 +11,12 @@ const authUsersTable = (auth) => ({
|
|
|
11
11
|
table: auth.options.user?.modelName ?? "user"
|
|
12
12
|
});
|
|
13
13
|
const regclass = async (db, name) => (await sql`select to_regclass(${name})::oid as oid`.execute(db)).rows[0].oid;
|
|
14
|
-
const userIdType = async (db, users
|
|
14
|
+
const userIdType = async (db, users, of = {
|
|
15
|
+
feature: "notifications",
|
|
16
|
+
rows: "devices"
|
|
17
|
+
}) => {
|
|
15
18
|
const oid = await regclass(db, qualified(users));
|
|
16
|
-
if (oid === null) throw new Error(
|
|
19
|
+
if (oid === null) throw new Error(`${of.feature}: auth's users table ${qualified(users)} is not in the app's database, so ${of.rows} cannot point at it`);
|
|
17
20
|
const { rows } = await sql`select format_type(atttypid, atttypmod) as type from pg_attribute where attrelid = ${oid} and attname = 'id'`.execute(db);
|
|
18
21
|
return rows[0].type;
|
|
19
22
|
};
|
|
@@ -89,4 +92,4 @@ const checkNotificationsSchema = async (db, options = {}, auth) => {
|
|
|
89
92
|
await checkHistoryTable(db, options, users);
|
|
90
93
|
};
|
|
91
94
|
//#endregion
|
|
92
|
-
export { authUsersTable, checkNotificationsSchema, notificationsMigration };
|
|
95
|
+
export { authUsersTable, checkNotificationsSchema, notificationsMigration, regclass, userIdType };
|
package/dist/package.js
CHANGED
package/dist/routes/record.js
CHANGED
|
@@ -13,6 +13,7 @@ import { revealField } from "../reveal-service.js";
|
|
|
13
13
|
import { readHistory } from "../history/read-history.js";
|
|
14
14
|
import { mentionable } from "../comments/mentionable.js";
|
|
15
15
|
import { generateField } from "../ai/generate-route.js";
|
|
16
|
+
import { readRecordCalls, watchRecordCalls } from "../calls/record-calls.js";
|
|
16
17
|
import { Hono } from "hono";
|
|
17
18
|
//#region src/routes/record.ts
|
|
18
19
|
const recordRoutes = (deps) => {
|
|
@@ -21,6 +22,11 @@ const recordRoutes = (deps) => {
|
|
|
21
22
|
const { entry } = resolveTarget(deps, c.req.param("resource"));
|
|
22
23
|
if (c.req.param("key").endsWith(":history")) return c.json(await readHistory(deps, entry, c.get("session"), keyFromPath(c, entry, ":history"), c.req.query()));
|
|
23
24
|
if (c.req.param("key").endsWith(":comments")) return c.json(await readComments(deps, entry, c.get("session"), keyFromPath(c, entry, ":comments"), c.req.query()));
|
|
25
|
+
if (c.req.param("key").endsWith(":calls")) {
|
|
26
|
+
const key = keyFromPath(c, entry, ":calls");
|
|
27
|
+
if (c.req.query("watch") === "true") return watchRecordCalls(c, deps, entry, key);
|
|
28
|
+
return c.json(await readRecordCalls(deps, entry, c.get("session"), key));
|
|
29
|
+
}
|
|
24
30
|
if (c.req.param("key").endsWith(":mentionable")) return c.json(await mentionable(deps, entry, c.get("session"), keyFromPath(c, entry, ":mentionable"), c.req.query()));
|
|
25
31
|
const found = await readRecord(deps, entry, c.get("session"), keyFromPath(c, entry), c.req.query("show_deleted") === "true");
|
|
26
32
|
const columns = selectedFields({
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { StatusNames } from "./options.js";
|
|
2
|
+
import { Db } from "@protobase/query";
|
|
3
|
+
//#region src/status/do-not-disturb.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Which of these people are on do not disturb now: their status says so and its clear time, if any, is still to come.
|
|
6
|
+
* The one check of everything that would alert someone, such as a push, so each holds it alike.
|
|
7
|
+
*/
|
|
8
|
+
export declare const onDoNotDisturb: (db: Db, names: StatusNames, userIds: readonly string[]) => Promise<Set<string>>;
|
|
9
|
+
//#endregion
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { qualified } from "../notifications/schema-sql.js";
|
|
2
|
+
import { sql } from "kysely";
|
|
3
|
+
//#region src/status/do-not-disturb.ts
|
|
4
|
+
/**
|
|
5
|
+
* Which of these people are on do not disturb now: their status says so and its clear time, if any, is still to come.
|
|
6
|
+
* The one check of everything that would alert someone, such as a push, so each holds it alike.
|
|
7
|
+
*/
|
|
8
|
+
const onDoNotDisturb = async (db, names, userIds) => {
|
|
9
|
+
if (userIds.length === 0) return /* @__PURE__ */ new Set();
|
|
10
|
+
const { rows } = await sql`select user_id from ${sql.raw(qualified(names))}
|
|
11
|
+
where user_id in (${sql.join([...new Set(userIds)])}) and availability = 'do_not_disturb' and (clears_at is null or clears_at > now())`.execute(db);
|
|
12
|
+
return new Set(rows.map((row) => String(row.user_id)));
|
|
13
|
+
};
|
|
14
|
+
//#endregion
|
|
15
|
+
export { onDoNotDisturb };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
//#region src/status/options.d.ts
|
|
2
|
+
/** People's own status, turned on with `status: {}` in `protobase.config.ts`; it needs auth with organizations. */
|
|
3
|
+
export type StatusOptions = {
|
|
4
|
+
/** The schema of the statuses table; default `status`. */
|
|
5
|
+
schema?: string;
|
|
6
|
+
};
|
|
7
|
+
/** The statuses table's names, checked: a bad schema name is an error naming the option. */
|
|
8
|
+
export declare const statusNames: (options?: StatusOptions) => {
|
|
9
|
+
schema: string;
|
|
10
|
+
table: string;
|
|
11
|
+
};
|
|
12
|
+
export type StatusNames = ReturnType<typeof statusNames>;
|
|
13
|
+
/** The options, checked as `status` takes them. */
|
|
14
|
+
export declare const checkStatusOptions: (options: StatusOptions) => StatusOptions;
|
|
15
|
+
//#endregion
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
//#region src/status/options.ts
|
|
2
|
+
const namePattern = /^[a-z_][a-z0-9_]*$/;
|
|
3
|
+
const maxNameBytes = 63;
|
|
4
|
+
/** The statuses table's names, checked: a bad schema name is an error naming the option. */
|
|
5
|
+
const statusNames = (options = {}) => {
|
|
6
|
+
const schema = options.schema ?? "status";
|
|
7
|
+
if (typeof schema !== "string" || !namePattern.test(schema) || Buffer.byteLength(schema) > maxNameBytes) throw new Error(`status schema is not a valid name: use lowercase letters, digits and "_", at most ${maxNameBytes} characters`);
|
|
8
|
+
return {
|
|
9
|
+
schema,
|
|
10
|
+
table: "statuses"
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
/** The options, checked as `status` takes them. */
|
|
14
|
+
const checkStatusOptions = (options) => {
|
|
15
|
+
const unknown = Object.keys(options).find((key) => key !== "schema");
|
|
16
|
+
if (unknown !== void 0) throw new Error(`status takes no option "${unknown}": only schema`);
|
|
17
|
+
statusNames(options);
|
|
18
|
+
return options;
|
|
19
|
+
};
|
|
20
|
+
/** Status shows people to the members of their organizations, so an app without organizations cannot turn it on. */
|
|
21
|
+
const checkStatusAuth = (auth) => {
|
|
22
|
+
if (!auth?.organizations) throw new Error("status: {} shows people to the members of their organizations: turn organizations on in the auth config, or remove status");
|
|
23
|
+
};
|
|
24
|
+
//#endregion
|
|
25
|
+
export { checkStatusAuth, checkStatusOptions, statusNames };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { ownSignInUser } from "../notifications/caller.js";
|
|
2
|
+
import { jsonBody, jsonOnly, limitedBody } from "../notifications/history-request.js";
|
|
3
|
+
import { statusInput } from "./status-input.js";
|
|
4
|
+
import { clearStatus, ownStatus, setStatus, visibleStatuses } from "./status-store.js";
|
|
5
|
+
import { Hono } from "hono";
|
|
6
|
+
//#region src/status/routes.ts
|
|
7
|
+
const noStore = { "Cache-Control": "no-store" };
|
|
8
|
+
const viewerOf = (session) => session.anonymous || session.client ? void 0 : String(session.user.id);
|
|
9
|
+
/**
|
|
10
|
+
* The status endpoints, mounted at `statusPath` behind the API's authenticator: `GET /` the caller's own status,
|
|
11
|
+
* `PUT /` sets it, `DELETE /` clears it, and `GET /people` the statuses of everyone who shares an organization with
|
|
12
|
+
* the caller. No request names whose status it sets: the person comes from the session alone, and only someone
|
|
13
|
+
* signed in as themselves sets theirs, never staff signed in as them (see `ownSignInUser`).
|
|
14
|
+
*/
|
|
15
|
+
const statusRoutes = (db, tables) => {
|
|
16
|
+
const app = new Hono();
|
|
17
|
+
app.get("/", async (c) => {
|
|
18
|
+
const viewer = viewerOf(c.get("session"));
|
|
19
|
+
const status = viewer === void 0 ? void 0 : await ownStatus(db, tables, viewer);
|
|
20
|
+
return c.json({ status: status ?? null }, 200, noStore);
|
|
21
|
+
});
|
|
22
|
+
app.put("/", jsonOnly, limitedBody, async (c) => {
|
|
23
|
+
const userId = ownSignInUser(c.get("session"), "set their status");
|
|
24
|
+
await setStatus(db, tables, userId, statusInput(await jsonBody(c)));
|
|
25
|
+
return c.json({ status: await ownStatus(db, tables, userId) ?? null }, 200, noStore);
|
|
26
|
+
});
|
|
27
|
+
app.delete("/", async (c) => {
|
|
28
|
+
await clearStatus(db, tables, ownSignInUser(c.get("session"), "set their status"));
|
|
29
|
+
return c.body(null, 204);
|
|
30
|
+
});
|
|
31
|
+
app.get("/people", async (c) => {
|
|
32
|
+
const viewer = viewerOf(c.get("session"));
|
|
33
|
+
return c.json({ statuses: viewer === void 0 ? [] : await visibleStatuses(db, tables, viewer) }, 200, noStore);
|
|
34
|
+
});
|
|
35
|
+
return app;
|
|
36
|
+
};
|
|
37
|
+
//#endregion
|
|
38
|
+
export { statusRoutes };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { quoteIdent } from "../changes/capture-sql.js";
|
|
2
|
+
import { qualified } from "../notifications/schema-sql.js";
|
|
3
|
+
//#region src/status/schema-sql.ts
|
|
4
|
+
/**
|
|
5
|
+
* The statuses table: one row per person who set a status, which goes with the user. A custom text or a clear time
|
|
6
|
+
* not set is NULL, and nothing has a default. Clearing a status deletes its row.
|
|
7
|
+
*/
|
|
8
|
+
const statusesTableSql = ({ schema, table }, user) => [`create schema if not exists ${quoteIdent(schema)}`, `create table ${qualified({
|
|
9
|
+
schema,
|
|
10
|
+
table
|
|
11
|
+
})} (
|
|
12
|
+
user_id ${user.type} primary key references ${user.references} on delete cascade,
|
|
13
|
+
availability text not null check (availability in ('automatic', 'busy', 'do_not_disturb')),
|
|
14
|
+
text text check (char_length(text) between 1 and 100),
|
|
15
|
+
clears_at timestamptz,
|
|
16
|
+
updated_at timestamptz not null
|
|
17
|
+
)`];
|
|
18
|
+
//#endregion
|
|
19
|
+
export { statusesTableSql };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { StatusOptions } from "./options.js";
|
|
2
|
+
import { AdminAuth } from "../better-auth/create-auth.js";
|
|
3
|
+
import { Db } from "@protobase/query";
|
|
4
|
+
//#region src/status/schema.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* The SQL that adds the statuses table `status` keeps people's statuses in, its user column pointing at auth's users
|
|
7
|
+
* table; `undefined` when it exists. An app without organizations is refused (see `checkStatusAuth`).
|
|
8
|
+
*/
|
|
9
|
+
export declare const statusMigration: (db: Db, options: StatusOptions, auth: AdminAuth | undefined) => Promise<string | undefined>;
|
|
10
|
+
/** Refuses to start while the statuses table is missing, or the app has no organizations. */
|
|
11
|
+
export declare const checkStatusSchema: (db: Db, options: StatusOptions, auth: AdminAuth | undefined) => Promise<void>;
|
|
12
|
+
//#endregion
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { migrationCommand } from "../changes/capture-schema.js";
|
|
2
|
+
import { qualified } from "../notifications/schema-sql.js";
|
|
3
|
+
import { checkStatusAuth, statusNames } from "./options.js";
|
|
4
|
+
import { authUsersTable, regclass, userIdType } from "../notifications/schema.js";
|
|
5
|
+
import { statusesTableSql } from "./schema-sql.js";
|
|
6
|
+
//#region src/status/schema.ts
|
|
7
|
+
/**
|
|
8
|
+
* The SQL that adds the statuses table `status` keeps people's statuses in, its user column pointing at auth's users
|
|
9
|
+
* table; `undefined` when it exists. An app without organizations is refused (see `checkStatusAuth`).
|
|
10
|
+
*/
|
|
11
|
+
const statusMigration = async (db, options, auth) => {
|
|
12
|
+
checkStatusAuth(auth);
|
|
13
|
+
const names = statusNames(options);
|
|
14
|
+
if (await regclass(db, qualified(names)) !== null) return;
|
|
15
|
+
const users = authUsersTable(auth);
|
|
16
|
+
const type = await userIdType(db, users, {
|
|
17
|
+
feature: "status",
|
|
18
|
+
rows: "statuses"
|
|
19
|
+
});
|
|
20
|
+
return `${statusesTableSql(names, {
|
|
21
|
+
type,
|
|
22
|
+
references: `${qualified(users)} (id)`
|
|
23
|
+
}).join(";\n\n")};`;
|
|
24
|
+
};
|
|
25
|
+
/** Refuses to start while the statuses table is missing, or the app has no organizations. */
|
|
26
|
+
const checkStatusSchema = async (db, options, auth) => {
|
|
27
|
+
checkStatusAuth(auth);
|
|
28
|
+
const table = qualified(statusNames(options));
|
|
29
|
+
if (await regclass(db, table) !== null) return;
|
|
30
|
+
throw new Error(`The statuses table ${table} of status is missing. Write it as a migration with \`${migrationCommand}\`, apply it with the project's migrations, then start again.`);
|
|
31
|
+
};
|
|
32
|
+
//#endregion
|
|
33
|
+
export { checkStatusSchema, statusMigration };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { badRequest } from "../problem.js";
|
|
2
|
+
import { availabilities, statusTextProblem } from "@protobase/schema/internal";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
//#region src/status/status-input.ts
|
|
5
|
+
const body = z.strictObject({
|
|
6
|
+
availability: z.enum(availabilities),
|
|
7
|
+
text: z.string().nullable(),
|
|
8
|
+
clearsAt: z.iso.datetime({ offset: true }).nullable()
|
|
9
|
+
});
|
|
10
|
+
/**
|
|
11
|
+
* The status a request sets, checked: an availability, a one-line text of at most `maxStatusText` characters, kept
|
|
12
|
+
* trimmed, an empty one as none, and a clear time to come or `null` for never. Anything else, a user named in it
|
|
13
|
+
* too, is a 400 that says what is wrong.
|
|
14
|
+
*/
|
|
15
|
+
const statusInput = (given, now = /* @__PURE__ */ new Date()) => {
|
|
16
|
+
const parsed = body.safeParse(given);
|
|
17
|
+
if (!parsed.success) {
|
|
18
|
+
const issue = parsed.error.issues[0];
|
|
19
|
+
throw badRequest("invalid-status", `Send { availability, text, clearsAt }: ${issue.path.join(".") || "the body"} ${issue.message.toLowerCase()}`);
|
|
20
|
+
}
|
|
21
|
+
const { availability, clearsAt } = parsed.data;
|
|
22
|
+
const text = parsed.data.text?.trim() || null;
|
|
23
|
+
const problem = text === null ? void 0 : statusTextProblem(text);
|
|
24
|
+
if (problem) throw badRequest("invalid-status-text", problem);
|
|
25
|
+
if (clearsAt !== null && Date.parse(clearsAt) <= now.getTime()) throw badRequest("invalid-clear-time", "A status clears at a time still to come, or never");
|
|
26
|
+
return {
|
|
27
|
+
availability,
|
|
28
|
+
text,
|
|
29
|
+
clearsAt: clearsAt === null ? null : new Date(clearsAt).toISOString()
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
//#endregion
|
|
33
|
+
export { statusInput };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { qualified } from "../notifications/schema-sql.js";
|
|
2
|
+
import { sql } from "kysely";
|
|
3
|
+
//#region src/status/status-store.ts
|
|
4
|
+
const statusOf = (row) => ({
|
|
5
|
+
userId: String(row.user_id),
|
|
6
|
+
availability: row.availability,
|
|
7
|
+
text: row.text,
|
|
8
|
+
clearsAt: row.clears_at?.toISOString() ?? null
|
|
9
|
+
});
|
|
10
|
+
const holds = sql`(clears_at is null or clears_at > now())`;
|
|
11
|
+
/** The person's own status, as it holds now; `undefined` when they have none. */
|
|
12
|
+
const ownStatus = async (db, tables, userId) => {
|
|
13
|
+
const statuses = sql.raw(qualified(tables.statuses));
|
|
14
|
+
const { rows } = await sql`select user_id, availability, text, clears_at from ${statuses} where user_id = ${userId} and ${holds}`.execute(db);
|
|
15
|
+
return rows[0] && statusOf(rows[0]);
|
|
16
|
+
};
|
|
17
|
+
/** The statuses that hold now of everyone who shares an organization with `viewerId`, their own included when they have one. */
|
|
18
|
+
const visibleStatuses = async (db, tables, viewerId) => {
|
|
19
|
+
const statuses = sql.raw(qualified(tables.statuses));
|
|
20
|
+
const members = sql.raw(qualified(tables.members));
|
|
21
|
+
const { rows } = await sql`select s.user_id, s.availability, s.text, s.clears_at from ${statuses} s
|
|
22
|
+
where ${holds} and exists (select from ${members} own join ${members} other on other.organization_id = own.organization_id
|
|
23
|
+
where own.user_id = ${viewerId} and other.user_id = s.user_id)
|
|
24
|
+
order by s.user_id`.execute(db);
|
|
25
|
+
return rows.map(statusOf);
|
|
26
|
+
};
|
|
27
|
+
/** Sets the person's status, replacing the one they had; one that says nothing, automatic without a text, clears it. */
|
|
28
|
+
const setStatus = async (db, tables, userId, input) => {
|
|
29
|
+
if (input.availability === "automatic" && input.text === null) return clearStatus(db, tables, userId);
|
|
30
|
+
const statuses = sql.raw(qualified(tables.statuses));
|
|
31
|
+
await sql`insert into ${statuses} (user_id, availability, text, clears_at, updated_at) values (${userId}, ${input.availability}, ${input.text}, ${input.clearsAt}, now())
|
|
32
|
+
on conflict (user_id) do update set availability = excluded.availability, text = excluded.text, clears_at = excluded.clears_at, updated_at = excluded.updated_at`.execute(db);
|
|
33
|
+
};
|
|
34
|
+
/** Clears the person's status, whether or not they had one. */
|
|
35
|
+
const clearStatus = async (db, tables, userId) => {
|
|
36
|
+
await sql`delete from ${sql.raw(qualified(tables.statuses))} where user_id = ${userId}`.execute(db);
|
|
37
|
+
};
|
|
38
|
+
//#endregion
|
|
39
|
+
export { clearStatus, ownStatus, setStatus, visibleStatuses };
|
package/dist/webhooks/attempt.js
CHANGED
|
@@ -47,6 +47,7 @@ const callFunction = async (name, subscription, event, options, context, signal)
|
|
|
47
47
|
session,
|
|
48
48
|
records: context.records(session)
|
|
49
49
|
};
|
|
50
|
+
const noCalls = () => Promise.reject(/* @__PURE__ */ new Error(`The function "${name}" reached calls, which a webhook delivery has none of`));
|
|
50
51
|
const acceptWebSocket = () => {
|
|
51
52
|
throw new Error(`The function "${name}" accepted a WebSocket, but a webhook delivery is no upgrade`);
|
|
52
53
|
};
|
|
@@ -56,7 +57,11 @@ const callFunction = async (name, subscription, event, options, context, signal)
|
|
|
56
57
|
channels: context.channels,
|
|
57
58
|
caller: async () => caller,
|
|
58
59
|
acceptWebSocket,
|
|
59
|
-
calls: {
|
|
60
|
+
calls: {
|
|
61
|
+
join: async () => acceptWebSocket(),
|
|
62
|
+
remove: noCalls,
|
|
63
|
+
of: noCalls
|
|
64
|
+
}
|
|
60
65
|
};
|
|
61
66
|
return invokeFunction(name, fn, await request("http://protobase.internal/", event, options, signal), fn.options.public ? functionContext : {
|
|
62
67
|
...functionContext,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.60.0",
|
|
4
4
|
"description": "The Protobase REST API as a Hono app, with Better Auth sign-in.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -42,9 +42,9 @@
|
|
|
42
42
|
"@better-auth/sso": "^1.7.12",
|
|
43
43
|
"@livekit/protocol": "1.53.1",
|
|
44
44
|
"@modelcontextprotocol/server": "^2.3.1",
|
|
45
|
-
"@protobase/layout": "0.
|
|
46
|
-
"@protobase/query": "0.
|
|
47
|
-
"@protobase/schema": "0.
|
|
45
|
+
"@protobase/layout": "0.60.0",
|
|
46
|
+
"@protobase/query": "0.60.0",
|
|
47
|
+
"@protobase/schema": "0.60.0",
|
|
48
48
|
"@react-email/render": "^2.1.0",
|
|
49
49
|
"@scalar/hono-api-reference": "^0.12.10",
|
|
50
50
|
"@sentry/core": "^11.7.0",
|