@teamscala/service-boot 0.23.430 → 0.23.432

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 (82) hide show
  1. package/package.json +12 -19
  2. package/src/agreement-gate/signing-shell.ts +19 -15
  3. package/src/attach-proposal-accept.ts +5 -2
  4. package/src/auth-gate/public-paths.ts +7 -16
  5. package/src/auth-gate.ts +54 -42
  6. package/src/bind-page-handler.ts +20 -32
  7. package/src/build-client-assets/build-lock.ts +15 -6
  8. package/src/build-client-assets/hashed-output.ts +3 -1
  9. package/src/build-client-assets/hydrate-bundle.ts +59 -57
  10. package/src/build-client-assets/input-fingerprint.ts +50 -57
  11. package/src/build-client-assets/input-hashing.ts +2 -4
  12. package/src/build-client-assets/manifest.ts +8 -11
  13. package/src/build-client-assets/source-candidates.ts +8 -16
  14. package/src/build-client-assets/sourcemaps-upload.ts +17 -10
  15. package/src/build-client-assets/styles-build.ts +17 -19
  16. package/src/build-client-assets.test.ts +2 -2
  17. package/src/build-client-assets.ts +16 -22
  18. package/src/content-gate/access.ts +5 -2
  19. package/src/content-gate/catalog.ts +26 -18
  20. package/src/content-gate/gate.ts +7 -3
  21. package/src/discover-blocks.ts +5 -7
  22. package/src/fn-routers/agreement-certificate.ts +1 -1
  23. package/src/fn-routers/agreement-send-router.ts +5 -4
  24. package/src/fn-routers/agreement-sign-side-effects.ts +27 -22
  25. package/src/fn-routers/assessment-report-email.ts +3 -2
  26. package/src/fn-routers/auth-router.ts +20 -21
  27. package/src/fn-routers/chat-session-router-shared.ts +12 -3
  28. package/src/fn-routers/chat-session-router.ts +8 -6
  29. package/src/fn-routers/conversation-ownership.ts +3 -2
  30. package/src/fn-routers/messaging-whatsapp-router.test.ts +3 -1
  31. package/src/fn-routers/messaging-whatsapp-router.ts +17 -3
  32. package/src/fn-routers/persist-chat-messages.ts +6 -4
  33. package/src/fn-routers/proposal-send-router.ts +4 -3
  34. package/src/fn-routers/public-agreement-router.ts +5 -4
  35. package/src/fn-routers/public-assessment-router.ts +5 -4
  36. package/src/fn-routers/public-gpt-session-router.ts +17 -12
  37. package/src/fn-routers/user-preferences-router.ts +6 -15
  38. package/src/fn-routers/work-items-router.ts +21 -17
  39. package/src/handlers/platform-fault-escalation/delivery.ts +36 -0
  40. package/src/handlers/platform-fault-escalation/orpc-client.ts +38 -18
  41. package/src/handlers/platform-fault-escalation-logic.ts +26 -5
  42. package/src/handlers/platform-fault-escalation.test.ts +67 -15
  43. package/src/handlers/platform-fault-escalation.ts +16 -0
  44. package/src/handlers/projects-digest.ts +1 -1
  45. package/src/handlers/scala-tools-currency-watch.test.ts +1 -5
  46. package/src/handlers/scala-tools-currency-watch.ts +23 -32
  47. package/src/handlers/singleton-tools-currency-watch.test.ts +1 -5
  48. package/src/handlers/singleton-tools-currency-watch.ts +27 -19
  49. package/src/handlers/upstream-issue-sync.ts +11 -4
  50. package/src/handlers/whatsapp-liveness.ts +14 -4
  51. package/src/install-harness.ts +1 -1
  52. package/src/install-logging.ts +1 -1
  53. package/src/mount-orpc-router.test.ts +56 -10
  54. package/src/mount-orpc-router.ts +23 -9
  55. package/src/mount-server-packages.ts +17 -15
  56. package/src/pg-boss-mount.ts +5 -12
  57. package/src/proposal-gate.ts +7 -2
  58. package/src/read-html-template.test.ts +12 -15
  59. package/src/read-html-template.ts +2 -1
  60. package/src/readiness-gate.ts +2 -1
  61. package/src/register-crash-handlers.ts +4 -8
  62. package/src/register-page-server-client.ts +15 -19
  63. package/src/register-registry-page-route/ai-chat-prefetch.ts +16 -12
  64. package/src/register-registry-page-route/apply-page-branding.ts +68 -56
  65. package/src/register-registry-page-route/graph-cache.ts +22 -18
  66. package/src/register-registry-page-route/meta-title.ts +24 -20
  67. package/src/register-registry-page-route/prefetch-org-id.ts +6 -2
  68. package/src/register-registry-page-route/proposal-approval-email-token.ts +5 -3
  69. package/src/register-registry-page-route/run-content-gates.ts +2 -4
  70. package/src/register-registry-page-route.ts +1 -2
  71. package/src/register-static-assets.ts +6 -5
  72. package/src/resolve-app-base-url.ts +12 -5
  73. package/src/resolvers/portal-route-paths.ts +15 -11
  74. package/src/resource-chat-inject.ts +84 -72
  75. package/src/resource-not-found.ts +23 -19
  76. package/src/scala-tools-currency-watchdog.ts +41 -12
  77. package/src/two-phase-full-app.ts +61 -29
  78. package/src/two-phase-minimal-app.ts +39 -31
  79. package/src/two-phase-readiness.ts +50 -38
  80. package/src/ui-service.ts +50 -38
  81. package/src/handlers/pane-outbox-drain.ts +0 -39
  82. package/src/handlers/prune-stale-data.ts +0 -110
@@ -32,6 +32,7 @@ import {
32
32
  import { sendResendEmail } from "./resend-client.ts";
33
33
  import { applyTemplateVars, lookupTemplate } from "@teamscala/email/templates/lookup";
34
34
  import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
35
+ import type { PrismaClient } from "@teamscala/db/client";
35
36
  import { getAppLogger } from "@teamscala/logger/app-loggers";
36
37
  import { signAgreementAccessToken } from "@teamscala/document-signing/agreement-access-token";
37
38
  import { resetRlsContext, setRlsContext } from "@teamscala/db/rls-context";
@@ -49,8 +50,8 @@ interface User {
49
50
  interface Context {
50
51
  user: User | null | undefined;
51
52
  headers: Headers;
52
- resolveUser?: (headers: Headers, prisma: any) => Promise<User | null>;
53
- prisma: any;
53
+ resolveUser?: (headers: Headers, prisma: PrismaClient) => Promise<User | null>;
54
+ prisma: PrismaClient;
54
55
  }
55
56
 
56
57
  const authed = os.$context<Context>().use(async (opt) => {
@@ -120,7 +121,7 @@ function bareAddress(sender: string): string {
120
121
  * platform's verified Resend address; reply_to is the org's contact email.
121
122
  */
122
123
  async function resolveSender(
123
- prisma: any,
124
+ prisma: PrismaClient,
124
125
  organisationId: string,
125
126
  ): Promise<{ from: string; replyTo?: string; orgName: string }> {
126
127
  const templates = await loadRegistryConfig(
@@ -180,7 +181,7 @@ export const agreementSendRouter = {
180
181
  };
181
182
 
182
183
  async function runSend(
183
- prisma: any,
184
+ prisma: PrismaClient,
184
185
  agreementId: string,
185
186
  baseUrl: string,
186
187
  log: ReturnType<typeof getAppLogger>,
@@ -11,8 +11,33 @@
11
11
  * byte-for-byte move; import paths adjusted only.
12
12
  */
13
13
 
14
+ /**
15
+ * Self-resetting demo harness. If the signed agreement's `proposal_id` is in
16
+ * the `demo-reset-agreements.proposalIds` allow-list, reset its parties to
17
+ * UNSIGNED (signedAt/signature cleared) + clear signed_at/signed_by + status
18
+ * 'sent', so the next person gets a fresh form to test the UI.
19
+ *
20
+ * Keyed on the stable PROPOSAL id, NOT the agreement id: the demo agreement is
21
+ * regenerated under a fresh id whenever the demo proposal is re-accepted, but
22
+ * its `proposal_id` FK is constant — so the allow-list cannot dangle. This
23
+ * deletes the drift class (prevention, not a patrol): the prior design keyed on
24
+ * `agreementIds`, and when the demo was regenerated the list silently pointed
25
+ * at a dead id and the reset became a no-op for weeks. A `proposal_id`-
26
+ * referenced row that doesn't exist is a real proposal being deleted, not a
27
+ * regeneration — a negligible residual the operator would notice directly.
28
+ *
29
+ * Gated strictly by the allow-list — a real agreement is never reset.
30
+ * Best-effort: any failure is swallowed (a demo-reset must never fail the
31
+ * signer's own response). The current signer already has their response (incl.
32
+ * payUrl) before this runs; only the stored record is reset.
33
+ */
34
+
35
+ // Why: Self-resetting demo harness. If the signed agreement's `prop…
36
+ // (full note hoisted to this file's leading header — kept out of the body per
37
+ // no-inline-prose-blocks / prose-stays-in-docs)
14
38
  import { getAppLogger } from "@teamscala/logger/app-loggers";
15
39
  import { queryRows } from "@teamscala/db/query-rows";
40
+ import type { PrismaClient } from "@teamscala/db/client";
16
41
  import type { AgreementParty } from "@teamscala/document-signing/agreement-parties";
17
42
 
18
43
  /** On full agreement completion, delegate to the payment service over HTTP to
@@ -63,28 +88,8 @@ export async function invoicePayUrl(
63
88
  }
64
89
  }
65
90
 
66
- /**
67
- * Self-resetting demo harness. If the signed agreement's `proposal_id` is in
68
- * the `demo-reset-agreements.proposalIds` allow-list, reset its parties to
69
- * UNSIGNED (signedAt/signature cleared) + clear signed_at/signed_by + status
70
- * 'sent', so the next person gets a fresh form to test the UI.
71
- *
72
- * Keyed on the stable PROPOSAL id, NOT the agreement id: the demo agreement is
73
- * regenerated under a fresh id whenever the demo proposal is re-accepted, but
74
- * its `proposal_id` FK is constant — so the allow-list cannot dangle. This
75
- * deletes the drift class (prevention, not a patrol): the prior design keyed on
76
- * `agreementIds`, and when the demo was regenerated the list silently pointed
77
- * at a dead id and the reset became a no-op for weeks. A `proposal_id`-
78
- * referenced row that doesn't exist is a real proposal being deleted, not a
79
- * regeneration — a negligible residual the operator would notice directly.
80
- *
81
- * Gated strictly by the allow-list — a real agreement is never reset.
82
- * Best-effort: any failure is swallowed (a demo-reset must never fail the
83
- * signer's own response). The current signer already has their response (incl.
84
- * payUrl) before this runs; only the stored record is reset.
85
- */
86
91
  export async function maybeResetDemoAgreement(
87
- prisma: any,
92
+ prisma: PrismaClient,
88
93
  agreementId: string,
89
94
  proposalId: string | null,
90
95
  parties: AgreementParty[],
@@ -129,7 +134,7 @@ export async function maybeResetDemoAgreement(
129
134
  * legal act; CRM connection is bookkeeping).
130
135
  */
131
136
  export async function connectSignerToCrm(
132
- prisma: any,
137
+ prisma: PrismaClient,
133
138
  agreement: { id: string; organisation_id?: string | null; contact_id?: string | null },
134
139
  email: string,
135
140
  signerName?: string | null,
@@ -19,6 +19,7 @@ import { applyTemplateVars, lookupTemplate } from "@teamscala/email/templates/lo
19
19
  import { resolveEmailSender } from "@teamscala/email/identity/resolve-email-sender";
20
20
  import { decryptSecretConfig } from "@teamscala/encryption/crypto/secret-config";
21
21
  import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
22
+ import type { PrismaClient } from "@teamscala/db/client";
22
23
  import { fetchOrgBrand } from "@teamscala/resolver-engine/data-resolvers/fetch-org-brand";
23
24
  import type { StageBand } from "./assessment-report-data.ts";
24
25
  import {
@@ -36,7 +37,7 @@ export async function sendAuthorityReportEmail(opts: {
36
37
  categoryScores: Record<string, number>;
37
38
  ctaUrl?: string;
38
39
  organisationId?: string | null;
39
- prisma: any;
40
+ prisma: PrismaClient;
40
41
  /**
41
42
  * Absolute origin (e.g. "https://quiz.scala.business") of the service that
42
43
  * received the submit — the origin that serves /api/assets. Mail clients
@@ -63,7 +64,7 @@ export async function sendAuthorityReportEmail(opts: {
63
64
  const brandRow =
64
65
  opts.organisationId && opts.prisma
65
66
  ? await fetchOrgBrand(
66
- opts.prisma as Parameters<typeof fetchOrgBrand>[0],
67
+ opts.prisma as unknown as Parameters<typeof fetchOrgBrand>[0],
67
68
  opts.organisationId,
68
69
  ).catch(() => null)
69
70
  : null;
@@ -10,6 +10,8 @@
10
10
 
11
11
  import { os } from "@orpc/server";
12
12
  import { createCache } from "@teamscala/cache/create-cache";
13
+ import type { PrismaClient } from "@teamscala/db/client";
14
+ import { queryRows } from "@teamscala/db/query-rows";
13
15
  import { resetRlsContext, setRlsContext } from "@teamscala/db/rls-context";
14
16
 
15
17
  interface User {
@@ -22,8 +24,8 @@ interface User {
22
24
  interface Context {
23
25
  user: User | null | undefined;
24
26
  headers: Headers;
25
- resolveUser?: (headers: Headers, prisma: any) => Promise<User | null>;
26
- prisma: any;
27
+ resolveUser?: (headers: Headers, prisma: PrismaClient) => Promise<User | null>;
28
+ prisma: PrismaClient;
27
29
  orgId?: string | null;
28
30
  }
29
31
 
@@ -64,22 +66,23 @@ const adminCache = createCache<boolean>("service-boot:org-admin", {
64
66
  async function isOrgAdminCached(
65
67
  userId: string,
66
68
  organisationId: string,
67
- prisma: any,
68
69
  ): Promise<boolean> {
69
70
  const cacheKey = `${userId}:${organisationId}`;
70
71
  const cached = adminCache.get(cacheKey);
71
72
  if (cached !== undefined) return cached;
72
73
 
73
74
  try {
74
- const membership = await prisma.user_organisation_memberships.findFirst({
75
- where: {
76
- user_id: userId,
77
- organisation_id: organisationId,
78
- role: { in: ["admin", "owner"] },
79
- },
80
- select: { id: true },
81
- });
82
- const result = !!membership;
75
+ // Raw SQL, not the model API: user_organisation_memberships lives in host
76
+ // schemas (not the shared client), so a typed model access would only
77
+ // compile on the hosts that declare it. queryRows is the sanctioned read
78
+ // surface and is host-portable by table name.
79
+ const membership = await queryRows<{ id: string }>`
80
+ SELECT id FROM user_organisation_memberships
81
+ WHERE user_id = ${userId}
82
+ AND organisation_id = ${organisationId}
83
+ AND role IN ('admin', 'owner')
84
+ LIMIT 1`;
85
+ const result = membership.length > 0;
83
86
  adminCache.set(cacheKey, result);
84
87
  return result;
85
88
  } catch {
@@ -91,7 +94,7 @@ export const authRouter = {
91
94
  isOrgAdmin: authed.handler(async ({ input, context }) => {
92
95
  const data = input as { userId: string; organisationId: string };
93
96
  if (!data.userId || !data.organisationId) return false;
94
- return isOrgAdminCached(data.userId, data.organisationId, context.prisma);
97
+ return isOrgAdminCached(data.userId, data.organisationId);
95
98
  }),
96
99
  resolveAdminOverride: authed.handler(async ({ input, context }) => {
97
100
  const data = input as {
@@ -100,24 +103,20 @@ export const authRouter = {
100
103
  };
101
104
  const targetUserId = data.targetUserId as string | undefined;
102
105
  if (!targetUserId) return null;
103
- if (targetUserId === (context.user as any)?.id) return null;
106
+ if (targetUserId === context.user?.id) return null;
104
107
  if (!data.adminOverride) {
105
108
  throw new Error(
106
109
  `This tool does not support admin override. Remove targetUserId from the request.`,
107
110
  );
108
111
  }
109
- const userId = (context.user as any)?.id;
110
- const organisationId = (context as any).orgId;
112
+ const userId = context.user?.id;
113
+ const organisationId = context.orgId;
111
114
  if (!userId || !organisationId) {
112
115
  throw new Error(
113
116
  `Admin override requires both user and organisation context.`,
114
117
  );
115
118
  }
116
- const admin = await isOrgAdminCached(
117
- userId,
118
- organisationId,
119
- context.prisma,
120
- );
119
+ const admin = await isOrgAdminCached(userId, organisationId);
121
120
  if (!admin) {
122
121
  throw new Error(
123
122
  `Access denied: you must be an org admin or owner to view another user's data.`,
@@ -91,10 +91,19 @@ export async function throwForbidden(message: string): Promise<never> {
91
91
  }
92
92
 
93
93
  /** Map stored message rows to the wire shape the chat client restores. */
94
- export function mapMessages(msgs: unknown[]) {
94
+ export function mapMessages(
95
+ msgs: Array<{
96
+ id: string;
97
+ role: string | null;
98
+ content: string | null;
99
+ channel_data: unknown;
100
+ created_at: Date | null;
101
+ occurred_at: Date | null;
102
+ }>,
103
+ ) {
95
104
  return msgs
96
- .filter((m: any) => m.role === "user" || m.role === "assistant")
97
- .map((m: any) => ({
105
+ .filter((m) => m.role === "user" || m.role === "assistant")
106
+ .map((m) => ({
98
107
  // Restore the stable row id so the client re-seeds useChat with the
99
108
  // SAME ids it persisted — a later re-save then upserts instead of
100
109
  // duplicating (chat-message-ids-server-stable).
@@ -91,8 +91,8 @@ export const chatSessionRouter = {
91
91
  orderBy: { created_at: "desc" },
92
92
  });
93
93
 
94
- let existing: any =
95
- candidates.find((c: any) => c.status === "active") ?? null;
94
+ let existing =
95
+ candidates.find((c) => c.status === "active") ?? null;
96
96
  // A returning user whose only thread is archived → reactivate the
97
97
  // most-recent one so their history continues (matches the GPT
98
98
  // "session gone" fix).
@@ -244,7 +244,9 @@ export const chatSessionRouter = {
244
244
  data: { status: "archived" },
245
245
  });
246
246
 
247
- const cd = (old.channel_data ?? {}) as Record<string, unknown>;
247
+ const cd = (old.channel_data ?? {}) as {
248
+ assistant_type?: string;
249
+ };
248
250
  const conversation = await prisma.conversations.create({
249
251
  data: {
250
252
  channel: "ai",
@@ -303,7 +305,7 @@ export const chatSessionRouter = {
303
305
 
304
306
  // One grouped fetch for the latest user message per thread (preview),
305
307
  // mapped in JS — avoids an N+1.
306
- const ids = conversations.map((c: any) => c.id);
308
+ const ids = conversations.map((c) => c.id);
307
309
  const lastMsgs = await prisma.messages.findMany({
308
310
  where: { conversation_id: { in: ids }, role: "user" },
309
311
  orderBy: { occurred_at: "desc" },
@@ -311,7 +313,7 @@ export const chatSessionRouter = {
311
313
  });
312
314
  const previewById = new Map<string, string>();
313
315
  for (const m of lastMsgs) {
314
- if (!previewById.has(m.conversation_id)) {
316
+ if (m.conversation_id && !previewById.has(m.conversation_id)) {
315
317
  previewById.set(
316
318
  m.conversation_id,
317
319
  (m.content ?? "").slice(0, 120),
@@ -320,7 +322,7 @@ export const chatSessionRouter = {
320
322
  }
321
323
 
322
324
  return {
323
- conversations: conversations.map((c: any) => ({
325
+ conversations: conversations.map((c) => ({
324
326
  id: c.id,
325
327
  title: c.title,
326
328
  status: c.status,
@@ -9,9 +9,10 @@
9
9
 
10
10
  import { throwForbidden, type SessionUser } from "./chat-session-router-shared.ts";
11
11
 
12
- /* eslint-disable @typescript-eslint/no-explicit-any */
12
+ import type { PrismaClient } from "@teamscala/db/client";
13
+
13
14
  export async function assertOwns(
14
- prisma: any,
15
+ prisma: PrismaClient,
15
16
  conversationId: string,
16
17
  user: SessionUser,
17
18
  ): Promise<unknown> {
@@ -67,7 +67,9 @@ const client = createRouterClient(
67
67
  context: {
68
68
  user: { id: "system", organisationId: null },
69
69
  headers: new Headers(),
70
- prisma: {},
70
+ // The authed base now sets the RLS/audit context per request; the
71
+ // mock only needs to accept the SET statements.
72
+ prisma: { $executeRawUnsafe: async () => [] },
71
73
  } as never,
72
74
  },
73
75
  ) as { messagingWhatsappSend: (a: unknown) => Promise<unknown> };
@@ -34,6 +34,8 @@ import {
34
34
  type SecretConfigLike,
35
35
  } from "@teamscala/encryption/crypto/secret-config";
36
36
  import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
37
+ import type { PrismaClient } from "@teamscala/db/client";
38
+ import { resetRlsContext, setRlsContext } from "@teamscala/db/rls-context";
37
39
  import { getAppLogger } from "@teamscala/logger/app-loggers";
38
40
 
39
41
  interface User {
@@ -44,8 +46,8 @@ interface User {
44
46
  interface Context {
45
47
  user: User | null | undefined;
46
48
  headers: Headers;
47
- resolveUser?: (headers: Headers, prisma: any) => Promise<User | null>;
48
- prisma: any;
49
+ resolveUser?: (headers: Headers, prisma: PrismaClient) => Promise<User | null>;
50
+ prisma: PrismaClient;
49
51
  }
50
52
 
51
53
  const authed = os.$context<Context>().use(async (opt) => {
@@ -58,7 +60,19 @@ const authed = os.$context<Context>().use(async (opt) => {
58
60
  const { ORPCError } = await import("@orpc/server");
59
61
  throw new ORPCError("UNAUTHORIZED", { message: "Authentication required" });
60
62
  }
61
- return await opt.next({ context: { ...context, user } });
63
+ // Audit attribution: writes through this base must record the actor in
64
+ // audit_trail (app.current_user_name), not an empty actor (the 'by .' bug).
65
+ const u = user as { id: string; name?: string | null; organisationId: string | null };
66
+ await setRlsContext(context.prisma, {
67
+ userId: u.id,
68
+ userName: u.name || u.id,
69
+ organisationId: u.organisationId,
70
+ });
71
+ try {
72
+ return await opt.next({ context: { ...context, user } });
73
+ } finally {
74
+ await resetRlsContext(context.prisma);
75
+ }
62
76
  });
63
77
 
64
78
  /**
@@ -18,6 +18,8 @@
18
18
  * with a 1ms step preserving array order so same-second saves restore in order.
19
19
  */
20
20
 
21
+ import type { Prisma, PrismaClient } from "@teamscala/db/client";
22
+
21
23
  export interface ChatMessageInput {
22
24
  id?: string;
23
25
  role: string;
@@ -32,7 +34,7 @@ export interface ChatMessageInput {
32
34
  }
33
35
 
34
36
  export async function persistChatMessages(
35
- prisma: any,
37
+ prisma: PrismaClient,
36
38
  opts: {
37
39
  conversationId: string;
38
40
  organisationId: string | null;
@@ -69,9 +71,9 @@ export async function persistChatMessages(
69
71
  direction,
70
72
  content: msg.content,
71
73
  occurred_at,
72
- channel_data: channelData,
74
+ channel_data: channelData as Prisma.InputJsonValue,
73
75
  },
74
- update: { content: msg.content, channel_data: channelData },
76
+ update: { content: msg.content, channel_data: channelData as Prisma.InputJsonValue },
75
77
  });
76
78
  } else {
77
79
  await prisma.messages.create({
@@ -83,7 +85,7 @@ export async function persistChatMessages(
83
85
  direction,
84
86
  content: msg.content,
85
87
  occurred_at,
86
- channel_data: channelData,
88
+ channel_data: channelData as Prisma.InputJsonValue,
87
89
  },
88
90
  });
89
91
  }
@@ -18,6 +18,7 @@ import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
18
18
  import { getAppLogger } from "@teamscala/logger/app-loggers";
19
19
  import { signAccessToken } from "../content-gate/token.ts";
20
20
  import { resetRlsContext, setRlsContext } from "@teamscala/db/rls-context";
21
+ import type { PrismaClient } from "@teamscala/db/client";
21
22
  import { getDefaultEmailSender } from "./email-defaults.ts";
22
23
  import { getEmailLinkTtlMs } from "./email-link-ttl.ts";
23
24
 
@@ -33,9 +34,9 @@ interface Context {
33
34
  user: User | null | undefined;
34
35
  headers: Headers;
35
36
  // matches agreement-send-router Context
36
- resolveUser?: (headers: Headers, prisma: any) => Promise<User | null>;
37
+ resolveUser?: (headers: Headers, prisma: PrismaClient) => Promise<User | null>;
37
38
  // prisma client type is service-injected
38
- prisma: any;
39
+ prisma: PrismaClient;
39
40
  }
40
41
 
41
42
  const authed = os.$context<Context>().use(async (opt) => {
@@ -70,7 +71,7 @@ function bareAddress(sender: string): string {
70
71
 
71
72
  async function resolveSender(
72
73
  // prisma client type is service-injected
73
- prisma: any,
74
+ prisma: PrismaClient,
74
75
  organisationId: string,
75
76
  ): Promise<{ from: string; replyTo?: string; orgName: string }> {
76
77
  const templates = await loadRegistryConfig(
@@ -25,6 +25,7 @@ import { os } from "@orpc/server";
25
25
  import * as v from "valibot";
26
26
  import { getAppLogger } from "@teamscala/logger/app-loggers";
27
27
  import { queryRows } from "@teamscala/db/query-rows";
28
+ import type { PrismaClient } from "@teamscala/db/client";
28
29
  import { signAgreementAccessToken } from "@teamscala/document-signing/agreement-access-token";
29
30
  import {
30
31
  parseAgreementParties as parseParties,
@@ -47,9 +48,9 @@ export { buildCertificateData } from "./agreement-certificate.ts";
47
48
  export type { CertificateData, CertificateCopy } from "./agreement-certificate.ts";
48
49
 
49
50
  interface Context {
50
- prisma: any;
51
+ prisma: PrismaClient;
51
52
  headers: Headers;
52
- resolveUser?: (headers: Headers, prisma: any) => Promise<unknown>;
53
+ resolveUser?: (headers: Headers, prisma: PrismaClient) => Promise<unknown>;
53
54
  }
54
55
 
55
56
  const pub = os.$context<Context>();
@@ -357,7 +358,7 @@ export const publicAgreementRouter = {
357
358
  signed_at: Date | null;
358
359
  signature_data: unknown;
359
360
  page_id: string | null;
360
- }[]
361
+ }
361
362
  >`
362
363
  SELECT id, name, signed_at, signature_data, page_id
363
364
  FROM agreements
@@ -370,7 +371,7 @@ export const publicAgreementRouter = {
370
371
  !!agreement.signed_at || (parties?.every((p) => p.signedAt) ?? false);
371
372
  let recomputedHash: string | null = null;
372
373
  if (evidence?.documentHash && parties && agreement.page_id) {
373
- const [doc] = await queryRows<{ puck_data: unknown }[]>`
374
+ const [doc] = await queryRows<{ puck_data: unknown }>`
374
375
  SELECT puck_data FROM builda_pages WHERE id = ${agreement.page_id} LIMIT 1`;
375
376
  recomputedHash = computeDocumentHash(doc?.puck_data ?? null, parties);
376
377
  }
@@ -25,11 +25,12 @@ import {
25
25
  } from "./assessment-report-data.ts";
26
26
  import { sendAuthorityReportEmail } from "./assessment-report-email.ts";
27
27
  import { scoreAnswers, toBandSlug, type StoredQuestion } from "./assessment-scoring.ts";
28
+ import type { PrismaClient } from "@teamscala/db/client";
28
29
 
29
30
  interface Context {
30
- prisma: any;
31
+ prisma: PrismaClient;
31
32
  headers: Headers;
32
- resolveUser?: (headers: Headers, prisma: any) => Promise<unknown>;
33
+ resolveUser?: (headers: Headers, prisma: PrismaClient) => Promise<unknown>;
33
34
  }
34
35
 
35
36
  const pub = os.$context<Context>();
@@ -76,9 +77,9 @@ export const publicAssessmentRouter = {
76
77
  });
77
78
  if (!assessment) return { error: "Assessment not found" } as const;
78
79
 
79
- const questions = (Array.isArray(assessment.questions)
80
+ const questions = ((Array.isArray(assessment.questions)
80
81
  ? assessment.questions
81
- : []) as StoredQuestion[];
82
+ : []) as unknown) as StoredQuestion[];
82
83
  const { totalScore, categoryScores } = scoreAnswers(
83
84
  questions,
84
85
  input.answers,
@@ -9,15 +9,16 @@
9
9
 
10
10
  import { os } from "@orpc/server";
11
11
  import * as v from "valibot";
12
+ import type { PrismaClient } from "@teamscala/db/client";
12
13
  import { getAppLogger } from "@teamscala/logger/app-loggers";
13
14
  import { queryRows } from "@teamscala/db/query-rows";
14
15
  import { getClient } from "@teamscala/object-storage/create/client/get-client";
15
16
  import { persistChatMessages } from "./persist-chat-messages.ts";
16
17
 
17
18
  interface Context {
18
- prisma: any;
19
+ prisma: PrismaClient;
19
20
  headers: Headers;
20
- resolveUser?: (headers: Headers, prisma: any) => Promise<unknown>;
21
+ resolveUser?: (headers: Headers, prisma: PrismaClient) => Promise<unknown>;
21
22
  }
22
23
 
23
24
  const pub = os.$context<Context>();
@@ -128,10 +129,8 @@ const uploadSchema = v.object({
128
129
  const contactName = contact.full_name ?? data.contactName;
129
130
  const contactPhone = contact.mobile_phone ?? undefined;
130
131
 
131
- // Restore the visitor's existing conversation on re-login by EMAIL —
132
- // regardless of status. Previously this filtered status:'active', so any
133
- // thread that had been archived (e.g. by a prior Clear) was invisible and
134
- // the visitor got a brand-new empty conversation every time ("session
132
+ // Restore the visitor's existing conversation on re-login by EMAIL — regardless of status. Previously this filtered status:'active', so any
133
+ // thread that had been archived (e.g. by a prior Clear) was invisible and the visitor got a brand-new empty conversation every time ("session
135
134
  // gone"). Restore the thread whose MOST RECENT MESSAGE is newest, found
136
135
  // via the messages themselves (last_message_at is unreliable on older
137
136
  // threads), reactivating it if archived. Only fall back to an active
@@ -163,10 +162,10 @@ const uploadSchema = v.object({
163
162
  // there is NO active thread do we reactivate the most-recent archived one (a
164
163
  // returning visitor whose thread was archived — preserving the earlier
165
164
  // "session gone" fix), and finally fall through to creating a new thread.
166
- const byCreatedDesc = (a: any, b: any) =>
165
+ const byCreatedDesc = (a: (typeof candidates)[number], b: (typeof candidates)[number]) =>
167
166
  new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
168
167
  let existing: (typeof candidates)[number] | null =
169
- candidates.filter((c: any) => c.status === "active").sort(byCreatedDesc)[0] ?? null;
168
+ candidates.filter((c) => c.status === "active").sort(byCreatedDesc)[0] ?? null;
170
169
  if (!existing && candidates.length > 0) {
171
170
  existing = [...candidates].sort(byCreatedDesc)[0] ?? null;
172
171
  if (existing) {
@@ -193,8 +192,8 @@ const uploadSchema = v.object({
193
192
  // filter it out so already-poisoned conversations restore cleanly.
194
193
  // Tool output is preserved inside the assistant message's parts[].
195
194
  messages: msgs
196
- .filter((m: any) => m.role === "user" || m.role === "assistant")
197
- .map((m: any) => ({ id: m.id, role: m.role, content: m.content, parts: (m.channel_data as { parts?: unknown[] } | null)?.parts, metadata: (m.channel_data as { metadata?: Record<string, unknown> } | null)?.metadata, created_at: m.created_at })),
195
+ .filter((m: { role: string | null }) => m.role === "user" || m.role === "assistant")
196
+ .map((m: { id: string; role: string | null; content: string | null; channel_data: unknown; created_at: Date }) => ({ id: m.id, role: m.role, content: m.content, parts: (m.channel_data as { parts?: unknown[] } | null)?.parts, metadata: (m.channel_data as { metadata?: Record<string, unknown> } | null)?.metadata, created_at: m.created_at })),
198
197
  tokenTotals: {
199
198
  inputTokens: (channelData?.total_input_tokens as number) ?? 0,
200
199
  outputTokens: (channelData?.total_output_tokens as number) ?? 0,
@@ -254,7 +253,7 @@ const uploadSchema = v.object({
254
253
  conversationId: conversation.id,
255
254
  contactName: (channelData?.contact_name as string) ?? "",
256
255
  contactEmail: (channelData?.contact_email as string) ?? "",
257
- messages: msgs.map((m: any) => ({ id: m.id, role: m.role, content: m.content, parts: (m.channel_data as { parts?: unknown[] } | null)?.parts, metadata: (m.channel_data as { metadata?: Record<string, unknown> } | null)?.metadata, created_at: m.created_at })),
256
+ messages: msgs.map((m: { id: string; role: string | null; content: string | null; channel_data: unknown; created_at: Date }) => ({ id: m.id, role: m.role, content: m.content, parts: (m.channel_data as { parts?: unknown[] } | null)?.parts, metadata: (m.channel_data as { metadata?: Record<string, unknown> } | null)?.metadata, created_at: m.created_at })),
258
257
  tokenTotals: {
259
258
  inputTokens: (channelData?.total_input_tokens as number) ?? 0,
260
259
  outputTokens: (channelData?.total_output_tokens as number) ?? 0,
@@ -313,7 +312,13 @@ const uploadSchema = v.object({
313
312
  }
314
313
 
315
314
  // Carry the old thread's identity into the fresh one.
316
- const cd = (old.channel_data ?? {}) as Record<string, unknown>;
315
+ const cd = (old.channel_data ?? {}) as {
316
+ assistant_type?: string;
317
+ contact_name?: string | null;
318
+ contact_email?: string | null;
319
+ contact_phone?: string | null;
320
+ source_url?: string | null;
321
+ };
317
322
  const conversation = await prisma.conversations.create({
318
323
  data: {
319
324
  channel: "ai",
@@ -24,19 +24,11 @@
24
24
  * org is part of the compound key (per-org isolation) but is client-supplied
25
25
  * (the session carries the user, not the active org).
26
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).
27
+ * DECISION — host-scoping of `user_preferences` is RETIRED: the table is a
28
+ * shared `@teamscala/db` model now, so `Context.prisma` is the typed shared
29
+ * PrismaClient and the proc is universally hostable — every host's generated
30
+ * client declares the table. (Previously `Context.prisma` was `any`-typed as a
31
+ * deliberate seam while only scala-projects declared the table.)
40
32
  */
41
33
 
42
34
  import * as v from "valibot";
@@ -68,7 +60,6 @@ const getSchema = v.object({
68
60
  * never block a write (matches the generated create/update notify guard).
69
61
  */
70
62
  async function notifyDataChange(
71
- prisma: any,
72
63
  organisationId: string,
73
64
  op: string,
74
65
  ): Promise<void> {
@@ -109,7 +100,7 @@ export const userPreferencesRouter = {
109
100
  },
110
101
  update: { value },
111
102
  });
112
- await notifyDataChange(prisma, organisationId, "upsert");
103
+ await notifyDataChange(organisationId, "upsert");
113
104
  return { success: true };
114
105
  } catch (err) {
115
106
  log.error(