@protobase/server 0.21.7 → 0.21.8

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.
Files changed (84) hide show
  1. package/dist/aggregate-groups.js +1 -1
  2. package/dist/aggregate-service.js +3 -3
  3. package/dist/ai/ai-fields.js +9 -0
  4. package/dist/ai/ai-model.js +9 -0
  5. package/dist/ai/check-ai.d.ts +13 -0
  6. package/dist/ai/check-ai.js +11 -0
  7. package/dist/ai/fill-hook.js +47 -0
  8. package/dist/ai/fill-job.js +67 -0
  9. package/dist/ai/generate-route.js +57 -0
  10. package/dist/ai/generate-value.js +28 -0
  11. package/dist/ai/values.js +15 -0
  12. package/dist/assistant/assistant-settings.d.ts +2 -2
  13. package/dist/assistant/assistant-settings.js +7 -21
  14. package/dist/assistant/built-in-assistant.js +2 -1
  15. package/dist/assistant/model-settings.js +31 -0
  16. package/dist/assistant/turn.d.ts +1 -1
  17. package/dist/assistant/turn.js +2 -2
  18. package/dist/batch/reorder.js +2 -2
  19. package/dist/batch/run-batch.js +1 -1
  20. package/dist/better-auth/create-auth.d.ts +12 -4
  21. package/dist/better-auth/create-auth.js +0 -1
  22. package/dist/better-auth/organizations/invitation-endpoints.js +1 -1
  23. package/dist/better-auth/organizations/member-endpoints.js +1 -1
  24. package/dist/better-auth/passkey-plugin.js +1 -1
  25. package/dist/better-auth/policy-plugin.js +1 -1
  26. package/dist/better-auth/roles/roles-plugin.js +1 -1
  27. package/dist/better-auth/sign-up/sign-up-plugin.js +1 -1
  28. package/dist/better-auth/staff-sign-in-endpoints.js +1 -1
  29. package/dist/changes/capture-schema.d.ts +1 -1
  30. package/dist/changes/outbox.d.ts +1 -1
  31. package/dist/check-views.js +26 -4
  32. package/dist/comments/app-origin.js +11 -0
  33. package/dist/comments/comment-access.js +54 -0
  34. package/dist/comments/comment-input.js +26 -0
  35. package/dist/comments/comment-item.d.ts +16 -0
  36. package/dist/comments/comment-item.js +18 -0
  37. package/dist/comments/comment-users.d.ts +21 -0
  38. package/dist/comments/comment-users.js +35 -0
  39. package/dist/comments/comments-sql.d.ts +8 -0
  40. package/dist/comments/comments-sql.js +37 -0
  41. package/dist/comments/mention-email.js +37 -0
  42. package/dist/comments/mention-job.d.ts +3 -0
  43. package/dist/comments/mention-job.js +43 -0
  44. package/dist/comments/mentionable.js +38 -0
  45. package/dist/comments/post-comment.js +57 -0
  46. package/dist/comments/read-comments.js +45 -0
  47. package/dist/comments/record-comments.js +53 -0
  48. package/dist/comments/record-label.js +19 -0
  49. package/dist/create-admin.d.ts +7 -4
  50. package/dist/create-admin.js +43 -8
  51. package/dist/deps.d.ts +4 -2
  52. package/dist/error-handler.js +1 -1
  53. package/dist/files/attach.js +1 -1
  54. package/dist/files/cleanup-runner.d.ts +1 -1
  55. package/dist/files/runtime.d.ts +2 -2
  56. package/dist/files/upload.js +2 -2
  57. package/dist/history/read-history.js +1 -1
  58. package/dist/index.d.ts +7 -3
  59. package/dist/index.js +11 -9
  60. package/dist/jobs/runtime.js +1 -1
  61. package/dist/list-photos.js +3 -3
  62. package/dist/list-service.js +2 -2
  63. package/dist/map-service.js +1 -1
  64. package/dist/meta.js +2 -2
  65. package/dist/openapi/resource-paths.js +151 -0
  66. package/dist/project-config.d.ts +1 -1
  67. package/dist/read-service.js +2 -2
  68. package/dist/related-labels.js +2 -2
  69. package/dist/request-access.d.ts +2 -2
  70. package/dist/routes/record.js +17 -0
  71. package/dist/row-permissions.js +3 -3
  72. package/dist/scan-guard.d.ts +2 -2
  73. package/dist/sql-parts.js +1 -1
  74. package/dist/types.d.ts +3 -1
  75. package/dist/view-access.js +33 -5
  76. package/dist/view-related.js +15 -2
  77. package/dist/webhooks/subscription.d.ts +1 -1
  78. package/dist/webhooks/table/schema.js +1 -1
  79. package/dist/webhooks/table/webhook-table.js +1 -1
  80. package/dist/webhooks/webhooks.js +1 -1
  81. package/dist/write-pipeline.js +2 -2
  82. package/dist/write-shared.js +3 -2
  83. package/dist/write-update.js +1 -1
  84. package/package.json +4 -4
@@ -6,9 +6,14 @@ import { chartErrors } from "./view-chart.js";
6
6
  import { mapDisplayErrors, mapErrors } from "./view-map.js";
7
7
  import { relatedColumnErrors } from "./view-related-column.js";
8
8
  import { editableErrors } from "./view-editable.js";
9
- import { actionInputErrors, addressErrors, addressPlaceholders, boardErrors, chartFields, displayFields, fieldRefsIn, hrefField, hrefPlaceholders, linkActionError } from "@protobase/schema/internal";
9
+ import { actionInputErrors, addressErrors, addressPlaceholders, boardErrors, calendarErrors, chartFields, displayFields, fieldRefsIn, hrefField, hrefPlaceholders, linkActionError, promptError, promptFields } from "@protobase/schema/internal";
10
10
  //#region src/check-views.ts
11
11
  const linkFields = (run) => run?.kind !== "link" ? [] : "to" in run ? addressPlaceholders(run.to) : hrefPlaceholders(run.href);
12
+ const actionFields = (run) => {
13
+ if (run?.kind === "update") return Object.keys(run.values);
14
+ if (run?.kind === "ai") return [run.writes, ...promptFields(run.prompt ?? "")];
15
+ return linkFields(run);
16
+ };
12
17
  const sensitiveErrors = (view, model) => {
13
18
  const sensitive = (name) => Boolean(model.fields[name]?.sensitive);
14
19
  return [
@@ -26,7 +31,7 @@ const sensitiveErrors = (view, model) => {
26
31
  ["a map", view.layout.flatMap((item) => item.kind === "map" ? [item.latitude, item.longitude] : [])],
27
32
  ["a gallery", view.layout.flatMap((item) => item.kind === "gallery" && item.image !== void 0 ? [item.image] : [])],
28
33
  ["the recent records", view.nav?.recent ? [view.nav.recent.status] : []],
29
- ["an action", view.actions.flatMap((action) => action.run?.kind === "update" ? Object.keys(action.run.values) : linkFields(action.run))]
34
+ ["an action", view.actions.flatMap((action) => actionFields(action.run))]
30
35
  ].flatMap(([place, names]) => [...new Set(names.filter(sensitive))].map((name) => `sensitive field "${name}" cannot be in ${place}`));
31
36
  };
32
37
  const valueLabelErrors = (view, model) => Object.entries(view.fields).flatMap(([name, hints]) => {
@@ -59,10 +64,22 @@ const linkErrors = (view, model, models, pages) => view.actions.flatMap(({ name,
59
64
  source: model
60
65
  }).map((error) => `action "${name}": ${error}`) : [];
61
66
  });
67
+ const aiActionErrors = (view, model) => view.actions.flatMap(({ name, run }) => {
68
+ if (run?.kind !== "ai") return [];
69
+ const prompt = promptError(run.prompt ?? "");
70
+ const written = Object.hasOwn(model.fields, run.writes) ? model.fields[run.writes] : void 0;
71
+ return [
72
+ ...prompt ? [prompt] : [],
73
+ ...promptFields(run.prompt ?? "").filter((field) => !Object.hasOwn(model.fields, field)).map((field) => `its prompt names field "${field}", which ${model.name} does not have`),
74
+ ...!written ? [`it writes field "${run.writes}", which ${model.name} does not have`] : [],
75
+ ...written && written.type !== "text" && written.type !== "enum" ? [`it writes "${run.writes}", which is ${written.type}, not text or an enum`] : [],
76
+ ...written?.readOnly ? [`it writes "${run.writes}", which is readOnly()`] : []
77
+ ].map((error) => `action "${name}": ${error}`);
78
+ });
62
79
  const inputErrors = (view, models) => view.actions.flatMap(({ name, run, inputs }) => {
63
80
  if (!inputs) return [];
64
81
  return [
65
- ...run ? [`inputs need a handler, and a${run.kind === "update" ? "n" : ""} ${run.kind} action has none`] : [],
82
+ ...run ? [`inputs need a handler, and a${run.kind === "update" || run.kind === "ai" ? "n" : ""} ${run.kind} action has none`] : [],
66
83
  ...actionInputErrors(inputs),
67
84
  ...inputs.flatMap((input) => input.resource !== void 0 && !models[input.resource] ? [`input "${input.name}" picks from resource "${input.resource}", which the app does not have`] : [])
68
85
  ].map((error) => `action "${name}": ${error}`);
@@ -113,7 +130,7 @@ const listOfErrors = (model, name, models) => {
113
130
  * has a related section or related list column, a cards display's or gallery's photos or a cards display's favorite button, a `listOf`, a "Save search" button or a
114
131
  * `searchesOf` that do not fit the resources, cards
115
132
  * displays of one list that name different photos, a chart per value that cannot group or add up its fields, a map that cannot show the
116
- * record's position or a map display that cannot find the records in its view, a board whose columns are no filterable enum, a link to an address the app does not have, action inputs that do not fit, or a create page that is not one of `pages`.
133
+ * record's position or a map display that cannot find the records in its view, a board whose columns are no filterable enum, a calendar whose dates are no filterable date or timestamp fields, a link to an address the app does not have, an AI action that cannot write its field, action inputs that do not fit, or a create page that is not one of `pages`.
117
134
  */
118
135
  const checkViews = (registry, views, pages = [], fieldTypes = {}) => {
119
136
  const models = Object.fromEntries(registry.entries.map((entry) => [entry.name, entry.model]));
@@ -132,8 +149,10 @@ const checkViews = (registry, views, pages = [], fieldTypes = {}) => {
132
149
  const searches = [...view.saveSearch ? saveSearchErrors(view.saveSearch, view.resource, models, views).map((error) => `saveSearch: ${error}`) : [], ...view.searchesOf ? searchesOfErrors(view.searchesOf, view.resource, models).map((error) => `searchesOf: ${error}`) : []];
133
150
  const maps = [...view.layout.flatMap((item) => item.kind === "map" ? mapErrors(item, model).map((error) => `map "${item.title}": ${error}`) : []), ...(view.list?.displays ?? []).flatMap((display) => display.kind === "map" ? mapDisplayErrors(display, model).map((error) => `display "${display.name}": ${error}`) : [])];
134
151
  const boards = (view.list?.displays ?? []).flatMap((display) => display.kind === "board" ? boardErrors(display, model).map((error) => `display "${display.name}": ${error}`) : []);
152
+ const calendars = (view.list?.displays ?? []).flatMap((display) => display.kind === "calendar" ? calendarErrors(display, model).map((error) => `display "${display.name}": ${error}`) : []);
135
153
  const chart = view.chart ? chartErrors(view.chart, model).map((error) => `chart: ${error}`) : [];
136
154
  const history = model.history ? [] : view.layout.flatMap((item) => item.kind === "history" ? [`history "${item.title}" needs .history() on resource "${view.resource}"`] : []);
155
+ const comments = model.comments ? [] : view.layout.flatMap((item) => item.kind === "comments" ? [`comments "${item.title}" needs .comments() on resource "${view.resource}"`] : []);
137
156
  return [
138
157
  ...sensitiveErrors(view, model),
139
158
  ...unknownFieldErrors(view, model),
@@ -149,9 +168,12 @@ const checkViews = (registry, views, pages = [], fieldTypes = {}) => {
149
168
  ...searches,
150
169
  ...maps,
151
170
  ...history,
171
+ ...comments,
152
172
  ...boards,
173
+ ...calendars,
153
174
  ...chart,
154
175
  ...linkErrors(view, model, models, pageNames),
176
+ ...aiActionErrors(view, model),
155
177
  ...inputErrors(view, models),
156
178
  ...createErrors(view, pageNames)
157
179
  ].map((problem) => `View "${view.resource}": ${problem}`);
@@ -0,0 +1,11 @@
1
+ //#region src/comments/app-origin.ts
2
+ /**
3
+ * The origin the app answers a request to `requestURL` on: the request's own when it is one of `origins`, else the
4
+ * first of them, the app's base URL. Any origin when there are none to check against.
5
+ */
6
+ const appOrigin = (origins) => (requestURL) => {
7
+ const origin = new URL(requestURL).origin;
8
+ return origins.length === 0 || origins.includes(origin) ? origin : origins[0];
9
+ };
10
+ //#endregion
11
+ export { appOrigin };
@@ -0,0 +1,54 @@
1
+ import { forbidden, notFound } from "../problem.js";
2
+ import { requestAccess, requireOperation } from "../request-access.js";
3
+ import { fetchReadable } from "../read-service.js";
4
+ //#region src/comments/comment-access.ts
5
+ /** The caller's access to a resource with comments, who must be allowed to read it; a resource without is a 404. */
6
+ const commentAccess = async (deps, entry, session) => {
7
+ if (!entry.model.comments) throw notFound(`${entry.name} takes no comments`);
8
+ const access = await requestAccess(deps, entry, session);
9
+ requireOperation(access, "read");
10
+ return access;
11
+ };
12
+ /** Writing a comment, or looking for someone to mention, takes a signed-in user: a visitor is no author. */
13
+ const requireSignedIn = (session) => {
14
+ if (session.anonymous || session.user.id === "") throw forbidden("comment-needs-user", "Sign in to comment");
15
+ };
16
+ /** The record with this key when `access` may read it (tenant, row filter and record-level rule), with `fields` besides its key. */
17
+ const readableRecord = (trx, access, key, deleted, fields = []) => {
18
+ const names = [.../* @__PURE__ */ new Set([...access.full.primaryKey, ...fields])];
19
+ return fetchReadable(trx, access, key, deleted, { fields: names.map((name) => access.full.fields[name]) });
20
+ };
21
+ /**
22
+ * The sessions these users act with now, in the caller's tenant, built as a subscription's is (see `subscriberSession`);
23
+ * none for one who holds no role there, or when the project has no auth to ask.
24
+ */
25
+ const userSessions = (deps, caller, ids) => Promise.all(ids.map(async (id) => {
26
+ const users = deps.comments?.users;
27
+ const roles = users ? await users.roles({
28
+ id,
29
+ ...caller.tenant !== void 0 && { tenant: caller.tenant }
30
+ }) : [];
31
+ if (roles.length === 0) return { id };
32
+ return {
33
+ id,
34
+ session: {
35
+ user: {
36
+ id,
37
+ roles
38
+ },
39
+ ...caller.tenant !== void 0 && {
40
+ tenant: caller.tenant,
41
+ organization: { id: caller.tenant }
42
+ }
43
+ }
44
+ };
45
+ }));
46
+ /** Whether the user of `session` may read the record: the same operation, tenant, row filter and record-level rule as a get. */
47
+ const mayRead = async (trx, deps, entry, session, key) => {
48
+ if (!session) return false;
49
+ const access = await requestAccess(deps, entry, session);
50
+ if (!access.resolved.operations.read) return false;
51
+ return await readableRecord(trx, access, key, "hide") !== void 0;
52
+ };
53
+ //#endregion
54
+ export { commentAccess, mayRead, readableRecord, requireSignedIn, userSessions };
@@ -0,0 +1,26 @@
1
+ import { badRequest } from "../problem.js";
2
+ import { z } from "zod";
3
+ //#region src/comments/comment-input.ts
4
+ /** The longest comment, in characters, after trimming. */
5
+ const maxCommentLength = 1e4;
6
+ const input = z.object({
7
+ body: z.string(),
8
+ mentions: z.array(z.string().min(1)).max(20).optional()
9
+ });
10
+ /**
11
+ * A comment as `author` posts it: the body trimmed, not empty and at most `maxCommentLength` long, and the user ids it
12
+ * mentions once each, without the author's own.
13
+ */
14
+ const parseCommentInput = (body, author) => {
15
+ const parsed = input.safeParse(body);
16
+ if (!parsed.success) throw badRequest("invalid-comment", `The comment is invalid: ${parsed.error.issues[0].path.join(".") || "body"}: ${parsed.error.issues[0].message}`);
17
+ const text = parsed.data.body.trim();
18
+ if (text === "") throw badRequest("invalid-comment", "The comment is empty");
19
+ if (text.length > 1e4) throw badRequest("invalid-comment", `The comment is longer than ${maxCommentLength} characters`);
20
+ return {
21
+ body: text,
22
+ mentions: [...new Set(parsed.data.mentions ?? [])].filter((id) => id !== author)
23
+ };
24
+ };
25
+ //#endregion
26
+ export { maxCommentLength, parseCommentInput };
@@ -0,0 +1,16 @@
1
+ //#region src/comments/comment-item.d.ts
2
+ /** One comment as the API gives it: its author and the people it mentions, with their names when the project's auth knows them. */
3
+ export type CommentItem = {
4
+ id: string;
5
+ created_at: string;
6
+ body: string;
7
+ author: {
8
+ id: string;
9
+ name?: string;
10
+ };
11
+ mentions: {
12
+ id: string;
13
+ name?: string;
14
+ }[];
15
+ };
16
+ //#endregion
@@ -0,0 +1,18 @@
1
+ //#region src/comments/comment-item.ts
2
+ const person = (id, names) => ({
3
+ id,
4
+ ...names[id] !== void 0 && { name: names[id] }
5
+ });
6
+ /** The user ids a page of comments names, once each. */
7
+ const namedUsers = (rows) => [...new Set(rows.flatMap((row) => [row.author_id, ...row.mentions]))];
8
+ const commentItem = (row, names) => ({
9
+ id: row.id,
10
+ created_at: row.created_at,
11
+ body: row.body,
12
+ author: person(row.author_id, names),
13
+ mentions: row.mentions.map((id) => person(id, names))
14
+ });
15
+ /** The columns of a `CommentRow`, `created_at` as ISO text in UTC. */
16
+ const commentColumns = `"id"::text as id, to_char("created_at" at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') as created_at, "body", "author_id", "mentions"`;
17
+ //#endregion
18
+ export { commentColumns, commentItem, namedUsers };
@@ -0,0 +1,21 @@
1
+ import "../better-auth/create-auth.js";
2
+ import { TenantValue } from "../types.js";
3
+ //#region src/comments/comment-users.d.ts
4
+ /** The users comments name, mention and mail: from the project's auth. */
5
+ export type CommentUsers = {
6
+ /** The names of these user ids; one who is gone or has none is left out. */
7
+ names(ids: readonly string[]): Promise<Record<string, string>>;
8
+ /** Up to `limit` users whose name contains `query`, whatever its case, by name; no banned ones. */
9
+ search(query: string, limit: number): Promise<{
10
+ id: string;
11
+ name: string;
12
+ }[]>;
13
+ /** The role the user acts with now in `tenant` (see `betterAuthOwnerRoles`); none when banned or gone. */
14
+ roles(user: {
15
+ id: string;
16
+ tenant?: TenantValue;
17
+ }): Promise<string[]>;
18
+ /** The address mail to the user goes to; `undefined` when they are banned or gone. */
19
+ email(id: string): Promise<string | undefined>;
20
+ };
21
+ //#endregion
@@ -0,0 +1,35 @@
1
+ import { betterAuthActorNames } from "../history/actor-names.js";
2
+ import { betterAuthOwnerRoles } from "../webhooks/table/owner-roles.js";
3
+ //#region src/comments/comment-users.ts
4
+ /** The users of a Better Auth instance, read through its adapters only (see `better-auth/users.ts`). */
5
+ const betterAuthCommentUsers = (auth) => ({
6
+ names: betterAuthActorNames(auth),
7
+ roles: betterAuthOwnerRoles(auth),
8
+ search: async (query, limit) => {
9
+ const { adapter } = await auth.$context;
10
+ return (await adapter.findMany({
11
+ model: "user",
12
+ ...query !== "" && { where: [{
13
+ field: "name",
14
+ operator: "contains",
15
+ value: query,
16
+ mode: "insensitive"
17
+ }] },
18
+ limit,
19
+ sortBy: {
20
+ field: "name",
21
+ direction: "asc"
22
+ }
23
+ })).flatMap((user) => user.banned || !user.name ? [] : [{
24
+ id: user.id,
25
+ name: user.name
26
+ }]);
27
+ },
28
+ email: async (id) => {
29
+ const { internalAdapter } = await auth.$context;
30
+ const user = await internalAdapter.findUserById(id);
31
+ return user && !user.banned && user.email ? user.email : void 0;
32
+ }
33
+ });
34
+ //#endregion
35
+ export { betterAuthCommentUsers };
@@ -0,0 +1,8 @@
1
+ import { ResourceModel } from "@protobase/schema";
2
+ import { Db } from "@protobase/query";
3
+ //#region src/comments/comments-sql.d.ts
4
+ /** The SQL that adds the `comments` schema and its table, `undefined` when it is there. The table is never dropped. */
5
+ export declare const commentsMigration: (db: Db) => Promise<string | undefined>;
6
+ /** Refuses to go on while a resource has `.comments()` and the database lacks their table; the project's migrations add it. */
7
+ export declare const checkComments: (db: Db, models: readonly ResourceModel[]) => Promise<void>;
8
+ //#endregion
@@ -0,0 +1,37 @@
1
+ import { migrationCommand } from "../changes/capture-schema.js";
2
+ import { sql } from "kysely";
3
+ //#region src/comments/comments-sql.ts
4
+ /** The table the comments of every resource with `.comments()` are kept in, in a schema of its own. */
5
+ const commentsTable = "\"comments\".\"comments\"";
6
+ /**
7
+ * A row per comment: the record it is on by resource and encoded key, its tenant, its author and the user ids it
8
+ * mentions (`[]` for none). Every column the admin list filters on has an index.
9
+ */
10
+ const commentsTableSql = () => `create table if not exists ${commentsTable} (
11
+ "id" bigint generated always as identity primary key,
12
+ "resource" text not null,
13
+ "record_key" text not null,
14
+ "tenant_id" text,
15
+ "author_id" text not null,
16
+ "body" text not null,
17
+ "mentions" jsonb not null,
18
+ "created_at" timestamptz not null default now()
19
+ );
20
+ create index if not exists "comments_record" on ${commentsTable} ("resource", "record_key", "id");
21
+ create index if not exists "comments_tenant" on ${commentsTable} ("tenant_id", "id");
22
+ create index if not exists "comments_author" on ${commentsTable} ("author_id", "id");
23
+ create index if not exists "comments_created_at" on ${commentsTable} ("created_at")`;
24
+ const hasCommentsTable = async (db) => (await sql`select to_regclass('comments.comments') is not null as found`.execute(db)).rows[0].found;
25
+ /** The SQL that adds the `comments` schema and its table, `undefined` when it is there. The table is never dropped. */
26
+ const commentsMigration = async (db) => {
27
+ if (await hasCommentsTable(db)) return void 0;
28
+ return `create schema if not exists "comments";\n\n${commentsTableSql()};`;
29
+ };
30
+ /** Refuses to go on while a resource has `.comments()` and the database lacks their table; the project's migrations add it. */
31
+ const checkComments = async (db, models) => {
32
+ if (!models.some((model) => model.comments)) return;
33
+ if (await hasCommentsTable(db)) return;
34
+ throw new Error(`The comments schema is missing, and resources with .comments() keep their comments in comments.comments. Write it as a migration with \`${migrationCommand}\`, apply it with the project's migrations, then start again.`);
35
+ };
36
+ //#endregion
37
+ export { checkComments, commentsMigration, commentsTable, commentsTableSql };
@@ -0,0 +1,37 @@
1
+ import { defineEmail } from "../mail/define-email.js";
2
+ import { AuthEmailLayout, EmailButton, EmailNote } from "../better-auth/auth-mail/auth-email-layout.js";
3
+ import { Text } from "react-email";
4
+ import { jsx, jsxs } from "react/jsx-runtime";
5
+ //#region src/comments/mention-email.tsx
6
+ const MentionEmail = ({ app, author, record, body, url }) => /* @__PURE__ */ jsxs(AuthEmailLayout, {
7
+ app,
8
+ preview: `${author} mentioned you on ${record}`,
9
+ heading: `${author} mentioned you`,
10
+ children: [
11
+ /* @__PURE__ */ jsxs(Text, { children: [
12
+ author,
13
+ " mentioned you in a comment on ",
14
+ record,
15
+ " in ",
16
+ app.name,
17
+ ":"
18
+ ] }),
19
+ /* @__PURE__ */ jsx(Text, {
20
+ style: { whiteSpace: "pre-wrap" },
21
+ children: body.split("\n").flatMap((line, index) => index === 0 ? [line] : [/* @__PURE__ */ jsx("br", {}, index), line])
22
+ }),
23
+ /* @__PURE__ */ jsxs(EmailButton, {
24
+ href: url,
25
+ children: ["Open ", record]
26
+ }),
27
+ /* @__PURE__ */ jsx(EmailNote, { children: "You get this email because someone mentioned you. Reply in the app, not to this email." })
28
+ ]
29
+ });
30
+ /** The email to someone a comment mentions: who wrote it on which record, the comment, and a link to the record. */
31
+ const mentionEmail = defineEmail({
32
+ name: "mention",
33
+ subject: ({ author, record }) => `${author} mentioned you on ${record}`,
34
+ component: MentionEmail
35
+ });
36
+ //#endregion
37
+ export { mentionEmail };
@@ -0,0 +1,3 @@
1
+ import "./comment-users.js";
2
+ import "../index.js";
3
+ import { z } from "zod";
@@ -0,0 +1,43 @@
1
+ import { defineJob } from "../jobs/define-job.js";
2
+ import { commentsTable } from "./comments-sql.js";
3
+ import { z } from "zod";
4
+ import { sql } from "kysely";
5
+ //#region src/comments/mention-job.ts
6
+ const payload = z.object({
7
+ comment: z.string().regex(/^\d+$/),
8
+ user: z.string().min(1),
9
+ url: z.string().url(),
10
+ record: z.string()
11
+ });
12
+ /**
13
+ * The job that emails someone a comment mentions, enqueued with the comment: nothing when the app has no mail, when an
14
+ * admin deleted the comment since, or when the user is banned or gone. A refused send is retried.
15
+ */
16
+ const mentionJob = (users, appName) => defineJob({
17
+ name: "comments.mention",
18
+ payload,
19
+ retryDelay: "1 minute",
20
+ run: async ({ comment, user, url, record }, { db, mail }) => {
21
+ if (!mail) return;
22
+ if (!users) throw new Error("Not implemented: mentions need Better Auth");
23
+ const { rows } = await sql`select "author_id", "body" from ${sql.raw(commentsTable)} where "id" = ${comment}::bigint`.execute(db);
24
+ const found = rows[0];
25
+ if (!found) return;
26
+ const to = await users.email(user);
27
+ if (!to) return;
28
+ const author = (await users.names([found.author_id]))[found.author_id] ?? found.author_id;
29
+ const { mentionEmail } = await import("./mention-email.js");
30
+ await mentionEmail.send(to, {
31
+ app: {
32
+ name: appName,
33
+ url: new URL("/", url).href
34
+ },
35
+ author,
36
+ record,
37
+ body: found.body,
38
+ url
39
+ }, mail);
40
+ }
41
+ });
42
+ //#endregion
43
+ export { mentionJob };
@@ -0,0 +1,38 @@
1
+ import { notFound } from "../problem.js";
2
+ import { readTransaction } from "../transactions.js";
3
+ import { parseParams } from "../params.js";
4
+ import { commentAccess, mayRead, readableRecord, requireSignedIn, userSessions } from "./comment-access.js";
5
+ import { z } from "zod";
6
+ //#region src/comments/mentionable.ts
7
+ const params = z.object({ q: z.string().max(100).optional() });
8
+ /** The most people `:mentionable` offers, and the candidates it checks to find them. */
9
+ const shown = 8;
10
+ const candidates = 20;
11
+ /**
12
+ * The people the caller may mention on a record they may comment on: users whose name contains `q`, by name, who may
13
+ * read the record themselves, without the caller. None without the project's auth.
14
+ */
15
+ const mentionable = async (deps, entry, session, key, query) => {
16
+ const access = await commentAccess(deps, entry, session);
17
+ requireSignedIn(session);
18
+ const { q = "" } = parseParams(params, query);
19
+ const users = deps.comments?.users;
20
+ const found = users ? (await users.search(q.trim(), candidates)).filter((user) => user.id !== String(session.user.id)) : [];
21
+ const sessions = await userSessions(deps, session, found.map((user) => user.id));
22
+ const readers = await readTransaction(deps.db, deps.statementTimeoutMs, async (trx) => {
23
+ if (!await readableRecord(trx, access, key, "hide")) return void 0;
24
+ const ids = /* @__PURE__ */ new Set();
25
+ for (const { id, session: theirs } of sessions) {
26
+ if (ids.size === shown) break;
27
+ if (await mayRead(trx, deps, entry, theirs, key)) ids.add(id);
28
+ }
29
+ return ids;
30
+ });
31
+ if (!readers) throw notFound(`No ${entry.name} with this key`);
32
+ return { items: found.filter((user) => readers.has(user.id)).map(({ id, name }) => ({
33
+ id,
34
+ name
35
+ })) };
36
+ };
37
+ //#endregion
38
+ export { mentionable };
@@ -0,0 +1,57 @@
1
+ import { badRequest, notFound } from "../problem.js";
2
+ import { tenantScope } from "../tenant.js";
3
+ import { writeTransaction } from "../transactions.js";
4
+ import { commentsTable } from "./comments-sql.js";
5
+ import { commentAccess, mayRead, readableRecord, requireSignedIn, userSessions } from "./comment-access.js";
6
+ import { parseCommentInput } from "./comment-input.js";
7
+ import { commentColumns, commentItem, namedUsers } from "./comment-item.js";
8
+ import { readableTitle, recordLabel } from "./record-label.js";
9
+ import { encodeKey } from "@protobase/schema/internal";
10
+ import { appPath } from "@protobase/schema";
11
+ import { sql } from "kysely";
12
+ //#region src/comments/post-comment.ts
13
+ const invalidMention = () => badRequest("invalid-mention", "Someone you mentioned cannot read this record");
14
+ /**
15
+ * Adds the caller's comment to a record they may read, live rather than soft-deleted, and enqueues the email of each
16
+ * person it mentions, who must be able to read the record too, in the same transaction. `requestURL` is the request's,
17
+ * whose origin the emailed link takes when the app is served there (see `Deps.comments`).
18
+ */
19
+ const postComment = async (deps, entry, session, key, body, requestURL) => {
20
+ const access = await commentAccess(deps, entry, session);
21
+ requireSignedIn(session);
22
+ const author = String(session.user.id);
23
+ const input = parseCommentInput(body, author);
24
+ const { users, mentionJob, appOrigin } = deps.comments ?? {};
25
+ if (input.mentions.length > 0 && !(users && mentionJob)) throw invalidMention();
26
+ const mentioned = await userSessions(deps, session, input.mentions);
27
+ const tenant = tenantScope(entry, session)?.tenantValue;
28
+ const title = readableTitle(access, deps.views);
29
+ const row = await writeTransaction(deps.db, deps.statementTimeoutMs, async (trx) => {
30
+ const current = await readableRecord(trx, access, key, "hide", title === void 0 ? [] : [title]);
31
+ if (!current) return void 0;
32
+ for (const { session: theirs } of mentioned) if (!await mayRead(trx, deps, entry, theirs, key)) throw invalidMention();
33
+ const { rows } = await sql`insert into ${sql.raw(commentsTable)} ("resource", "record_key", "tenant_id", "author_id", "body", "mentions")
34
+ values (${entry.name}, ${encodeKey(key)}, ${tenant === void 0 ? null : String(tenant)}, ${author}, ${input.body}, ${JSON.stringify(input.mentions)}::jsonb)
35
+ returning ${sql.raw(commentColumns)}`.execute(trx);
36
+ const inserted = rows[0];
37
+ if (input.mentions.length === 0) return inserted;
38
+ const url = `${appOrigin(requestURL)}${appPath({
39
+ kind: "record",
40
+ resource: entry.name,
41
+ key
42
+ })}`;
43
+ const record = recordLabel(access, deps.views, key, current.record);
44
+ for (const user of input.mentions) await mentionJob.enqueue(trx, {
45
+ comment: inserted.id,
46
+ user,
47
+ url,
48
+ record
49
+ }, { key: `${inserted.id}:${user}` });
50
+ return inserted;
51
+ });
52
+ if (!row) throw notFound(`No ${entry.name} with this key`);
53
+ const names = deps.comments?.users ? await deps.comments.users.names(namedUsers([row])) : {};
54
+ return commentItem(row, names);
55
+ };
56
+ //#endregion
57
+ export { postComment };
@@ -0,0 +1,45 @@
1
+ import { notFound } from "../problem.js";
2
+ import { tenantScope } from "../tenant.js";
3
+ import { readTransaction } from "../transactions.js";
4
+ import { commentsTable } from "./comments-sql.js";
5
+ import { parseParams } from "../params.js";
6
+ import { commentAccess, readableRecord } from "./comment-access.js";
7
+ import { commentColumns, commentItem, namedUsers } from "./comment-item.js";
8
+ import { encodeKey } from "@protobase/schema/internal";
9
+ import { z } from "zod";
10
+ import { sql } from "kysely";
11
+ //#region src/comments/read-comments.ts
12
+ const params = z.object({
13
+ page_size: z.coerce.number().int().min(0).max(100).optional(),
14
+ page_token: z.string().regex(/^\d+$/, "Use the next_page_token of the previous page").optional()
15
+ });
16
+ const defaultPageSize = 20;
17
+ /**
18
+ * A page of a record's comments, newest first, for a caller who may read the record: the same tenant, row filter and
19
+ * record-level rule as a get, a soft-deleted record included.
20
+ */
21
+ const readComments = async (deps, entry, session, key, query) => {
22
+ const access = await commentAccess(deps, entry, session);
23
+ const { page_size, page_token } = parseParams(params, query);
24
+ const size = page_size || defaultPageSize;
25
+ const tenant = tenantScope(entry, session)?.tenantValue;
26
+ const rows = await readTransaction(deps.db, deps.statementTimeoutMs, async (trx) => {
27
+ if (!await readableRecord(trx, access, key, entry.model.softDelete ? "show" : "hide")) return void 0;
28
+ const { rows } = await sql`select ${sql.raw(commentColumns)}
29
+ from ${sql.raw(commentsTable)} where "resource" = ${entry.name} and "record_key" = ${encodeKey(key)}
30
+ ${tenant === void 0 ? sql`` : sql`and "tenant_id" = ${String(tenant)}`}
31
+ ${page_token === void 0 ? sql`` : sql`and "id" < ${page_token}::bigint`}
32
+ order by "id" desc limit ${size + 1}`.execute(trx);
33
+ return rows;
34
+ });
35
+ if (!rows) throw notFound(`No ${entry.name} with this key`);
36
+ const page = rows.slice(0, size);
37
+ const ids = namedUsers(page);
38
+ const names = ids.length > 0 && deps.comments?.users ? await deps.comments.users.names(ids) : {};
39
+ return {
40
+ items: page.map((row) => commentItem(row, names)),
41
+ next_page_token: rows.length > size ? page.at(-1).id : ""
42
+ };
43
+ };
44
+ //#endregion
45
+ export { readComments };
@@ -0,0 +1,53 @@
1
+ import { f, l, resource, view } from "@protobase/schema";
2
+ //#region src/comments/record-comments.ts
3
+ const admins = ({ user }) => user?.roles?.includes("admin") ?? false;
4
+ /**
5
+ * Every comment on every resource with comments, over their table: the admin app lists them as Comments. Admins only,
6
+ * whatever the project's roles say, since it spans every record; they may delete one, which is how a wrong or abusive
7
+ * comment goes. With `tenant`, when a resource with comments has one, it is scoped to the caller's tenant.
8
+ */
9
+ const commentsResource = ({ tenant }) => {
10
+ const comments = resource("record_comments").table("comments.comments").fields({
11
+ id: f.bigint().readOnly().sortable(),
12
+ resource: f.text().readOnly().filterable(),
13
+ record_key: f.text().readOnly().filterable(),
14
+ tenant_id: f.text().optional().readOnly(),
15
+ author_id: f.text().readOnly().filterable(),
16
+ body: f.text().readOnly(),
17
+ mentions: f.json().readOnly(),
18
+ created_at: f.timestamp().readOnly().filterable()
19
+ }).primaryKey((r) => r.id).access({
20
+ list: admins,
21
+ read: admins,
22
+ create: () => false,
23
+ update: () => false,
24
+ delete: admins
25
+ });
26
+ return tenant ? comments.tenant((r) => r.tenant_id) : comments;
27
+ };
28
+ const commentsView = view("record_comments").names({
29
+ singular: "Comment",
30
+ plural: "Comments"
31
+ }).icon("message-square").title((r) => r.record_key).help("Every comment on the records whose resource takes comments, newest first. Delete one to take it down.").fields((r) => ({
32
+ record_key: r.record_key.label("Record"),
33
+ author_id: r.author_id.label("By"),
34
+ body: r.body.label("Comment"),
35
+ mentions: r.mentions.label("Mentions"),
36
+ created_at: r.created_at.label("When")
37
+ })).list((r) => ({
38
+ columns: [
39
+ r.created_at,
40
+ r.resource,
41
+ r.record_key,
42
+ r.author_id,
43
+ r.body
44
+ ],
45
+ sort: [[r.id, "desc"]]
46
+ })).layout((r) => [l.section("Comment", [
47
+ r.resource,
48
+ r.record_key,
49
+ r.body,
50
+ r.mentions
51
+ ]), l.sidebar([r.created_at, r.author_id])]);
52
+ //#endregion
53
+ export { commentsResource, commentsView };
@@ -0,0 +1,19 @@
1
+ import { encodeKey } from "@protobase/schema/internal";
2
+ //#region src/comments/record-label.ts
3
+ /** The title field of the resource's view, when the caller may read it. */
4
+ const readableTitle = (access, views) => {
5
+ const title = views.find((view) => view.resource === access.entry.name)?.title;
6
+ return title !== void 0 && Object.hasOwn(access.model.fields, title) ? title : void 0;
7
+ };
8
+ /**
9
+ * The record as a mention email names it: the view's singular name with the record's title when it has one the author
10
+ * may read, else with its key, such as `Ticket: Printer on fire` or `Ticket 12`.
11
+ */
12
+ const recordLabel = (access, views, key, record) => {
13
+ const name = views.find((view) => view.resource === access.entry.name)?.names?.singular ?? access.entry.name;
14
+ const title = readableTitle(access, views);
15
+ const value = title === void 0 ? void 0 : record[title];
16
+ return value === void 0 || value === null || value === "" ? `${name} ${encodeKey(key)}` : `${name}: ${String(value)}`;
17
+ };
18
+ //#endregion
19
+ export { readableTitle, recordLabel };