@teamscala/service-boot 0.23.284

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 (138) hide show
  1. package/package.json +1016 -0
  2. package/src/agreement-gate.test.ts +118 -0
  3. package/src/agreement-gate.ts +887 -0
  4. package/src/asset-cache-key.ts +59 -0
  5. package/src/asset-optimize.ts +93 -0
  6. package/src/asset-proxy.ts +148 -0
  7. package/src/asset-social-card.ts +58 -0
  8. package/src/attach-agreement-magic.ts +62 -0
  9. package/src/attach-portal-members.ts +107 -0
  10. package/src/attach-proposal-accept.ts +123 -0
  11. package/src/attach-proposal-magic.ts +55 -0
  12. package/src/attach-transfers-upload.ts +100 -0
  13. package/src/auth-gate.ts +385 -0
  14. package/src/bind-page-handler.ts +223 -0
  15. package/src/build-client-assets/input-hashing.ts +206 -0
  16. package/src/build-client-assets/source-candidates.ts +115 -0
  17. package/src/build-client-assets.test.ts +216 -0
  18. package/src/build-client-assets.ts +962 -0
  19. package/src/compose-page-title.test.ts +88 -0
  20. package/src/compose-page-title.ts +73 -0
  21. package/src/content-gate/access.ts +89 -0
  22. package/src/content-gate/config-types.ts +108 -0
  23. package/src/content-gate/gate.ts +74 -0
  24. package/src/content-gate/parse-cookie.ts +23 -0
  25. package/src/content-gate/token.test.ts +70 -0
  26. package/src/content-gate/token.ts +148 -0
  27. package/src/content-page-floor.test.ts +127 -0
  28. package/src/content-page-floor.ts +70 -0
  29. package/src/discover-blocks.ts +228 -0
  30. package/src/expand-roster.ts +197 -0
  31. package/src/fn-routers/agreement-completion-email.ts +115 -0
  32. package/src/fn-routers/agreement-send-router.ts +303 -0
  33. package/src/fn-routers/ai-health-router.ts +64 -0
  34. package/src/fn-routers/auth-router.ts +131 -0
  35. package/src/fn-routers/cache-admin-router.ts +50 -0
  36. package/src/fn-routers/chat-session-router.ts +455 -0
  37. package/src/fn-routers/email-defaults.ts +22 -0
  38. package/src/fn-routers/email-link-ttl.ts +23 -0
  39. package/src/fn-routers/index.ts +70 -0
  40. package/src/fn-routers/panel-data-router.ts +93 -0
  41. package/src/fn-routers/persist-chat-messages.test.ts +86 -0
  42. package/src/fn-routers/persist-chat-messages.ts +96 -0
  43. package/src/fn-routers/plans-router.ts +141 -0
  44. package/src/fn-routers/proposal-send-router.ts +207 -0
  45. package/src/fn-routers/public-agreement-router.ts +865 -0
  46. package/src/fn-routers/public-assessment-router.ts +421 -0
  47. package/src/fn-routers/public-gpt-session-router.ts +373 -0
  48. package/src/fn-routers/sign-in-flow-router.ts +196 -0
  49. package/src/fn-routers/task-sync-bind.test.ts +105 -0
  50. package/src/fn-routers/task-sync-classify.test.ts +109 -0
  51. package/src/fn-routers/task-sync-dispatch.test.ts +44 -0
  52. package/src/fn-routers/task-sync-dispatch.ts +124 -0
  53. package/src/fn-routers/task-sync-router.test.ts +279 -0
  54. package/src/fn-routers/task-sync-router.ts +1036 -0
  55. package/src/fn-routers/tasks-router.ts +77 -0
  56. package/src/fn-routers/user-preferences-router.ts +139 -0
  57. package/src/fn-routers/work-items-router.ts +341 -0
  58. package/src/gate-ttls.ts +56 -0
  59. package/src/gpt-gate.ts +76 -0
  60. package/src/handlers/bun-stable-watch.ts +89 -0
  61. package/src/handlers/projects-digest.ts +198 -0
  62. package/src/handlers/prune-stale-data.ts +110 -0
  63. package/src/handlers/registry-stale-inactive-sweep.ts +82 -0
  64. package/src/handlers/scala-tools-currency-watch.test.ts +60 -0
  65. package/src/handlers/scala-tools-currency-watch.ts +171 -0
  66. package/src/handlers/scheduled-browser-tests.ts +85 -0
  67. package/src/handlers/sweep-event-log.ts +98 -0
  68. package/src/handlers/sweep-host-cache.ts +211 -0
  69. package/src/handlers/upstream-issue-sync.ts +210 -0
  70. package/src/handlers/whatsapp-liveness-handler.test.ts +59 -0
  71. package/src/handlers/whatsapp-liveness-logic.ts +53 -0
  72. package/src/handlers/whatsapp-liveness.test.ts +78 -0
  73. package/src/handlers/whatsapp-liveness.ts +125 -0
  74. package/src/hono-error-handler.ts +55 -0
  75. package/src/install-harness.ts +80 -0
  76. package/src/install-logging.ts +182 -0
  77. package/src/load-service-install-row.ts +98 -0
  78. package/src/load-service-prisma-client.ts +53 -0
  79. package/src/mount-orpc-router.ts +148 -0
  80. package/src/mount-server-packages.ts +155 -0
  81. package/src/mount.ts +18 -0
  82. package/src/mutable-app-ref.ts +68 -0
  83. package/src/page-route-shell-types.ts +16 -0
  84. package/src/page-server-orpc/audit-revert-router.ts +102 -0
  85. package/src/page-server-orpc/hub-mcp-client.ts +20 -0
  86. package/src/page-server-orpc/portal-diagnostics-router.ts +170 -0
  87. package/src/page-server-orpc/puck-pages-router.ts +272 -0
  88. package/src/page-server-orpc/server-client.ts +31 -0
  89. package/src/page-server-orpc/templates-router.ts +116 -0
  90. package/src/page-server-orpc/user-preferences-router.ts +132 -0
  91. package/src/pg-boss-handler-registry.ts +36 -0
  92. package/src/pg-boss-mount.ts +295 -0
  93. package/src/proposal-authorized-emails.ts +65 -0
  94. package/src/proposal-chat-inject.ts +141 -0
  95. package/src/proposal-gate.ts +175 -0
  96. package/src/read-html-template.test.ts +74 -0
  97. package/src/read-html-template.ts +102 -0
  98. package/src/read-viewport-from-request.ts +36 -0
  99. package/src/readiness-gate.ts +39 -0
  100. package/src/register-api-routes.ts +127 -0
  101. package/src/register-crash-handlers.ts +39 -0
  102. package/src/register-page-routes.ts +62 -0
  103. package/src/register-page-server-client.ts +109 -0
  104. package/src/register-registry-page-route/ai-chat-prefetch.ts +106 -0
  105. package/src/register-registry-page-route/graph-cache.test.ts +84 -0
  106. package/src/register-registry-page-route/graph-cache.ts +90 -0
  107. package/src/register-registry-page-route/meta-title.ts +73 -0
  108. package/src/register-registry-page-route/proposal-approval-email-token.ts +78 -0
  109. package/src/register-registry-page-route/proposal-brand-stamping.ts +60 -0
  110. package/src/register-registry-page-route/resolve-page-data.ts +518 -0
  111. package/src/register-registry-page-route.ts +765 -0
  112. package/src/register-static-assets.ts +180 -0
  113. package/src/register-ws-routes.ts +180 -0
  114. package/src/resolve-app-base-url.ts +28 -0
  115. package/src/resolve-brand-favicon.ts +65 -0
  116. package/src/resolve-from-loader-config.test.ts +44 -0
  117. package/src/resolve-from-loader-config.ts +291 -0
  118. package/src/resolve-service-context.ts +67 -0
  119. package/src/resolve-theme-mode.ts +61 -0
  120. package/src/resolvers/fetch-org-brand.ts +28 -0
  121. package/src/resolvers/portal-route-paths.test.ts +102 -0
  122. package/src/resolvers/portal-route-paths.ts +83 -0
  123. package/src/resolvers/website-by-host-map.ts +49 -0
  124. package/src/resource-chat-inject.ts +168 -0
  125. package/src/resource-not-found.ts +263 -0
  126. package/src/run-boot-hook-list.ts +75 -0
  127. package/src/run-boot-hooks.ts +97 -0
  128. package/src/scala-tools-currency-watchdog.ts +160 -0
  129. package/src/security-headers.ts +59 -0
  130. package/src/serve/serve-ws.test.ts +135 -0
  131. package/src/serve/serve.ts +190 -0
  132. package/src/server-error-page.test.ts +102 -0
  133. package/src/server-error-page.ts +95 -0
  134. package/src/start-scheduled-jobs.ts +84 -0
  135. package/src/two-phase-boot.ts +449 -0
  136. package/src/types.ts +52 -0
  137. package/src/ui-service.ts +171 -0
  138. package/src/ws-boot-self-test.ts +114 -0
@@ -0,0 +1,77 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * ORPC procedures for task creation with initial comment.
7
+ * Extracted from @teamscala/orpc/fn-router — primitives tier must not
8
+ * contain application-specific work_items logic.
9
+ */
10
+
11
+ import { authed, type Context } from "./auth-router.ts";
12
+
13
+ function toSession(ctx: Context): { userId: string; orgId: string | null } {
14
+ const user = ctx.user as { id: string; organisationId?: string | null };
15
+ return {
16
+ userId: user.id,
17
+ orgId: ctx.orgId || user.organisationId || null,
18
+ };
19
+ }
20
+
21
+ export const tasksRouter = {
22
+ createWithComment: authed.handler(async ({ input, context }) => {
23
+ const data = input as {
24
+ project_id: string;
25
+ title: string;
26
+ initial_comment: string;
27
+ status?: string;
28
+ assignee_id?: string;
29
+ due_date?: string;
30
+ start_date?: string;
31
+ time_estimate?: number;
32
+ assignee_type?: string;
33
+ agent_assignee_id?: string;
34
+ automation_type?: string;
35
+ responsible_party?: string;
36
+ depends_on?: string[];
37
+ };
38
+
39
+ if (!data.project_id) throw new Error("project_id is required");
40
+ if (!data.title) throw new Error("title is required");
41
+ if (!data.initial_comment) throw new Error("initial_comment is required");
42
+
43
+ const userId = toSession(context).userId;
44
+ const result = await (context.prisma as any).$transaction(
45
+ async (tx: any) => {
46
+ const task = await tx.work_items.create({
47
+ data: {
48
+ kind: "task",
49
+ project_id: data.project_id,
50
+ title: data.title,
51
+ status: data.status || "todo",
52
+ assignee_id: data.assignee_id || null,
53
+ due_date: data.due_date || null,
54
+ start_date: data.start_date || null,
55
+ time_estimate: data.time_estimate || null,
56
+ assignee_type: data.assignee_type || "user",
57
+ agent_assignee_id: data.agent_assignee_id || null,
58
+ automation_type: data.automation_type || "manual",
59
+ responsible_party: data.responsible_party || "team",
60
+ depends_on: data.depends_on || [],
61
+ created_by: userId,
62
+ updated_by: userId,
63
+ },
64
+ });
65
+ await tx.comments.create({
66
+ data: {
67
+ work_item_id: task.id,
68
+ content: data.initial_comment,
69
+ user_id: userId,
70
+ },
71
+ });
72
+ return task;
73
+ },
74
+ );
75
+ return { success: true, data: result };
76
+ }),
77
+ };
@@ -0,0 +1,139 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * ORPC procedures for a GENERIC per-user preference store backed by the
7
+ * `user_preferences` table. The user is resolved from the ORPC session context
8
+ * (`authed` middleware → `context.user.id`); the client supplies the org + key
9
+ * + value. This is the DB-durable, user-scoped key/value surface any feature
10
+ * can use — the AI browser-control focus (key `ai_ui_focus`) is the first
11
+ * consumer; it is NOT scala-projects-bespoke.
12
+ *
13
+ * Why this exists (over the generated `user_preferences` CRUD): the generated
14
+ * create/update do NOT resolve `user_id` server-side (only `organisation_id`
15
+ * from ctx.orgId), so a client cannot correctly write a per-user row through
16
+ * them. This proc stamps `user_id` from the authenticated caller, making the
17
+ * write safe + per-user. It also emits the `data_change` notify so the
18
+ * realtime surface (regions.watchDataChange) pushes updates to subscribed
19
+ * clients — the cross-region channel for the AI focus (the AiChat panel writes
20
+ * a focus here; the project-list-view in a different region subscribes +
21
+ * reacts, no shared React-context ancestor needed).
22
+ *
23
+ * Ownership is user_id: a caller reads/writes ONLY their own preferences. The
24
+ * org is part of the compound key (per-org isolation) but is client-supplied
25
+ * (the session carries the user, not the active org).
26
+ *
27
+ * DECISION — host-scoping of `user_preferences`: the table is NOT yet a shared
28
+ * `@teamscala/db` model (it lives in scala-projects's schema today), so this proc
29
+ * is genuinely host-scoped: it runs correctly on services whose Prisma client
30
+ * declares `user_preferences` (scala-projects is the first host) + is inert
31
+ * elsewhere. `Context.prisma` is `any`-typed, so the proc COMPILES workspace-
32
+ * wide (no type error on a model a host may not declare) — the `any` is the
33
+ * deliberate seam that lets one generic proc serve hosts incrementally as they
34
+ * adopt the table. Per-user preferences are platform-universal, so the tracked
35
+ * follow-on is to promote `user_preferences` to a shared `@teamscala/db` model
36
+ * (open-migration: `user_preferences→shared-model`, exit: every kind:ui host
37
+ * can mount this proc with no schema precondition) — at which point the proc is
38
+ * universally hostable + the `any` seam can tighten to the shared client. Until
39
+ * then the proc is opt-in per host (declare the table → get the capability).
40
+ */
41
+
42
+ import * as v from "valibot";
43
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
44
+ import { executeRawUnsafe } from "@teamscala/db/query-rows";
45
+ import { authed, type Context } from "./auth-router.ts";
46
+
47
+ interface SessionUser {
48
+ id: string;
49
+ }
50
+
51
+ const upsertSchema = v.object({
52
+ organisationId: v.string(),
53
+ /** The preference key (e.g. `ai_ui_focus`). Namespaced by feature. */
54
+ key: v.string(),
55
+ /** The JSON value. Arbitrary per feature — typed by the caller. */
56
+ value: v.any(),
57
+ });
58
+
59
+ const getSchema = v.object({
60
+ organisationId: v.string(),
61
+ key: v.string(),
62
+ });
63
+
64
+ /**
65
+ * Emit a best-effort `data_change` notify so realtime subscribers
66
+ * (regions.watchDataChange filtering on `user_preferences`) re-read + react.
67
+ * Mirrors the notify the generated CRUD emits. Never throws — realtime must
68
+ * never block a write (matches the generated create/update notify guard).
69
+ */
70
+ async function notifyDataChange(
71
+ prisma: any,
72
+ organisationId: string,
73
+ op: string,
74
+ ): Promise<void> {
75
+ if (!organisationId) return;
76
+ try {
77
+ await (executeRawUnsafe as Function)(
78
+ "SELECT pg_notify('data_change', $1)",
79
+ JSON.stringify({ table: "user_preferences", op, org_id: organisationId }),
80
+ );
81
+ } catch {
82
+ /* best-effort */
83
+ }
84
+ }
85
+
86
+ export const userPreferencesRouter = {
87
+ // upsert — write (create-or-replace) the caller's preference value for
88
+ // (org, key). Stamps user_id from the session; the client never supplies it.
89
+ upsert: authed.input(upsertSchema).handler(async ({ input, context }) => {
90
+ const prisma = (context as Context).prisma;
91
+ const user = context.user as SessionUser;
92
+ const log = getAppLogger();
93
+ const { organisationId, key, value } = input;
94
+
95
+ try {
96
+ await prisma.user_preferences.upsert({
97
+ where: {
98
+ user_id_organisation_id_key: {
99
+ user_id: user.id,
100
+ organisation_id: organisationId,
101
+ key,
102
+ },
103
+ },
104
+ create: {
105
+ user_id: user.id,
106
+ organisation_id: organisationId,
107
+ key,
108
+ value,
109
+ },
110
+ update: { value },
111
+ });
112
+ await notifyDataChange(prisma, organisationId, "upsert");
113
+ return { success: true };
114
+ } catch (err) {
115
+ log.error(
116
+ `[userPreferences.upsert] failed: ${err instanceof Error ? err.message : String(err)}`,
117
+ );
118
+ throw err;
119
+ }
120
+ }),
121
+
122
+ // get — read the caller's preference value for (org, key), or null. The
123
+ // initial read for a subscriber (the realtime notify then drives re-reads).
124
+ get: authed.input(getSchema).handler(async ({ input, context }) => {
125
+ const prisma = (context as Context).prisma;
126
+ const user = context.user as SessionUser;
127
+ const row = await prisma.user_preferences.findUnique({
128
+ where: {
129
+ user_id_organisation_id_key: {
130
+ user_id: user.id,
131
+ organisation_id: input.organisationId,
132
+ key: input.key,
133
+ },
134
+ },
135
+ select: { value: true, updated_at: true },
136
+ });
137
+ return { value: row?.value ?? null, updatedAt: row?.updated_at ?? null };
138
+ }),
139
+ };
@@ -0,0 +1,341 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * ORPC procedure for safely deleting a work_item (initiative/project/task).
7
+ * Refuses the delete when the row still has child rows (parent_id = id), so a
8
+ * delete can NEVER orphan its descendants — the tree must be emptied bottom-up.
9
+ *
10
+ * Initiatives, projects and tasks are all rows of the ONE `work_items` table
11
+ * discriminated by `kind`, and `parent_id` carries no DB-level foreign key, so
12
+ * a plain `work_items.delete` leaves dangling children with no referential
13
+ * integrity to stop it. This one guard covers every level (you may only delete
14
+ * a leaf — a node with no children), which is exactly the "empty it first"
15
+ * behaviour the board wants.
16
+ */
17
+
18
+ import { createEmailClient } from "@teamscala/email-client/client";
19
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
20
+ import { authed, type Context } from "./auth-router.ts";
21
+ import { resolveAppBaseUrl } from "../resolve-app-base-url.ts";
22
+
23
+ const emailClient = createEmailClient();
24
+
25
+ export const workItemsRouter = {
26
+ /**
27
+ * Fired client-side right after a task's status is set to
28
+ * "pending_approval" (RaCI: the task's assignee is Responsible; the
29
+ * project's Accountable person is the approver). Walks parent_id up to
30
+ * the nearest project-kind ancestor, emails every accountable person on
31
+ * it. Never throws to the caller — a notification failure must not block
32
+ * the status change that already committed.
33
+ */
34
+ requestApproval: authed.handler(async ({ input, context }) => {
35
+ const { id } = input as { id: string };
36
+ const log = getAppLogger();
37
+ const prisma = (context as Context).prisma as {
38
+ work_items: {
39
+ findUnique: (a: unknown) => Promise<{
40
+ id: string;
41
+ title: string;
42
+ kind: string;
43
+ parent_id: string | null;
44
+ organisation_id: string | null;
45
+ } | null>;
46
+ };
47
+ work_item_roles: {
48
+ findMany: (a: unknown) => Promise<{ user_id: string }[]>;
49
+ };
50
+ user: {
51
+ findUnique: (a: unknown) => Promise<{ id: string; email: string; name: string | null } | null>;
52
+ };
53
+ organisation_profile: {
54
+ findFirst: (a: unknown) => Promise<{ company_name: string | null } | null>;
55
+ };
56
+ notifications: {
57
+ create: (a: unknown) => Promise<unknown>;
58
+ };
59
+ };
60
+
61
+ try {
62
+ const task = await prisma.work_items.findUnique({
63
+ where: { id },
64
+ select: { id: true, title: true, kind: true, parent_id: true, organisation_id: true },
65
+ });
66
+ if (!task) return { notified: 0 };
67
+
68
+ let project = task;
69
+ while (project.kind !== "project" && project.parent_id) {
70
+ const parent = await prisma.work_items.findUnique({
71
+ where: { id: project.parent_id },
72
+ select: { id: true, title: true, kind: true, parent_id: true, organisation_id: true },
73
+ });
74
+ if (!parent) break;
75
+ project = parent;
76
+ }
77
+
78
+ const accountable = await prisma.work_item_roles.findMany({
79
+ where: { work_item_id: project.id, role: "accountable" },
80
+ });
81
+ if (accountable.length === 0) {
82
+ log.info("[work-items-router] requestApproval: no accountable person set", { taskId: id, projectId: project.id });
83
+ return { notified: 0 };
84
+ }
85
+
86
+ const requester = context.user as { id: string; name?: string | null; email?: string | null } | null;
87
+ const org = await prisma.organisation_profile.findFirst({
88
+ where: { id: task.organisation_id },
89
+ select: { company_name: true },
90
+ });
91
+ const linkUrl = `${await resolveAppBaseUrl()}/${task.organisation_id}?panel=task-comments&comment=${task.id}`;
92
+ const requesterName = requester?.name ?? requester?.email ?? "Someone";
93
+
94
+ let notified = 0;
95
+ for (const role of accountable) {
96
+ const user = await prisma.user.findUnique({ where: { id: role.user_id }, select: { id: true, email: true, name: true } });
97
+ if (!user?.email) continue;
98
+ const result = await emailClient.send({
99
+ to: user.email,
100
+ template: "task-awaiting-approval",
101
+ vars: {
102
+ recipient_name: user.name ?? user.email,
103
+ requester_name: requesterName,
104
+ project_title: project.title,
105
+ task_title: task.title,
106
+ task_url: linkUrl,
107
+ org_name: org?.company_name ?? "your organisation",
108
+ },
109
+ });
110
+ if (result.status !== "failed") notified++;
111
+ try {
112
+ await prisma.notifications.create({
113
+ data: {
114
+ organisation_id: task.organisation_id,
115
+ user_id: role.user_id,
116
+ type: "approval_request",
117
+ title: `${requesterName} requested approval`,
118
+ body: task.title,
119
+ work_item_id: task.id,
120
+ link_url: linkUrl,
121
+ },
122
+ });
123
+ } catch (err) {
124
+ log.error("[work-items-router] requestApproval: failed to write notification row", {
125
+ taskId: id,
126
+ userId: role.user_id,
127
+ error: err instanceof Error ? err.message : String(err),
128
+ });
129
+ }
130
+ }
131
+ return { notified };
132
+ } catch (err) {
133
+ log.error("[work-items-router] requestApproval failed", { taskId: id, error: err instanceof Error ? err.message : String(err) });
134
+ return { notified: 0 };
135
+ }
136
+ }),
137
+ /**
138
+ * Fired client-side right after a comment is created, when the comment
139
+ * text contains an @mention the client already resolved to a team-member
140
+ * id (client does the name matching against the people list it already
141
+ * has loaded; this handler only sends). Never throws to the caller for
142
+ * same reason as requestApproval -- a notification failure must not
143
+ * surface as a failed comment post.
144
+ */
145
+ notifyMentions: authed.handler(async ({ input, context }) => {
146
+ const { workItemId, mentionedUserIds, commentExcerpt } = input as {
147
+ workItemId: string;
148
+ mentionedUserIds: string[];
149
+ commentExcerpt: string;
150
+ };
151
+ const log = getAppLogger();
152
+ const prisma = (context as Context).prisma as {
153
+ work_items: {
154
+ findUnique: (a: unknown) => Promise<{ id: string; title: string; organisation_id: string | null } | null>;
155
+ };
156
+ user: {
157
+ findUnique: (a: unknown) => Promise<{ id: string; email: string; name: string | null } | null>;
158
+ };
159
+ notifications: {
160
+ create: (a: unknown) => Promise<unknown>;
161
+ };
162
+ };
163
+ if (!mentionedUserIds || mentionedUserIds.length === 0) return { notified: 0 };
164
+ try {
165
+ const task = await prisma.work_items.findUnique({
166
+ where: { id: workItemId },
167
+ select: { id: true, title: true, organisation_id: true },
168
+ });
169
+ if (!task) return { notified: 0 };
170
+ const author = context.user as { id: string; name?: string | null; email?: string | null } | null;
171
+ const commentUrl = `${await resolveAppBaseUrl()}/${task.organisation_id}?panel=task-comments&comment=${task.id}`;
172
+ const authorName = author?.name ?? author?.email ?? "Someone";
173
+ const excerpt = commentExcerpt.slice(0, 280);
174
+
175
+ let notified = 0;
176
+ for (const userId of new Set(mentionedUserIds)) {
177
+ if (userId === author?.id) continue;
178
+ let user: { id: string; email: string; name: string | null } | null = null;
179
+ try {
180
+ user = await prisma.user.findUnique({ where: { id: userId }, select: { id: true, email: true, name: true } });
181
+ } catch (err) {
182
+ log.error("[work-items-router] notifyMentions: user lookup failed", { userId, error: err instanceof Error ? err.message : String(err) });
183
+ continue;
184
+ }
185
+ if (!user?.email) continue;
186
+ let result: { status: string };
187
+ try {
188
+ result = await emailClient.send({
189
+ to: user.email,
190
+ template: "comment-mention",
191
+ vars: {
192
+ recipient_name: user.name ?? user.email,
193
+ author_name: authorName,
194
+ task_title: task.title,
195
+ comment_excerpt: excerpt,
196
+ comment_url: commentUrl,
197
+ },
198
+ });
199
+ } catch (err) {
200
+ log.error("[work-items-router] notifyMentions: emailClient.send threw", { userId, error: err instanceof Error ? err.message : String(err) });
201
+ result = { status: "failed" };
202
+ }
203
+ if (result.status !== "failed") notified++;
204
+ try {
205
+ await prisma.notifications.create({
206
+ data: {
207
+ organisation_id: task.organisation_id,
208
+ user_id: userId,
209
+ type: "mention",
210
+ title: `${authorName} mentioned you`,
211
+ body: excerpt,
212
+ work_item_id: task.id,
213
+ link_url: commentUrl,
214
+ },
215
+ });
216
+ } catch (err) {
217
+ log.error("[work-items-router] notifyMentions: failed to write notification row", {
218
+ workItemId,
219
+ userId,
220
+ error: err instanceof Error ? err.message : String(err),
221
+ });
222
+ }
223
+ }
224
+ return { notified };
225
+ } catch (err) {
226
+ log.error("[work-items-router] notifyMentions failed", { workItemId, error: err instanceof Error ? err.message : String(err) });
227
+ return { notified: 0 };
228
+ }
229
+ }),
230
+ /**
231
+ * Org-scoped member list for the task-comments panel — @mention matching AND
232
+ * comment-sender display (name + avatar). ChatPanel.tsx has no
233
+ * serverLoader/orgId prop (client-only overlay content), so it can't do the
234
+ * SSR raw-SQL org-scope join load-project-list-view.ts uses; this exposes
235
+ * the same result via a custom procedure instead of widening service_schema
236
+ * to the raw better_auth_member table.
237
+ *
238
+ * This is the SINGLE source of member display info for the comments panel:
239
+ * it coalesces BOTH name/email/avatar families on the user model into one
240
+ * shape (the `full_name`/`primary_email`/`avatar_url` family AND the
241
+ * better-auth `name`/`email`/`image` family). Returning avatar here lets the
242
+ * client resolve sender name+avatar from this one list and drop the separate
243
+ * `user.findMany` enrichment fetch it previously ran as a second waterfall
244
+ * hop after `comments.findMany` (loading-shape: one wave-parallel hit, not a
245
+ * dependent client-side round-trip chain).
246
+ */
247
+ orgMembers: authed.handler(async ({ input, context }) => {
248
+ const { orgId } = input as { orgId: string };
249
+ const prisma = (context as Context).prisma as {
250
+ $queryRawUnsafe: <T>(sql: string, ...params: unknown[]) => Promise<T>;
251
+ user: {
252
+ findMany: (a: unknown) => Promise<{
253
+ id: string;
254
+ name: string | null;
255
+ email: string | null;
256
+ image: string | null;
257
+ full_name: string | null;
258
+ primary_email: string | null;
259
+ avatar_url: string | null;
260
+ avatar_thumb_url: string | null;
261
+ }[]>;
262
+ };
263
+ };
264
+ if (!orgId) return [];
265
+ const memberIds = (
266
+ await prisma.$queryRawUnsafe<{ user_id: string }[]>(
267
+ "SELECT user_id FROM better_auth_member WHERE organization_id = $1",
268
+ orgId,
269
+ )
270
+ ).map((m) => m.user_id);
271
+ if (memberIds.length === 0) return [];
272
+ const users = await prisma.user.findMany({
273
+ where: { id: { in: memberIds } },
274
+ select: {
275
+ id: true,
276
+ name: true,
277
+ email: true,
278
+ image: true,
279
+ full_name: true,
280
+ primary_email: true,
281
+ avatar_url: true,
282
+ avatar_thumb_url: true,
283
+ },
284
+ });
285
+ return users.map((u) => ({
286
+ id: u.id,
287
+ name: u.full_name || u.name || "",
288
+ email: u.primary_email || u.email || "",
289
+ avatar: u.avatar_url || u.image || u.avatar_thumb_url || null,
290
+ }));
291
+ }),
292
+ deleteEmpty: authed.handler(async ({ input, context }) => {
293
+ const { id } = input as { id: string };
294
+ if (!id) throw new Error("id is required");
295
+ const prisma = (context as Context).prisma as {
296
+ work_items: {
297
+ findUnique: (a: unknown) => Promise<{
298
+ id: string;
299
+ kind: string;
300
+ organisation_id: string | null;
301
+ } | null>;
302
+ count: (a: unknown) => Promise<number>;
303
+ delete: (a: unknown) => Promise<unknown>;
304
+ };
305
+ };
306
+
307
+ const item = await prisma.work_items.findUnique({
308
+ where: { id },
309
+ select: { id: true, kind: true, organisation_id: true },
310
+ });
311
+ if (!item) throw new Error("Work item not found");
312
+
313
+ // Org-scope parity with the generated CRUD: a non-super-admin may only
314
+ // delete within their active organisation.
315
+ const user = context.user as { id: string; isSuperAdmin?: boolean } | null;
316
+ const orgId = (context as Context).orgId ?? null;
317
+ if (
318
+ !user?.isSuperAdmin &&
319
+ item.organisation_id &&
320
+ item.organisation_id !== orgId
321
+ ) {
322
+ const { ORPCError } = await import("@orpc/server");
323
+ throw new ORPCError("FORBIDDEN", {
324
+ message: "You can only delete items in your own organisation.",
325
+ });
326
+ }
327
+
328
+ const childCount = await prisma.work_items.count({
329
+ where: { parent_id: id },
330
+ });
331
+ if (childCount > 0) {
332
+ const { ORPCError } = await import("@orpc/server");
333
+ throw new ORPCError("CONFLICT", {
334
+ message: `Can't delete this ${item.kind}: it still has ${childCount} item${childCount === 1 ? "" : "s"} inside it. Empty it first, then delete.`,
335
+ });
336
+ }
337
+
338
+ await prisma.work_items.delete({ where: { id } });
339
+ return { success: true };
340
+ }),
341
+ };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Cached readers for the content-gate access-token TTL config rows
7
+ * (agreement-gate / proposal-gate / gpt-gate). initGateTtls() runs once at boot
8
+ * (two-phase-boot, before the gates mount) to populate the cache from the
9
+ * registry rows; the sync getters (agTtlMs/propTtlMs/gptTtlMs) return the
10
+ * cached value — or the former hardcoded default before init / on row-miss — so
11
+ * the sync AccessConfig.ttlMs fields + sync hono magic-link handlers read them
12
+ * without async. Replaces the per-file AG_TTL_MS / PROP_TTL_MS / GPT_TTL_MS
13
+ * constants (config-constants: the registry row is the source, dedup'd across
14
+ * each gate + its attach-* magic counterpart so token TTL and cookie maxAge
15
+ * never drift).
16
+ */
17
+ import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
18
+ import * as v from "valibot";
19
+
20
+ const DEFAULT_AG_TTL_MS = 30 * 60 * 1000;
21
+ const DEFAULT_PROP_TTL_MS = 24 * 60 * 60 * 1000;
22
+ const DEFAULT_GPT_TTL_MS = 30 * 24 * 60 * 60 * 1000;
23
+
24
+ let _agTtlMs = DEFAULT_AG_TTL_MS;
25
+ let _propTtlMs = DEFAULT_PROP_TTL_MS;
26
+ let _gptTtlMs = DEFAULT_GPT_TTL_MS;
27
+ let _initialized = false;
28
+
29
+ /** Populate the TTL cache from the registry rows. Idempotent; keeps defaults on
30
+ * any miss so the gates ALWAYS render (protective-layers-fail-explicitly). */
31
+ export async function initGateTtls(): Promise<void> {
32
+ if (_initialized) return;
33
+ _initialized = true;
34
+ try {
35
+ const [ag, prop, gpt] = await Promise.all([
36
+ loadRegistryConfig("config", "agreement-gate", v.looseObject({})),
37
+ loadRegistryConfig("config", "proposal-gate", v.looseObject({})),
38
+ loadRegistryConfig("config", "gpt-gate", v.looseObject({})),
39
+ ]);
40
+ if (typeof ag?.ag_ttl_ms === "number") _agTtlMs = ag.ag_ttl_ms;
41
+ if (typeof prop?.prop_ttl_ms === "number") _propTtlMs = prop.prop_ttl_ms;
42
+ if (typeof gpt?.gpt_ttl_ms === "number") _gptTtlMs = gpt.gpt_ttl_ms;
43
+ } catch {
44
+ // keep defaults
45
+ }
46
+ }
47
+
48
+ export function agTtlMs(): number {
49
+ return _agTtlMs;
50
+ }
51
+ export function propTtlMs(): number {
52
+ return _propTtlMs;
53
+ }
54
+ export function gptTtlMs(): number {
55
+ return _gptTtlMs;
56
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * The PUBLIC GPT identity gate, expressed as config for the content-gate
7
+ * primitive (content-gate/). Unlike proposals/agreements this is NOT access
8
+ * control — every email is a valid visitor (lead capture). Its whole job is to
9
+ * make a returning visitor's identity known AT SSR so the GPT body renders the
10
+ * real conversation on first paint instead of server-rendering the capture gate
11
+ * and then swapping to the chat after hydration (the ssr-then-live-handoff +
12
+ * "know the visitor at SSR first paint" violation: building-applications).
13
+ *
14
+ * Flow: the capture form POSTs {gptId, email} to /api/gpt-access (openCapture →
15
+ * any email matches); the access endpoint sets a signed `gpt_token` cookie. On
16
+ * the next request runContentGate verifies that cookie for this agent and seeds
17
+ * resolved.initialVisitor — which the GPT body serverLoader (ssrGptSession) then
18
+ * uses to SSR-restore the conversation, and GptBodyBlock uses to skip the gate.
19
+ * onUnauthorized is a no-op: an un-gated (first-time) visitor sees the capture
20
+ * form the block renders itself when no visitor is known — that is correct, not
21
+ * a flash (they ARE anonymous), so there is nothing to withhold.
22
+ */
23
+
24
+ import type { AccessConfig, ContentGateConfig } from "./content-gate/config-types.ts";
25
+ import { signAccessToken, verifyAccessTokenIdentity } from "./content-gate/token.ts";
26
+ import { gptTtlMs } from "./gate-ttls.ts";
27
+
28
+ const GPT_COOKIE = "gpt_token";
29
+ // A GPT visitor returns over days/weeks; keep the identity cookie long-lived so
30
+ // the conversation is restored on every revisit without re-entering the email.
31
+
32
+ export const gptGateConfig: ContentGateConfig = {
33
+ cookieName: GPT_COOKIE,
34
+ getResourceId(resolved) {
35
+ // The GPT route resolves the agent into resolved.agent (the same entity
36
+ // the page title + GptBodyBlock derive from). No agent → not a GPT page →
37
+ // the engine no-ops.
38
+ const agent = resolved.agent as { id?: unknown } | null | undefined;
39
+ return typeof agent?.id === "string" ? agent.id : "";
40
+ },
41
+ verifyToken(token, resourceId, nowMs) {
42
+ return verifyAccessTokenIdentity(token, GPT_COOKIE, resourceId, nowMs);
43
+ },
44
+ onAuthorized(resolved, _resourceId, { email, name }) {
45
+ // Seed the verified visitor identity (building-applications initialVisitor)
46
+ // with the name carried in the token, so the header AND body render the
47
+ // full identity at SSR — and the body serverLoader (ssrGptSession) restores
48
+ // the conversation from the email. No client-side gate→chat swap.
49
+ (resolved as {
50
+ initialVisitor?: { name?: string; email: string };
51
+ }).initialVisitor = { name, email };
52
+ },
53
+ onUnauthorized() {
54
+ // No-op: a first-time/anonymous visitor correctly sees the capture form
55
+ // GptBodyBlock renders when no visitor is known. Nothing to withhold —
56
+ // the GPT page body is not gated content, only its identity is.
57
+ },
58
+ };
59
+
60
+ export const gptAccessConfig: AccessConfig = {
61
+ path: "/api/gpt-access",
62
+ cookieName: GPT_COOKIE,
63
+ get ttlMs() {
64
+ return gptTtlMs();
65
+ },
66
+ recordIdField: "gptId",
67
+ // Lead capture: any email is a valid visitor (no allow-list).
68
+ openCapture: true,
69
+ // Never called under openCapture; present to satisfy the config contract.
70
+ async resolveAuthorizedEmails() {
71
+ return new Set<string>();
72
+ },
73
+ signToken(resourceId, email, nowMs, name) {
74
+ return signAccessToken(GPT_COOKIE, resourceId, email, nowMs, gptTtlMs(), name);
75
+ },
76
+ };