@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.
- package/package.json +1016 -0
- package/src/agreement-gate.test.ts +118 -0
- package/src/agreement-gate.ts +887 -0
- package/src/asset-cache-key.ts +59 -0
- package/src/asset-optimize.ts +93 -0
- package/src/asset-proxy.ts +148 -0
- package/src/asset-social-card.ts +58 -0
- package/src/attach-agreement-magic.ts +62 -0
- package/src/attach-portal-members.ts +107 -0
- package/src/attach-proposal-accept.ts +123 -0
- package/src/attach-proposal-magic.ts +55 -0
- package/src/attach-transfers-upload.ts +100 -0
- package/src/auth-gate.ts +385 -0
- package/src/bind-page-handler.ts +223 -0
- package/src/build-client-assets/input-hashing.ts +206 -0
- package/src/build-client-assets/source-candidates.ts +115 -0
- package/src/build-client-assets.test.ts +216 -0
- package/src/build-client-assets.ts +962 -0
- package/src/compose-page-title.test.ts +88 -0
- package/src/compose-page-title.ts +73 -0
- package/src/content-gate/access.ts +89 -0
- package/src/content-gate/config-types.ts +108 -0
- package/src/content-gate/gate.ts +74 -0
- package/src/content-gate/parse-cookie.ts +23 -0
- package/src/content-gate/token.test.ts +70 -0
- package/src/content-gate/token.ts +148 -0
- package/src/content-page-floor.test.ts +127 -0
- package/src/content-page-floor.ts +70 -0
- package/src/discover-blocks.ts +228 -0
- package/src/expand-roster.ts +197 -0
- package/src/fn-routers/agreement-completion-email.ts +115 -0
- package/src/fn-routers/agreement-send-router.ts +303 -0
- package/src/fn-routers/ai-health-router.ts +64 -0
- package/src/fn-routers/auth-router.ts +131 -0
- package/src/fn-routers/cache-admin-router.ts +50 -0
- package/src/fn-routers/chat-session-router.ts +455 -0
- package/src/fn-routers/email-defaults.ts +22 -0
- package/src/fn-routers/email-link-ttl.ts +23 -0
- package/src/fn-routers/index.ts +70 -0
- package/src/fn-routers/panel-data-router.ts +93 -0
- package/src/fn-routers/persist-chat-messages.test.ts +86 -0
- package/src/fn-routers/persist-chat-messages.ts +96 -0
- package/src/fn-routers/plans-router.ts +141 -0
- package/src/fn-routers/proposal-send-router.ts +207 -0
- package/src/fn-routers/public-agreement-router.ts +865 -0
- package/src/fn-routers/public-assessment-router.ts +421 -0
- package/src/fn-routers/public-gpt-session-router.ts +373 -0
- package/src/fn-routers/sign-in-flow-router.ts +196 -0
- package/src/fn-routers/task-sync-bind.test.ts +105 -0
- package/src/fn-routers/task-sync-classify.test.ts +109 -0
- package/src/fn-routers/task-sync-dispatch.test.ts +44 -0
- package/src/fn-routers/task-sync-dispatch.ts +124 -0
- package/src/fn-routers/task-sync-router.test.ts +279 -0
- package/src/fn-routers/task-sync-router.ts +1036 -0
- package/src/fn-routers/tasks-router.ts +77 -0
- package/src/fn-routers/user-preferences-router.ts +139 -0
- package/src/fn-routers/work-items-router.ts +341 -0
- package/src/gate-ttls.ts +56 -0
- package/src/gpt-gate.ts +76 -0
- package/src/handlers/bun-stable-watch.ts +89 -0
- package/src/handlers/projects-digest.ts +198 -0
- package/src/handlers/prune-stale-data.ts +110 -0
- package/src/handlers/registry-stale-inactive-sweep.ts +82 -0
- package/src/handlers/scala-tools-currency-watch.test.ts +60 -0
- package/src/handlers/scala-tools-currency-watch.ts +171 -0
- package/src/handlers/scheduled-browser-tests.ts +85 -0
- package/src/handlers/sweep-event-log.ts +98 -0
- package/src/handlers/sweep-host-cache.ts +211 -0
- package/src/handlers/upstream-issue-sync.ts +210 -0
- package/src/handlers/whatsapp-liveness-handler.test.ts +59 -0
- package/src/handlers/whatsapp-liveness-logic.ts +53 -0
- package/src/handlers/whatsapp-liveness.test.ts +78 -0
- package/src/handlers/whatsapp-liveness.ts +125 -0
- package/src/hono-error-handler.ts +55 -0
- package/src/install-harness.ts +80 -0
- package/src/install-logging.ts +182 -0
- package/src/load-service-install-row.ts +98 -0
- package/src/load-service-prisma-client.ts +53 -0
- package/src/mount-orpc-router.ts +148 -0
- package/src/mount-server-packages.ts +155 -0
- package/src/mount.ts +18 -0
- package/src/mutable-app-ref.ts +68 -0
- package/src/page-route-shell-types.ts +16 -0
- package/src/page-server-orpc/audit-revert-router.ts +102 -0
- package/src/page-server-orpc/hub-mcp-client.ts +20 -0
- package/src/page-server-orpc/portal-diagnostics-router.ts +170 -0
- package/src/page-server-orpc/puck-pages-router.ts +272 -0
- package/src/page-server-orpc/server-client.ts +31 -0
- package/src/page-server-orpc/templates-router.ts +116 -0
- package/src/page-server-orpc/user-preferences-router.ts +132 -0
- package/src/pg-boss-handler-registry.ts +36 -0
- package/src/pg-boss-mount.ts +295 -0
- package/src/proposal-authorized-emails.ts +65 -0
- package/src/proposal-chat-inject.ts +141 -0
- package/src/proposal-gate.ts +175 -0
- package/src/read-html-template.test.ts +74 -0
- package/src/read-html-template.ts +102 -0
- package/src/read-viewport-from-request.ts +36 -0
- package/src/readiness-gate.ts +39 -0
- package/src/register-api-routes.ts +127 -0
- package/src/register-crash-handlers.ts +39 -0
- package/src/register-page-routes.ts +62 -0
- package/src/register-page-server-client.ts +109 -0
- package/src/register-registry-page-route/ai-chat-prefetch.ts +106 -0
- package/src/register-registry-page-route/graph-cache.test.ts +84 -0
- package/src/register-registry-page-route/graph-cache.ts +90 -0
- package/src/register-registry-page-route/meta-title.ts +73 -0
- package/src/register-registry-page-route/proposal-approval-email-token.ts +78 -0
- package/src/register-registry-page-route/proposal-brand-stamping.ts +60 -0
- package/src/register-registry-page-route/resolve-page-data.ts +518 -0
- package/src/register-registry-page-route.ts +765 -0
- package/src/register-static-assets.ts +180 -0
- package/src/register-ws-routes.ts +180 -0
- package/src/resolve-app-base-url.ts +28 -0
- package/src/resolve-brand-favicon.ts +65 -0
- package/src/resolve-from-loader-config.test.ts +44 -0
- package/src/resolve-from-loader-config.ts +291 -0
- package/src/resolve-service-context.ts +67 -0
- package/src/resolve-theme-mode.ts +61 -0
- package/src/resolvers/fetch-org-brand.ts +28 -0
- package/src/resolvers/portal-route-paths.test.ts +102 -0
- package/src/resolvers/portal-route-paths.ts +83 -0
- package/src/resolvers/website-by-host-map.ts +49 -0
- package/src/resource-chat-inject.ts +168 -0
- package/src/resource-not-found.ts +263 -0
- package/src/run-boot-hook-list.ts +75 -0
- package/src/run-boot-hooks.ts +97 -0
- package/src/scala-tools-currency-watchdog.ts +160 -0
- package/src/security-headers.ts +59 -0
- package/src/serve/serve-ws.test.ts +135 -0
- package/src/serve/serve.ts +190 -0
- package/src/server-error-page.test.ts +102 -0
- package/src/server-error-page.ts +95 -0
- package/src/start-scheduled-jobs.ts +84 -0
- package/src/two-phase-boot.ts +449 -0
- package/src/types.ts +52 -0
- package/src/ui-service.ts +171 -0
- package/src/ws-boot-self-test.ts +114 -0
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Public ORPC procedure for submitting a public assessment (quiz) response.
|
|
7
|
+
* Mirrors public-gpt-session-router: a public, system-trusted write path for an
|
|
8
|
+
* anonymous/guest visitor — find-or-create the contact in client_database, score
|
|
9
|
+
* the answers, and persist an assessment_responses row. This is the submit path
|
|
10
|
+
* the deleted public-assessment wrapper used to provide via an injected onSubmit;
|
|
11
|
+
* recovering it here brings the quiz in line with the shared machinery (the GPT's
|
|
12
|
+
* publicGptSession is the sibling pattern) instead of a per-page bespoke handler.
|
|
13
|
+
*
|
|
14
|
+
* Scoring (recovered from live data, 2026-06-13): each scale answer contributes
|
|
15
|
+
* (scaleValue - 1) earned out of 4 max (1–5 scale → 0–4); choice answers
|
|
16
|
+
* contribute the selected option's points out of the question's max option
|
|
17
|
+
* points. A category's score is round(100 * earned / max) over that category's
|
|
18
|
+
* questions; the overall total is the same over all questions. Unanswered
|
|
19
|
+
* questions count toward the denominator (contribute 0) — matches the historical
|
|
20
|
+
* rows (e.g. Σ(v-1)=78 over 36 questions → 78/144 = 54).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { os } from "@orpc/server";
|
|
24
|
+
import * as v from "valibot";
|
|
25
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
26
|
+
import { sendEmail, initResendClient } from "@teamscala/api-resend/send-email";
|
|
27
|
+
import { applyTemplateVars, lookupTemplate } from "@teamscala/email/templates/lookup";
|
|
28
|
+
import { decryptSecretConfig } from "@teamscala/encryption/crypto/secret-config";
|
|
29
|
+
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
30
|
+
import { recordEvent } from "@teamscala/event-log/record-event";
|
|
31
|
+
import { getDefaultEmailSender } from "./email-defaults.ts";
|
|
32
|
+
|
|
33
|
+
interface Context {
|
|
34
|
+
prisma: any;
|
|
35
|
+
headers: Headers;
|
|
36
|
+
resolveUser?: (headers: Headers, prisma: any) => Promise<any>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const pub = os.$context<Context>();
|
|
40
|
+
|
|
41
|
+
const submitSchema = v.object({
|
|
42
|
+
assessmentId: v.string(),
|
|
43
|
+
firstName: v.string(),
|
|
44
|
+
email: v.string(),
|
|
45
|
+
company: v.optional(v.string()),
|
|
46
|
+
phone: v.optional(v.string()),
|
|
47
|
+
website: v.optional(v.string()),
|
|
48
|
+
industry: v.optional(v.string()),
|
|
49
|
+
teamSize: v.optional(v.string()),
|
|
50
|
+
revenueRange: v.optional(v.string()),
|
|
51
|
+
answers: v.array(
|
|
52
|
+
v.object({
|
|
53
|
+
questionId: v.string(),
|
|
54
|
+
scaleValue: v.optional(v.number()),
|
|
55
|
+
textValue: v.optional(v.string()),
|
|
56
|
+
choiceValue: v.optional(v.string()),
|
|
57
|
+
choiceValues: v.optional(v.array(v.string())),
|
|
58
|
+
}),
|
|
59
|
+
),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
interface StoredQuestion {
|
|
63
|
+
id: string;
|
|
64
|
+
category?: string | null;
|
|
65
|
+
type?: string | null;
|
|
66
|
+
weight?: number | null;
|
|
67
|
+
options?: Array<{ value: string; points: number }> | null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Scale answers are 1–5; earned = value-1, max = 4. */
|
|
71
|
+
const SCALE_MAX = 4;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Bands are DATA, not code: each assessment that wants score-bands declares them
|
|
75
|
+
* in a `config` registry row named `<assessment-slug>-bands` (shape:
|
|
76
|
+
* `{bands: [{max, slug, label}, …]}`). An assessment with no such row is scored
|
|
77
|
+
* but not banded — so the Authority Index bands without affecting other quizzes.
|
|
78
|
+
* The band NARRATIVE is generated by the Strategist agent from its system prompt.
|
|
79
|
+
*/
|
|
80
|
+
const bandsRowSchema = v.looseObject({
|
|
81
|
+
bands: v.array(
|
|
82
|
+
v.object({ max: v.number(), slug: v.string(), label: v.string() }),
|
|
83
|
+
),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
async function getBands(
|
|
87
|
+
assessmentId: string | null | undefined,
|
|
88
|
+
): Promise<Array<{ max: number; slug: string; label: string }> | null> {
|
|
89
|
+
if (!assessmentId) return null;
|
|
90
|
+
const row = await loadRegistryConfig(
|
|
91
|
+
"config",
|
|
92
|
+
`${assessmentId}-bands`,
|
|
93
|
+
bandsRowSchema,
|
|
94
|
+
).catch(() => null);
|
|
95
|
+
if (!row || !Array.isArray(row.bands) || row.bands.length === 0) return null;
|
|
96
|
+
return row.bands;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function bandForScore(
|
|
100
|
+
total: number,
|
|
101
|
+
bands: Array<{ max: number; slug: string; label: string }>,
|
|
102
|
+
): { slug: string; label: string } {
|
|
103
|
+
return bands.find((b) => total <= b.max) ?? bands[bands.length - 1];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Best-effort branded Authority Index Report email. Self-contained: loads its
|
|
109
|
+
* own Resend secret, swallows every error — a send failure is logged and NEVER
|
|
110
|
+
* undoes the submit (the agreement-completion-email pattern). Fire-and-forget
|
|
111
|
+
* from the submit handler.
|
|
112
|
+
*/
|
|
113
|
+
async function sendAuthorityReportEmail(opts: {
|
|
114
|
+
to: string;
|
|
115
|
+
firstName: string;
|
|
116
|
+
totalScore: number;
|
|
117
|
+
bandLabel: string;
|
|
118
|
+
categoryScores: Record<string, number>;
|
|
119
|
+
ctaUrl?: string;
|
|
120
|
+
}): Promise<void> {
|
|
121
|
+
const log = getAppLogger();
|
|
122
|
+
const rawSecret = await loadRegistryConfig(
|
|
123
|
+
"secret",
|
|
124
|
+
"resend-api-key",
|
|
125
|
+
v.looseObject({ token: v.optional(v.string()) }),
|
|
126
|
+
).catch(() => null);
|
|
127
|
+
const decrypted = rawSecret
|
|
128
|
+
? (decryptSecretConfig(rawSecret as never) as { token?: string })
|
|
129
|
+
: null;
|
|
130
|
+
if (!decrypted?.token) {
|
|
131
|
+
log.warn("[authority-report-email] Resend token unavailable — skipping");
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
initResendClient(decrypted.token);
|
|
135
|
+
const from = await getDefaultEmailSender();
|
|
136
|
+
// Subject + body from the `authority-index-report` email_template row, not
|
|
137
|
+
// literals here (email-template-registry-type). The per-pillar rows and the
|
|
138
|
+
// optional CTA are the only DYNAMIC markup, so they are rendered here and
|
|
139
|
+
// passed as merge vars — the row still owns all the copy and the layout.
|
|
140
|
+
const pillarRows = (
|
|
141
|
+
Object.entries(opts.categoryScores) as Array<[string, number]>
|
|
142
|
+
)
|
|
143
|
+
.map(
|
|
144
|
+
([name, score]) =>
|
|
145
|
+
`<tr><td style="padding:6px 0;color:#374151">${name}</td><td style="padding:6px 0;text-align:right;font-weight:600;color:#111827">${score} / 100</td></tr>`,
|
|
146
|
+
)
|
|
147
|
+
.join("");
|
|
148
|
+
const pillarsText = (
|
|
149
|
+
Object.entries(opts.categoryScores) as Array<[string, number]>
|
|
150
|
+
)
|
|
151
|
+
.map(([name, score]) => `${name}: ${score} / 100`)
|
|
152
|
+
.join("\n");
|
|
153
|
+
const tpl = applyTemplateVars(await lookupTemplate("authority-index-report"), {
|
|
154
|
+
firstName: opts.firstName || "there",
|
|
155
|
+
totalScore: String(opts.totalScore),
|
|
156
|
+
bandLabel: opts.bandLabel,
|
|
157
|
+
pillarRows,
|
|
158
|
+
pillarsText,
|
|
159
|
+
ctaHtml: opts.ctaUrl
|
|
160
|
+
? `<p style="margin:24px 0 0"><a href="${opts.ctaUrl}" style="display:inline-block;background:#111827;color:#ffffff;text-decoration:none;font-weight:600;padding:12px 22px;border-radius:8px">Book your Authority Growth Session</a></p>`
|
|
161
|
+
: "",
|
|
162
|
+
ctaText: opts.ctaUrl ? `\nBook your Authority Growth Session: ${opts.ctaUrl}` : "",
|
|
163
|
+
});
|
|
164
|
+
const res = await sendEmail({
|
|
165
|
+
from,
|
|
166
|
+
to: opts.to,
|
|
167
|
+
subject: tpl.subject,
|
|
168
|
+
html: tpl.bodyHtml,
|
|
169
|
+
text: tpl.bodyText,
|
|
170
|
+
});
|
|
171
|
+
if (res.success) {
|
|
172
|
+
log.info(
|
|
173
|
+
`[authority-report-email] sent to ${opts.to}${res.id ? ` (id ${res.id})` : ""}`,
|
|
174
|
+
);
|
|
175
|
+
} else {
|
|
176
|
+
log.error(
|
|
177
|
+
`[authority-report-email] failed to ${opts.to}: ${res.error ?? "unknown"}`,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function scoreAnswers(
|
|
183
|
+
questions: StoredQuestion[],
|
|
184
|
+
answers: Array<{
|
|
185
|
+
questionId: string;
|
|
186
|
+
scaleValue?: number;
|
|
187
|
+
choiceValue?: string;
|
|
188
|
+
choiceValues?: string[];
|
|
189
|
+
}>,
|
|
190
|
+
): { totalScore: number; categoryScores: Record<string, number> } {
|
|
191
|
+
const answerByQ = new Map(answers.map((a) => [a.questionId, a]));
|
|
192
|
+
// Per-category and overall earned/max accumulators.
|
|
193
|
+
const cat = new Map<string, { earned: number; max: number }>();
|
|
194
|
+
let totalEarned = 0;
|
|
195
|
+
let totalMax = 0;
|
|
196
|
+
|
|
197
|
+
for (const q of questions) {
|
|
198
|
+
if (q.type === "text") continue; // text questions are qualitative — never scored
|
|
199
|
+
const category = q.category || "General";
|
|
200
|
+
const a = answerByQ.get(q.id);
|
|
201
|
+
let earned = 0;
|
|
202
|
+
let max = 0;
|
|
203
|
+
|
|
204
|
+
if (q.options && q.options.length > 0) {
|
|
205
|
+
// Choice question: max is the highest single option's points; earned is
|
|
206
|
+
// the selected option(s)' points (capped at max).
|
|
207
|
+
max = Math.max(0, ...q.options.map((o) => o.points || 0));
|
|
208
|
+
if (a?.choiceValue) {
|
|
209
|
+
earned = q.options.find((o) => o.value === a.choiceValue)?.points ?? 0;
|
|
210
|
+
} else if (a?.choiceValues?.length) {
|
|
211
|
+
earned = Math.min(
|
|
212
|
+
max,
|
|
213
|
+
a.choiceValues.reduce(
|
|
214
|
+
(s, val) =>
|
|
215
|
+
s + (q.options?.find((o) => o.value === val)?.points ?? 0),
|
|
216
|
+
0,
|
|
217
|
+
),
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
} else {
|
|
221
|
+
// Scale question (the common case): 1–5 → earned 0–4.
|
|
222
|
+
max = SCALE_MAX;
|
|
223
|
+
if (typeof a?.scaleValue === "number") {
|
|
224
|
+
earned = Math.max(0, Math.min(SCALE_MAX, a.scaleValue - 1));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (max <= 0) continue; // text/unscored questions don't affect the score
|
|
229
|
+
const entry = cat.get(category) ?? { earned: 0, max: 0 };
|
|
230
|
+
entry.earned += earned;
|
|
231
|
+
entry.max += max;
|
|
232
|
+
cat.set(category, entry);
|
|
233
|
+
totalEarned += earned;
|
|
234
|
+
totalMax += max;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const categoryScores: Record<string, number> = {};
|
|
238
|
+
for (const [name, { earned, max }] of cat) {
|
|
239
|
+
categoryScores[name] = max > 0 ? Math.round((100 * earned) / max) : 0;
|
|
240
|
+
}
|
|
241
|
+
const totalScore = totalMax > 0 ? Math.round((100 * totalEarned) / totalMax) : 0;
|
|
242
|
+
return { totalScore, categoryScores };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export const publicAssessmentRouter = {
|
|
246
|
+
submit: pub.input(submitSchema).handler(async ({ input, context }) => {
|
|
247
|
+
const prisma = context.prisma;
|
|
248
|
+
const log = getAppLogger();
|
|
249
|
+
const email = (input.email ?? "").trim().toLowerCase();
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
const assessment = await prisma.assessments.findUnique({
|
|
253
|
+
where: { id: input.assessmentId },
|
|
254
|
+
select: {
|
|
255
|
+
id: true,
|
|
256
|
+
organisation_id: true,
|
|
257
|
+
questions: true,
|
|
258
|
+
cta_url: true,
|
|
259
|
+
slug: true,
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
if (!assessment) return { error: "Assessment not found" } as const;
|
|
263
|
+
|
|
264
|
+
const questions = (Array.isArray(assessment.questions)
|
|
265
|
+
? assessment.questions
|
|
266
|
+
: []) as StoredQuestion[];
|
|
267
|
+
const { totalScore, categoryScores } = scoreAnswers(
|
|
268
|
+
questions,
|
|
269
|
+
input.answers,
|
|
270
|
+
);
|
|
271
|
+
const bands = await getBands(assessment.id);
|
|
272
|
+
const band = bands ? bandForScore(totalScore, bands) : null;
|
|
273
|
+
|
|
274
|
+
// Find-or-create the contact in client_database (the single contact
|
|
275
|
+
// store) — same shape as publicGptSession's gate. OPTIONAL: only
|
|
276
|
+
// services whose prisma client includes client_database (e.g. the GPT)
|
|
277
|
+
// link a CRM contact here. The quiz service's client is scoped without
|
|
278
|
+
// it, so skip gracefully — the assessment_responses row below already
|
|
279
|
+
// captures name/last_name/email/company, matching the historical quiz
|
|
280
|
+
// (which never wrote CRM contacts either).
|
|
281
|
+
const nameParts = (input.firstName ?? "").trim().split(/\s+/);
|
|
282
|
+
const firstName = nameParts[0] || "Unknown";
|
|
283
|
+
const lastName = nameParts.slice(1).join(" ") || "-";
|
|
284
|
+
const orgId = assessment.organisation_id;
|
|
285
|
+
const bandTag = band ? `authority:${band.slug}` : null;
|
|
286
|
+
const firmographicTags = [
|
|
287
|
+
input.industry ? `industry:${input.industry}` : null,
|
|
288
|
+
input.teamSize ? `team-size:${input.teamSize}` : null,
|
|
289
|
+
input.revenueRange ? `revenue:${input.revenueRange}` : null,
|
|
290
|
+
].filter((t): t is string => t !== null);
|
|
291
|
+
const assignedTags = [bandTag, ...firmographicTags].filter(
|
|
292
|
+
(t): t is string => t !== null,
|
|
293
|
+
);
|
|
294
|
+
const assignedPrefixes = [
|
|
295
|
+
"authority:",
|
|
296
|
+
"industry:",
|
|
297
|
+
"team-size:",
|
|
298
|
+
"revenue:",
|
|
299
|
+
];
|
|
300
|
+
if (orgId && prisma.client_database) {
|
|
301
|
+
const existing = await prisma.client_database.findFirst({
|
|
302
|
+
where: { organisation_id: orgId, primary_email: email },
|
|
303
|
+
select: { id: true, tags: true },
|
|
304
|
+
});
|
|
305
|
+
const websiteData = input.website
|
|
306
|
+
? { company_website: input.website }
|
|
307
|
+
: {};
|
|
308
|
+
if (existing) {
|
|
309
|
+
// Replace any prior assessment-assigned tags with the new set.
|
|
310
|
+
const nextTags = [
|
|
311
|
+
...(existing.tags ?? []).filter(
|
|
312
|
+
(t: string) => !assignedPrefixes.some((p) => t.startsWith(p)),
|
|
313
|
+
),
|
|
314
|
+
...assignedTags,
|
|
315
|
+
];
|
|
316
|
+
await prisma.client_database.update({
|
|
317
|
+
where: { id: existing.id },
|
|
318
|
+
data: {
|
|
319
|
+
first_name: firstName,
|
|
320
|
+
last_name: lastName,
|
|
321
|
+
...(input.phone ? { mobile_phone: input.phone } : {}),
|
|
322
|
+
...(input.company ? { company_name: input.company } : {}),
|
|
323
|
+
...websiteData,
|
|
324
|
+
tags: nextTags,
|
|
325
|
+
last_updated_by: "assessment-gate",
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
} else {
|
|
329
|
+
await prisma.client_database.create({
|
|
330
|
+
data: {
|
|
331
|
+
organisation_id: orgId,
|
|
332
|
+
first_name: firstName,
|
|
333
|
+
last_name: lastName,
|
|
334
|
+
primary_email: email,
|
|
335
|
+
mobile_phone: input.phone ?? null,
|
|
336
|
+
company_name: input.company ?? null,
|
|
337
|
+
...websiteData,
|
|
338
|
+
lead_source: "assessment-gate",
|
|
339
|
+
data_source: "public-form",
|
|
340
|
+
referral_source: input.assessmentId,
|
|
341
|
+
lifecycle_stage: "lead",
|
|
342
|
+
tags: assignedTags,
|
|
343
|
+
created_by: "assessment-gate",
|
|
344
|
+
last_updated_by: "assessment-gate",
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const response = await prisma.assessment_responses.create({
|
|
351
|
+
data: {
|
|
352
|
+
assessment_id: input.assessmentId,
|
|
353
|
+
email,
|
|
354
|
+
first_name: firstName,
|
|
355
|
+
last_name: lastName,
|
|
356
|
+
company: input.company ?? null,
|
|
357
|
+
total_score: totalScore,
|
|
358
|
+
category_scores: categoryScores,
|
|
359
|
+
answers: input.answers.map((a) => ({
|
|
360
|
+
question_id: a.questionId,
|
|
361
|
+
scale_value: a.scaleValue ?? null,
|
|
362
|
+
text_value: a.textValue ?? null,
|
|
363
|
+
choice_value: a.choiceValue ?? null,
|
|
364
|
+
choice_values: a.choiceValues ?? null,
|
|
365
|
+
})),
|
|
366
|
+
organisation_id: orgId,
|
|
367
|
+
completed_at: new Date(),
|
|
368
|
+
},
|
|
369
|
+
select: { id: true },
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
// Detached post-submit automation: branded report email + sales notify.
|
|
373
|
+
// Failures are logged and never undo the submit.
|
|
374
|
+
const ctaUrl =
|
|
375
|
+
(typeof assessment.cta_url === "string" && assessment.cta_url) ||
|
|
376
|
+
undefined;
|
|
377
|
+
if (band) {
|
|
378
|
+
void sendAuthorityReportEmail({
|
|
379
|
+
to: email,
|
|
380
|
+
firstName,
|
|
381
|
+
totalScore,
|
|
382
|
+
bandLabel: band.label,
|
|
383
|
+
categoryScores,
|
|
384
|
+
ctaUrl,
|
|
385
|
+
}).catch((e) =>
|
|
386
|
+
log.error(
|
|
387
|
+
`[publicAssessment.submit] report email non-fatal: ${e instanceof Error ? e.message : String(e)}`,
|
|
388
|
+
),
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
if (orgId) {
|
|
392
|
+
recordEvent({
|
|
393
|
+
kind: "assessment.authority.completed",
|
|
394
|
+
payload: {
|
|
395
|
+
assessment_id: input.assessmentId,
|
|
396
|
+
response_id: response.id,
|
|
397
|
+
email,
|
|
398
|
+
organisation_id: orgId,
|
|
399
|
+
total_score: totalScore,
|
|
400
|
+
band: band?.slug ?? null,
|
|
401
|
+
category_scores: categoryScores,
|
|
402
|
+
},
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return {
|
|
407
|
+
success: true as const,
|
|
408
|
+
responseId: response.id,
|
|
409
|
+
reportUrl: `/report/${response.id}`,
|
|
410
|
+
totalScore,
|
|
411
|
+
categoryScores,
|
|
412
|
+
...(band ? { band: band.label } : {}),
|
|
413
|
+
};
|
|
414
|
+
} catch (err) {
|
|
415
|
+
log.error(
|
|
416
|
+
`[publicAssessment.submit] failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
417
|
+
);
|
|
418
|
+
throw err;
|
|
419
|
+
}
|
|
420
|
+
}),
|
|
421
|
+
};
|