@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.
- package/package.json +12 -19
- package/src/agreement-gate/signing-shell.ts +19 -15
- package/src/attach-proposal-accept.ts +5 -2
- package/src/auth-gate/public-paths.ts +7 -16
- package/src/auth-gate.ts +54 -42
- package/src/bind-page-handler.ts +20 -32
- package/src/build-client-assets/build-lock.ts +15 -6
- package/src/build-client-assets/hashed-output.ts +3 -1
- package/src/build-client-assets/hydrate-bundle.ts +59 -57
- package/src/build-client-assets/input-fingerprint.ts +50 -57
- package/src/build-client-assets/input-hashing.ts +2 -4
- package/src/build-client-assets/manifest.ts +8 -11
- package/src/build-client-assets/source-candidates.ts +8 -16
- package/src/build-client-assets/sourcemaps-upload.ts +17 -10
- package/src/build-client-assets/styles-build.ts +17 -19
- package/src/build-client-assets.test.ts +2 -2
- package/src/build-client-assets.ts +16 -22
- package/src/content-gate/access.ts +5 -2
- package/src/content-gate/catalog.ts +26 -18
- package/src/content-gate/gate.ts +7 -3
- package/src/discover-blocks.ts +5 -7
- package/src/fn-routers/agreement-certificate.ts +1 -1
- package/src/fn-routers/agreement-send-router.ts +5 -4
- package/src/fn-routers/agreement-sign-side-effects.ts +27 -22
- package/src/fn-routers/assessment-report-email.ts +3 -2
- package/src/fn-routers/auth-router.ts +20 -21
- package/src/fn-routers/chat-session-router-shared.ts +12 -3
- package/src/fn-routers/chat-session-router.ts +8 -6
- package/src/fn-routers/conversation-ownership.ts +3 -2
- package/src/fn-routers/messaging-whatsapp-router.test.ts +3 -1
- package/src/fn-routers/messaging-whatsapp-router.ts +17 -3
- package/src/fn-routers/persist-chat-messages.ts +6 -4
- package/src/fn-routers/proposal-send-router.ts +4 -3
- package/src/fn-routers/public-agreement-router.ts +5 -4
- package/src/fn-routers/public-assessment-router.ts +5 -4
- package/src/fn-routers/public-gpt-session-router.ts +17 -12
- package/src/fn-routers/user-preferences-router.ts +6 -15
- package/src/fn-routers/work-items-router.ts +21 -17
- package/src/handlers/platform-fault-escalation/delivery.ts +36 -0
- package/src/handlers/platform-fault-escalation/orpc-client.ts +38 -18
- package/src/handlers/platform-fault-escalation-logic.ts +26 -5
- package/src/handlers/platform-fault-escalation.test.ts +67 -15
- package/src/handlers/platform-fault-escalation.ts +16 -0
- package/src/handlers/projects-digest.ts +1 -1
- package/src/handlers/scala-tools-currency-watch.test.ts +1 -5
- package/src/handlers/scala-tools-currency-watch.ts +23 -32
- package/src/handlers/singleton-tools-currency-watch.test.ts +1 -5
- package/src/handlers/singleton-tools-currency-watch.ts +27 -19
- package/src/handlers/upstream-issue-sync.ts +11 -4
- package/src/handlers/whatsapp-liveness.ts +14 -4
- package/src/install-harness.ts +1 -1
- package/src/install-logging.ts +1 -1
- package/src/mount-orpc-router.test.ts +56 -10
- package/src/mount-orpc-router.ts +23 -9
- package/src/mount-server-packages.ts +17 -15
- package/src/pg-boss-mount.ts +5 -12
- package/src/proposal-gate.ts +7 -2
- package/src/read-html-template.test.ts +12 -15
- package/src/read-html-template.ts +2 -1
- package/src/readiness-gate.ts +2 -1
- package/src/register-crash-handlers.ts +4 -8
- package/src/register-page-server-client.ts +15 -19
- package/src/register-registry-page-route/ai-chat-prefetch.ts +16 -12
- package/src/register-registry-page-route/apply-page-branding.ts +68 -56
- package/src/register-registry-page-route/graph-cache.ts +22 -18
- package/src/register-registry-page-route/meta-title.ts +24 -20
- package/src/register-registry-page-route/prefetch-org-id.ts +6 -2
- package/src/register-registry-page-route/proposal-approval-email-token.ts +5 -3
- package/src/register-registry-page-route/run-content-gates.ts +2 -4
- package/src/register-registry-page-route.ts +1 -2
- package/src/register-static-assets.ts +6 -5
- package/src/resolve-app-base-url.ts +12 -5
- package/src/resolvers/portal-route-paths.ts +15 -11
- package/src/resource-chat-inject.ts +84 -72
- package/src/resource-not-found.ts +23 -19
- package/src/scala-tools-currency-watchdog.ts +41 -12
- package/src/two-phase-full-app.ts +61 -29
- package/src/two-phase-minimal-app.ts +39 -31
- package/src/two-phase-readiness.ts +50 -38
- package/src/ui-service.ts +50 -38
- package/src/handlers/pane-outbox-drain.ts +0 -39
- 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:
|
|
53
|
-
prisma:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
26
|
-
prisma:
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
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 ===
|
|
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 =
|
|
110
|
-
const organisationId =
|
|
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(
|
|
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
|
|
97
|
-
.map((m
|
|
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
|
|
95
|
-
candidates.find((c
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
12
|
+
import type { PrismaClient } from "@teamscala/db/client";
|
|
13
|
+
|
|
13
14
|
export async function assertOwns(
|
|
14
|
-
prisma:
|
|
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
|
-
|
|
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:
|
|
48
|
-
prisma:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
37
|
+
resolveUser?: (headers: Headers, prisma: PrismaClient) => Promise<User | null>;
|
|
37
38
|
// prisma client type is service-injected
|
|
38
|
-
prisma:
|
|
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:
|
|
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:
|
|
51
|
+
prisma: PrismaClient;
|
|
51
52
|
headers: Headers;
|
|
52
|
-
resolveUser?: (headers: Headers, prisma:
|
|
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:
|
|
31
|
+
prisma: PrismaClient;
|
|
31
32
|
headers: Headers;
|
|
32
|
-
resolveUser?: (headers: Headers, prisma:
|
|
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:
|
|
19
|
+
prisma: PrismaClient;
|
|
19
20
|
headers: Headers;
|
|
20
|
-
resolveUser?: (headers: Headers, prisma:
|
|
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
|
-
//
|
|
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:
|
|
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
|
|
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:
|
|
197
|
-
.map((m:
|
|
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:
|
|
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
|
|
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
|
|
28
|
-
* `@teamscala/db` model
|
|
29
|
-
*
|
|
30
|
-
* declares
|
|
31
|
-
*
|
|
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(
|
|
103
|
+
await notifyDataChange(organisationId, "upsert");
|
|
113
104
|
return { success: true };
|
|
114
105
|
} catch (err) {
|
|
115
106
|
log.error(
|