@teamscala/service-boot 0.23.421 → 0.23.426
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 +9 -2
- package/src/build-client-assets/input-hashing.ts +1 -1
- package/src/fn-routers/assessment-report-data.test.ts +404 -0
- package/src/fn-routers/assessment-report-data.ts +423 -0
- package/src/fn-routers/assessment-report-email.ts +2 -2
- package/src/fn-routers/chat-session-router-shared.ts +4 -21
- package/src/fn-routers/chat-session-router.ts +1 -1
- package/src/fn-routers/conversation-ownership.ts +23 -0
- package/src/fn-routers/index.ts +1 -1
- package/src/fn-routers/public-agreement-router.ts +1 -1
- package/src/fn-routers/public-assessment-router.ts +2 -2
- package/src/fn-routers/public-gpt-session-router.ts +1 -1
- package/src/handlers/platform-fault-escalation/config.ts +16 -0
- package/src/handlers/platform-fault-escalation/delivery.ts +75 -1
- package/src/handlers/platform-fault-escalation/event-log-queries.ts +40 -0
- package/src/handlers/platform-fault-escalation-logic.ts +83 -4
- package/src/handlers/platform-fault-escalation.test.ts +68 -1
- package/src/handlers/platform-fault-escalation.ts +47 -3
- package/src/page-server-orpc/audit-revert-router.ts +1 -1
- package/src/pg-boss-mount.ts +1 -1
- package/src/resolve-from-loader-config.ts +1 -1
- package/src/two-phase-readiness.ts +2 -1
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten @indivisible-unit owner=lane/idle-pools-hold-the-ceiling reason="one report-shaping unit: band selection, narrative and chart shaping change together"
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* report-data.ts — quiz-results → report data generation.
|
|
7
|
+
*
|
|
8
|
+
* Shapes a stored assessment response into the data the report puck_page
|
|
9
|
+
|
|
10
|
+
* renders. The response already carries total_score + category_scores
|
|
11
|
+
* (computed at submit time), so this is pure shaping — no re-scoring and no
|
|
12
|
+
* authored copy. The `computeReportData` resolver_fn (a resource_loader
|
|
13
|
+
* source, key `reportData`) runs this at SSR; the report blocks bind its
|
|
14
|
+
* fields through the unified page-data surface (`dataSource:"page-data"` +
|
|
15
|
+
* `fieldPath:"reportData.<key>"`).
|
|
16
|
+
*
|
|
17
|
+
* @see scala-docs/curated/apps/messaging.md is unrelated; the stage model
|
|
18
|
+
* lives on `assessments.stage_bands` (migration 560/561).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** A chart data point — structural match for @teamscala/chart-block's ChartPoint. */
|
|
22
|
+
export interface ReportChartPoint {
|
|
23
|
+
label: string;
|
|
24
|
+
value: number;
|
|
25
|
+
color?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A derived pillar position for the Positioning card (label + advisory summary). */
|
|
29
|
+
export interface PillarPosition {
|
|
30
|
+
label: string;
|
|
31
|
+
summary: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** One roadmap phase — a focus word plus its actions. */
|
|
35
|
+
export interface RoadmapPhase {
|
|
36
|
+
focus: string;
|
|
37
|
+
actions: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A list item carrying its own ordinal. The number lives WITH the item so a
|
|
42
|
+
* fixed-slot layout cannot print a badge for an item that does not exist: a
|
|
43
|
+
* stage authoring four strengths into six slots leaves slots five and six with
|
|
44
|
+
* no number and no text, rather than a bare numeral beside empty space. Numbers
|
|
45
|
+
* spelled in the page instead would be a second copy of the list's length.
|
|
46
|
+
*/
|
|
47
|
+
export interface NumberedItem {
|
|
48
|
+
index: string;
|
|
49
|
+
text: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A titled action with its explanatory line. */
|
|
53
|
+
export interface TitledAction {
|
|
54
|
+
title: string;
|
|
55
|
+
description: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* One stage band, as authored on `assessments.stage_bands`. Every field is
|
|
60
|
+
* copy the business owns; nothing here is computed. `max` is the inclusive
|
|
61
|
+
* upper bound of the band — the final band omits it and catches the rest.
|
|
62
|
+
*/
|
|
63
|
+
export interface StageBand {
|
|
64
|
+
name: string;
|
|
65
|
+
max?: number;
|
|
66
|
+
tagline?: string;
|
|
67
|
+
executiveSummary?: string;
|
|
68
|
+
strongestAreas?: string[];
|
|
69
|
+
priorityActions?: TitledAction[];
|
|
70
|
+
strategicFocus?: string;
|
|
71
|
+
strengths?: string[];
|
|
72
|
+
opportunities?: string[];
|
|
73
|
+
twelveMonthTarget?: string;
|
|
74
|
+
/**
|
|
75
|
+
* The remaining elements of Anna's five-page template. Every one is COPY,
|
|
76
|
+
* authored per stage like the rest — none can be derived from the response.
|
|
77
|
+
* Wiring them here makes each a row edit rather than a build task: the page
|
|
78
|
+
* binds them and hides the section (`visibleWhenPath`) until a stage fills
|
|
79
|
+
* it in, so Anna can land them one at a time without a deploy.
|
|
80
|
+
*/
|
|
81
|
+
whoYouHelp?: string;
|
|
82
|
+
knownFor?: string;
|
|
83
|
+
insights?: string[];
|
|
84
|
+
contentThemes?: TitledAction[];
|
|
85
|
+
contentRhythm?: string;
|
|
86
|
+
northStar?: string;
|
|
87
|
+
priorityFocus?: string;
|
|
88
|
+
priorityFocusSummary?: string;
|
|
89
|
+
recommendedTier?: string;
|
|
90
|
+
tierSummary?: string;
|
|
91
|
+
whyItFits?: string;
|
|
92
|
+
whatItSolves?: string;
|
|
93
|
+
implementationFocus?: string;
|
|
94
|
+
expectedOutcome?: string;
|
|
95
|
+
finalWord?: string;
|
|
96
|
+
roadmap?: Record<string, RoadmapPhase>;
|
|
97
|
+
pillarNotes?: Record<string, string>;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* One pillar as the report presents it: the authored label and subtitle, this
|
|
102
|
+
* person's score, the stage's note, and the pillar's colour. Held in the
|
|
103
|
+
* assessment's own category order (not sorted by score) because the design
|
|
104
|
+
* shows the three pillars in a fixed sequence with fixed colours.
|
|
105
|
+
*/
|
|
106
|
+
export interface PillarCard {
|
|
107
|
+
label: string;
|
|
108
|
+
subtitle: string;
|
|
109
|
+
value: number;
|
|
110
|
+
max: number;
|
|
111
|
+
note: string;
|
|
112
|
+
color: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Per-category display authored on `assessments.category_descriptions`. */
|
|
116
|
+
export interface CategoryDisplay {
|
|
117
|
+
description?: string;
|
|
118
|
+
subtitle?: string;
|
|
119
|
+
color?: string;
|
|
120
|
+
max?: number;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The shape a report resource_loader stamps into loaderData for the blocks. */
|
|
124
|
+
export interface ReportData {
|
|
125
|
+
/** the three pillars in authored order, for the pillar cards */
|
|
126
|
+
pillarCards: PillarCard[];
|
|
127
|
+
reportScore: ReportChartPoint[];
|
|
128
|
+
reportPillars: ReportChartPoint[];
|
|
129
|
+
narrative: string;
|
|
130
|
+
totalScore: number;
|
|
131
|
+
/** overall band label — the Snapshot stage badge */
|
|
132
|
+
stage: string;
|
|
133
|
+
/** band advice — the Snapshot stage summary */
|
|
134
|
+
stageSummary: string;
|
|
135
|
+
/** highest-scoring pillar — the Positioning "Strongest Pillar" column */
|
|
136
|
+
strongest: PillarPosition | null;
|
|
137
|
+
/** mid pillar — the Positioning "Biggest Opportunity" column */
|
|
138
|
+
opportunity: PillarPosition | null;
|
|
139
|
+
/** lowest pillar — the Positioning "Authority Gap" column */
|
|
140
|
+
authorityGap: PillarPosition | null;
|
|
141
|
+
/** section 4 — what this person already has going for them */
|
|
142
|
+
strongestAreas: string[];
|
|
143
|
+
/** section 6 — the numbered actions for this stage */
|
|
144
|
+
priorityActions: TitledAction[];
|
|
145
|
+
/** section 7 */
|
|
146
|
+
strategicFocus: string;
|
|
147
|
+
/** section 8, left column */
|
|
148
|
+
strengths: string[];
|
|
149
|
+
/** section 8, right column */
|
|
150
|
+
opportunities: string[];
|
|
151
|
+
/** section 8 left, numbered for badge rendering */
|
|
152
|
+
strengthsList: NumberedItem[];
|
|
153
|
+
/** section 8 right, numbered for badge rendering */
|
|
154
|
+
opportunitiesList: NumberedItem[];
|
|
155
|
+
/** section 9 */
|
|
156
|
+
twelveMonthTarget: string;
|
|
157
|
+
/** section 10 — three phases, each a focus word plus four actions */
|
|
158
|
+
roadmap: RoadmapPhase[];
|
|
159
|
+
/** Anna's remaining template elements — empty until a stage authors them. */
|
|
160
|
+
whoYouHelp: string;
|
|
161
|
+
knownFor: string;
|
|
162
|
+
insights: string[];
|
|
163
|
+
contentThemes: TitledAction[];
|
|
164
|
+
contentRhythm: string;
|
|
165
|
+
northStar: string;
|
|
166
|
+
priorityFocus: string;
|
|
167
|
+
priorityFocusSummary: string;
|
|
168
|
+
recommendedTier: string;
|
|
169
|
+
tierSummary: string;
|
|
170
|
+
whyItFits: string;
|
|
171
|
+
whatItSolves: string;
|
|
172
|
+
implementationFocus: string;
|
|
173
|
+
expectedOutcome: string;
|
|
174
|
+
finalWord: string;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Minimal view of the stored response — total_score + category_scores. */
|
|
178
|
+
export interface ReportResponseInput {
|
|
179
|
+
total_score?: number | null;
|
|
180
|
+
category_scores?: Record<string, number> | null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Minimal view of the assessment metadata — title plus the stage model. */
|
|
184
|
+
export interface ReportAssessmentInput {
|
|
185
|
+
title?: string | null;
|
|
186
|
+
stage_bands?: unknown;
|
|
187
|
+
/** authored pillar order, e.g. ["Be Seen", "Be Heard", "Be Chosen"] */
|
|
188
|
+
categories?: unknown;
|
|
189
|
+
/** per-pillar display: subtitle, colour, description */
|
|
190
|
+
category_descriptions?: unknown;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The fallback pillar colour when the assessment authors none. */
|
|
194
|
+
const DEFAULT_PILLAR_COLOR = "var(--color-primary)";
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Match a pillar label to its authored stage note.
|
|
198
|
+
*
|
|
199
|
+
* The stage designs key these notes by the distinctive word — `seen`, `heard`,
|
|
200
|
+
* `chosen` — while the assessment names the pillars "Be Seen", "Be Heard",
|
|
201
|
+
* "Be Chosen". A plain lowercase lookup therefore missed EVERY note, and the
|
|
202
|
+
* positioning cards silently fell back to their generic role lines: present,
|
|
203
|
+
* plausible, and identical for every respondent, so nothing looked broken.
|
|
204
|
+
|
|
205
|
+
* Tries the whole label first, then the last word, so either authoring style
|
|
206
|
+
* resolves and neither has to know about the other.
|
|
207
|
+
*/
|
|
208
|
+
export function pillarNote(band: StageBand | null, label: string): string {
|
|
209
|
+
const notes = band?.pillarNotes;
|
|
210
|
+
if (!notes) return "";
|
|
211
|
+
const whole = label.trim().toLowerCase();
|
|
212
|
+
const lastWord = whole.split(/\s+/).pop() ?? whole;
|
|
213
|
+
return (notes[whole] ?? notes[lastWord] ?? "").trim();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Build the pillar cards in the assessment's OWN category order. Sorting by
|
|
218
|
+
* score is right for "strongest / gap" positioning and wrong here: a reader
|
|
219
|
+
* comparing two reports needs the pillars in the same place every time, and
|
|
220
|
+
* each pillar keeps one colour across every report.
|
|
221
|
+
*/
|
|
222
|
+
export function buildPillarCards(
|
|
223
|
+
assessment: ReportAssessmentInput | null | undefined,
|
|
224
|
+
categoryScores: Record<string, number>,
|
|
225
|
+
band: StageBand | null,
|
|
226
|
+
): PillarCard[] {
|
|
227
|
+
const categories = Array.isArray(assessment?.categories)
|
|
228
|
+
? (assessment.categories as unknown[]).filter(
|
|
229
|
+
(c): c is string => typeof c === "string" && c.length > 0,
|
|
230
|
+
)
|
|
231
|
+
: Object.keys(categoryScores);
|
|
232
|
+
const display = (
|
|
233
|
+
assessment?.category_descriptions && typeof assessment.category_descriptions === "object"
|
|
234
|
+
? assessment.category_descriptions
|
|
235
|
+
: {}
|
|
236
|
+
) as Record<string, CategoryDisplay | string>;
|
|
237
|
+
return categories.map((label) => {
|
|
238
|
+
const raw = display[label];
|
|
239
|
+
const cfg: CategoryDisplay = typeof raw === "string" ? { description: raw } : (raw ?? {});
|
|
240
|
+
const score = Number(categoryScores[label] ?? 0);
|
|
241
|
+
return {
|
|
242
|
+
label,
|
|
243
|
+
subtitle: cfg.subtitle ?? "",
|
|
244
|
+
value: Number.isFinite(score) ? Math.max(0, Math.round(score)) : 0,
|
|
245
|
+
max: typeof cfg.max === "number" && cfg.max > 0 ? cfg.max : 100,
|
|
246
|
+
note: pillarNote(band, label) || cfg.description || "",
|
|
247
|
+
color: cfg.color || DEFAULT_PILLAR_COLOR,
|
|
248
|
+
};
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Read the authored bands off the assessment row. Returns [] when the column
|
|
254
|
+
* is null or malformed, which is the signal to fall back rather than throw —
|
|
255
|
+
* a report that renders a score with no stage copy is degraded; one that 500s
|
|
256
|
+
* because a JSON column is empty is broken.
|
|
257
|
+
*/
|
|
258
|
+
export function readStageBands(assessment?: ReportAssessmentInput | null): StageBand[] {
|
|
259
|
+
const raw = assessment?.stage_bands;
|
|
260
|
+
if (!raw || typeof raw !== "object") return [];
|
|
261
|
+
const bands = (raw as { bands?: unknown }).bands;
|
|
262
|
+
if (!Array.isArray(bands)) return [];
|
|
263
|
+
return bands.filter(
|
|
264
|
+
(band): band is StageBand =>
|
|
265
|
+
!!band && typeof band === "object" && typeof (band as StageBand).name === "string",
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Select the band a score falls in. Bands are ascending by `max`; the first
|
|
271
|
+
* whose `max` the score does not exceed wins, and a band without `max` is the
|
|
272
|
+
* catch-all. Authored order is respected rather than re-sorted, so a
|
|
273
|
+
* mis-ordered row is visible in the output instead of silently corrected.
|
|
274
|
+
*/
|
|
275
|
+
export function selectStageBand(bands: StageBand[], score: number): StageBand | null {
|
|
276
|
+
for (const band of bands) {
|
|
277
|
+
if (typeof band.max !== "number") return band;
|
|
278
|
+
if (score <= band.max) return band;
|
|
279
|
+
}
|
|
280
|
+
return bands.length > 0 ? (bands[bands.length - 1] ?? null) : null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* The narrative is authored per band and carries `{{score}}` where the design
|
|
285
|
+
* spelled a number, so one band serves every score inside it. With no band,
|
|
286
|
+
* the fallback states the score and nothing it cannot support.
|
|
287
|
+
*/
|
|
288
|
+
export function generateNarrative(
|
|
289
|
+
totalScore: number,
|
|
290
|
+
band: StageBand | null,
|
|
291
|
+
assessment?: ReportAssessmentInput | null,
|
|
292
|
+
): string {
|
|
293
|
+
const authored = band?.executiveSummary?.trim();
|
|
294
|
+
if (authored) return authored.replaceAll("{{score}}", String(totalScore));
|
|
295
|
+
const title = assessment?.title?.trim() || "your assessment";
|
|
296
|
+
return `You scored ${totalScore} on ${title}.`;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Pair each item with its 1-based ordinal for badge rendering. */
|
|
300
|
+
export function numberItems(items: string[] | undefined): NumberedItem[] {
|
|
301
|
+
if (!Array.isArray(items)) return [];
|
|
302
|
+
return items
|
|
303
|
+
.filter((text): text is string => typeof text === "string" && text.trim().length > 0)
|
|
304
|
+
.map((text, i) => ({ index: String(i + 1), text: text.trim() }));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Flatten the authored `{month1, month2, month3}` map into render order. */
|
|
308
|
+
export function orderRoadmap(roadmap: Record<string, RoadmapPhase> | undefined): RoadmapPhase[] {
|
|
309
|
+
if (!roadmap || typeof roadmap !== "object") return [];
|
|
310
|
+
return Object.keys(roadmap)
|
|
311
|
+
.sort((a, b) => a.localeCompare(b, "en", { numeric: true }))
|
|
312
|
+
.map((key) => roadmap[key])
|
|
313
|
+
.filter((phase): phase is RoadmapPhase => !!phase && Array.isArray(phase.actions));
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Resolver call shape: the resolver_fn executor invokes every resolver as
|
|
318
|
+
* `fn({ input, client, params })` (see resolveByResolverFn in
|
|
319
|
+
* @teamscala/service-boot/resolve-from-loader-config). computeReportData is
|
|
320
|
+
* registered as the `computeReportData` resolver_fn row, so it receives that
|
|
321
|
+
* context — input.response / input.assessment come from the loader's
|
|
322
|
+
* resolver_input ({response: ':response', assessment: ':assessment'}).
|
|
323
|
+
*/
|
|
324
|
+
export function computeReportData({
|
|
325
|
+
input,
|
|
326
|
+
}: {
|
|
327
|
+
input: {
|
|
328
|
+
response?: ReportResponseInput | null;
|
|
329
|
+
assessment?: ReportAssessmentInput | null;
|
|
330
|
+
};
|
|
331
|
+
}): ReportData {
|
|
332
|
+
const { response, assessment } = input;
|
|
333
|
+
const totalScore = Math.max(0, Math.round(Number(response?.total_score ?? 0)));
|
|
334
|
+
const categoryScores = response?.category_scores ?? {};
|
|
335
|
+
const reportScore: ReportChartPoint[] = [{ label: "Score", value: totalScore }];
|
|
336
|
+
const reportPillars: ReportChartPoint[] = Object.entries(categoryScores)
|
|
337
|
+
.filter(([, value]) => Number.isFinite(Number(value)))
|
|
338
|
+
.map(([label, value]) => ({ label, value: Math.max(0, Math.round(Number(value))) }))
|
|
339
|
+
.filter((p) => p.label.length > 0)
|
|
340
|
+
.sort((a, b) => b.value - a.value);
|
|
341
|
+
|
|
342
|
+
const band = selectStageBand(readStageBands(assessment), totalScore);
|
|
343
|
+
const narrative = generateNarrative(totalScore, band, assessment);
|
|
344
|
+
const pillarCards = buildPillarCards(assessment, categoryScores, band);
|
|
345
|
+
// The chart bars take each pillar's own colour, so the bar chart and the
|
|
346
|
+
// pillar cards cannot disagree about which colour means which pillar.
|
|
347
|
+
// Only an AUTHORED colour travels to the chart. When the assessment sets
|
|
348
|
+
// none, the bars keep the chart's own default rather than being stamped with
|
|
349
|
+
// the cards' fallback — an assessment that never asked for pillar colours
|
|
350
|
+
// gets exactly the output it got before.
|
|
351
|
+
const colorFor = new Map(
|
|
352
|
+
pillarCards.filter((c) => c.color !== DEFAULT_PILLAR_COLOR).map((c) => [c.label, c.color]),
|
|
353
|
+
);
|
|
354
|
+
for (const point of reportPillars) {
|
|
355
|
+
const color = colorFor.get(point.label);
|
|
356
|
+
if (color) point.color = color;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// The positioning trio names the pillar; the advisory line beside it is the
|
|
360
|
+
// band's note for that pillar when one is authored, otherwise the generic
|
|
361
|
+
// role of that column. Falling back per-column rather than dropping the
|
|
362
|
+
// whole card keeps the section rendering while copy is still being written.
|
|
363
|
+
const noteFor = (label: string, fallback: string): string =>
|
|
364
|
+
pillarNote(band, label) || fallback;
|
|
365
|
+
const pos = (p: ReportChartPoint | undefined, fallback: string): PillarPosition | null =>
|
|
366
|
+
p ? { label: p.label, summary: noteFor(p.label, fallback) } : null;
|
|
367
|
+
|
|
368
|
+
const strongest = pos(
|
|
369
|
+
reportPillars[0],
|
|
370
|
+
"Your strongest pillar — protect and compound this advantage.",
|
|
371
|
+
);
|
|
372
|
+
const opportunity =
|
|
373
|
+
reportPillars.length >= 3
|
|
374
|
+
? pos(
|
|
375
|
+
reportPillars[1],
|
|
376
|
+
"Your biggest growth opportunity — focused effort here lifts overall authority.",
|
|
377
|
+
)
|
|
378
|
+
: null;
|
|
379
|
+
const authorityGap =
|
|
380
|
+
reportPillars.length >= 2
|
|
381
|
+
? pos(
|
|
382
|
+
reportPillars[reportPillars.length - 1],
|
|
383
|
+
"Your lowest pillar — the single biggest lever for improvement right now.",
|
|
384
|
+
)
|
|
385
|
+
: null;
|
|
386
|
+
|
|
387
|
+
return {
|
|
388
|
+
pillarCards,
|
|
389
|
+
reportScore,
|
|
390
|
+
reportPillars,
|
|
391
|
+
narrative,
|
|
392
|
+
totalScore,
|
|
393
|
+
stage: band?.name ?? "",
|
|
394
|
+
stageSummary: band?.tagline ?? "",
|
|
395
|
+
strongest,
|
|
396
|
+
opportunity,
|
|
397
|
+
authorityGap,
|
|
398
|
+
strongestAreas: band?.strongestAreas ?? [],
|
|
399
|
+
priorityActions: band?.priorityActions ?? [],
|
|
400
|
+
strategicFocus: band?.strategicFocus ?? "",
|
|
401
|
+
strengths: band?.strengths ?? [],
|
|
402
|
+
opportunities: band?.opportunities ?? [],
|
|
403
|
+
strengthsList: numberItems(band?.strengths),
|
|
404
|
+
opportunitiesList: numberItems(band?.opportunities),
|
|
405
|
+
twelveMonthTarget: band?.twelveMonthTarget ?? "",
|
|
406
|
+
roadmap: orderRoadmap(band?.roadmap),
|
|
407
|
+
whoYouHelp: band?.whoYouHelp ?? "",
|
|
408
|
+
knownFor: band?.knownFor ?? "",
|
|
409
|
+
insights: band?.insights ?? [],
|
|
410
|
+
contentThemes: band?.contentThemes ?? [],
|
|
411
|
+
contentRhythm: band?.contentRhythm ?? "",
|
|
412
|
+
northStar: band?.northStar ?? "",
|
|
413
|
+
priorityFocus: band?.priorityFocus ?? "",
|
|
414
|
+
priorityFocusSummary: band?.priorityFocusSummary ?? "",
|
|
415
|
+
recommendedTier: band?.recommendedTier ?? "",
|
|
416
|
+
tierSummary: band?.tierSummary ?? "",
|
|
417
|
+
whyItFits: band?.whyItFits ?? "",
|
|
418
|
+
whatItSolves: band?.whatItSolves ?? "",
|
|
419
|
+
implementationFocus: band?.implementationFocus ?? "",
|
|
420
|
+
expectedOutcome: band?.expectedOutcome ?? "",
|
|
421
|
+
finalWord: band?.finalWord ?? "",
|
|
422
|
+
};
|
|
423
|
+
}
|
|
@@ -20,11 +20,11 @@ import { resolveEmailSender } from "@teamscala/email/identity/resolve-email-send
|
|
|
20
20
|
import { decryptSecretConfig } from "@teamscala/encryption/crypto/secret-config";
|
|
21
21
|
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
22
22
|
import { fetchOrgBrand } from "@teamscala/resolver-engine/data-resolvers/fetch-org-brand";
|
|
23
|
-
import type { StageBand } from "
|
|
23
|
+
import type { StageBand } from "./assessment-report-data.ts";
|
|
24
24
|
import {
|
|
25
25
|
generateNarrative,
|
|
26
26
|
orderRoadmap,
|
|
27
|
-
} from "
|
|
27
|
+
} from "./assessment-report-data.ts";
|
|
28
28
|
|
|
29
29
|
export async function sendAuthorityReportEmail(opts: {
|
|
30
30
|
to: string;
|
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
* @edit edit directly
|
|
5
5
|
*
|
|
6
6
|
* The valibot schemas + row helpers of the chatSession ORPC router: procedure
|
|
7
|
-
* input schemas,
|
|
8
|
-
* message mapping.
|
|
7
|
+
* input schemas, and the stored-row → wire message mapping.
|
|
9
8
|
*
|
|
10
9
|
* Split out of fn-routers/chat-session-router.ts (455 lines) — byte-for-byte
|
|
11
|
-
* move; import paths adjusted only.
|
|
10
|
+
* move; import paths adjusted only. Conversation ownership lives in its own
|
|
11
|
+
* sibling, ./conversation-ownership.ts (single-canonical-unit).
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import * as v from "valibot";
|
|
@@ -90,25 +90,8 @@ export async function throwForbidden(message: string): Promise<never> {
|
|
|
90
90
|
throw new ORPCError("FORBIDDEN", { message });
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
/**
|
|
94
|
-
* Reject if the conversation does not belong to the calling user. Ownership
|
|
95
|
-
* by user_id is the complete proof (the row was created with this user's id);
|
|
96
|
-
* no org check needed.
|
|
97
|
-
*/
|
|
98
|
-
export async function assertOwns(
|
|
99
|
-
prisma: any,
|
|
100
|
-
conversationId: string,
|
|
101
|
-
user: SessionUser,
|
|
102
|
-
): Promise<any> {
|
|
103
|
-
const conv = await prisma.conversations.findUnique({
|
|
104
|
-
where: { id: conversationId },
|
|
105
|
-
});
|
|
106
|
-
if (!conv || conv.user_id !== user.id) await throwForbidden("Conversation not found.");
|
|
107
|
-
return conv;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
93
|
/** Map stored message rows to the wire shape the chat client restores. */
|
|
111
|
-
export function mapMessages(msgs:
|
|
94
|
+
export function mapMessages(msgs: unknown[]) {
|
|
112
95
|
return msgs
|
|
113
96
|
.filter((m: any) => m.role === "user" || m.role === "assistant")
|
|
114
97
|
.map((m: any) => ({
|
|
@@ -37,7 +37,6 @@ import { authed, type Context } from "./auth-router.ts";
|
|
|
37
37
|
import { persistChatMessages } from "./persist-chat-messages.ts";
|
|
38
38
|
|
|
39
39
|
import {
|
|
40
|
-
assertOwns,
|
|
41
40
|
assistantType,
|
|
42
41
|
clearSchema,
|
|
43
42
|
createOrRestoreSchema,
|
|
@@ -50,6 +49,7 @@ import {
|
|
|
50
49
|
SessionUser,
|
|
51
50
|
throwForbidden,
|
|
52
51
|
} from "./chat-session-router-shared.ts";
|
|
52
|
+
import { assertOwns } from "./conversation-ownership.ts";
|
|
53
53
|
|
|
54
54
|
export const chatSessionRouter = {
|
|
55
55
|
// createOrRestore — resume the caller's most-recent ACTIVE thread for this
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Conversation ownership assertion for the chatSession ORPC router — the
|
|
7
|
+
* reject-if-not-yours gate every mutating procedure runs before touching rows.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { throwForbidden, type SessionUser } from "./chat-session-router-shared.ts";
|
|
11
|
+
|
|
12
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
13
|
+
export async function assertOwns(
|
|
14
|
+
prisma: any,
|
|
15
|
+
conversationId: string,
|
|
16
|
+
user: SessionUser,
|
|
17
|
+
): Promise<unknown> {
|
|
18
|
+
const conv = await prisma.conversations.findUnique({
|
|
19
|
+
where: { id: conversationId },
|
|
20
|
+
});
|
|
21
|
+
if (!conv || conv.user_id !== user.id) await throwForbidden("Conversation not found.");
|
|
22
|
+
return conv;
|
|
23
|
+
}
|
package/src/fn-routers/index.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { registerCustomRouter } from "@teamscala/orpc/app-router";
|
|
19
|
-
import { puckPagesRouter } from "
|
|
19
|
+
import { puckPagesRouter } from "../page-server-orpc/puck-pages-router";
|
|
20
20
|
import { watchDataChange } from "@teamscala/realtime-utils/data-change-procedure";
|
|
21
21
|
import { watchPresence } from "@teamscala/realtime-utils/presence-procedure";
|
|
22
22
|
import { agreementSendRouter } from "./agreement-send-router.ts";
|
|
@@ -49,7 +49,7 @@ export type { CertificateData, CertificateCopy } from "./agreement-certificate.t
|
|
|
49
49
|
interface Context {
|
|
50
50
|
prisma: any;
|
|
51
51
|
headers: Headers;
|
|
52
|
-
resolveUser?: (headers: Headers, prisma: any) => Promise<
|
|
52
|
+
resolveUser?: (headers: Headers, prisma: any) => Promise<unknown>;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
const pub = os.$context<Context>();
|
|
@@ -22,14 +22,14 @@ import { recordEvent } from "@teamscala/event-log/record-event";
|
|
|
22
22
|
import {
|
|
23
23
|
readStageBands,
|
|
24
24
|
selectStageBand,
|
|
25
|
-
} from "
|
|
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
28
|
|
|
29
29
|
interface Context {
|
|
30
30
|
prisma: any;
|
|
31
31
|
headers: Headers;
|
|
32
|
-
resolveUser?: (headers: Headers, prisma: any) => Promise<
|
|
32
|
+
resolveUser?: (headers: Headers, prisma: any) => Promise<unknown>;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
const pub = os.$context<Context>();
|
|
@@ -17,7 +17,7 @@ import { persistChatMessages } from "./persist-chat-messages.ts";
|
|
|
17
17
|
interface Context {
|
|
18
18
|
prisma: any;
|
|
19
19
|
headers: Headers;
|
|
20
|
-
resolveUser?: (headers: Headers, prisma: any) => Promise<
|
|
20
|
+
resolveUser?: (headers: Headers, prisma: any) => Promise<unknown>;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
const pub = os.$context<Context>();
|
|
@@ -33,6 +33,22 @@ export const FaultEscalationConfigSchema = v.object({
|
|
|
33
33
|
),
|
|
34
34
|
),
|
|
35
35
|
resolve_after_minutes: v.number(),
|
|
36
|
+
/** Per-kind volume sources closing the quiet-vs-dead gap: a fault kind
|
|
37
|
+
* whose producer can DIE maps to the kind whose row VOLUME proves the
|
|
38
|
+
* pipeline alive. Without a source, resolution on quiet is unchanged;
|
|
39
|
+
* with one, resolve additionally requires the volume floor in-window,
|
|
40
|
+
* and quiet-with-zero-volume HOLDS the episode open instead (one hold
|
|
41
|
+
* notice per episode-state — measured 2026-08-27: the dead llm-router
|
|
42
|
+
* emitted zero failures and the sweep closed as healthy). */
|
|
43
|
+
volume_sources: v.optional(
|
|
44
|
+
v.record(
|
|
45
|
+
v.string(),
|
|
46
|
+
v.object({
|
|
47
|
+
volume_kind_pattern: v.string(),
|
|
48
|
+
min_volume: v.optional(v.number()),
|
|
49
|
+
}),
|
|
50
|
+
),
|
|
51
|
+
),
|
|
36
52
|
/** The orchestrator's work item — escalations are COMMENTS on it, never
|
|
37
53
|
* task rows (the fleet-task-placement CHECK constraint refuses a system
|
|
38
54
|
* task without session identity, and an escalation is a finding, not an
|