@growth-labs/cms 0.8.26 → 0.8.30

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 (85) hide show
  1. package/README.md +23 -0
  2. package/dist/engine/content-insights.d.ts +27 -3
  3. package/dist/engine/content-insights.d.ts.map +1 -1
  4. package/dist/engine/content-insights.js +83 -4
  5. package/dist/engine/content-insights.js.map +1 -1
  6. package/dist/engine/index.d.ts +1 -0
  7. package/dist/engine/index.d.ts.map +1 -1
  8. package/dist/engine/index.js +1 -0
  9. package/dist/engine/index.js.map +1 -1
  10. package/dist/engine/publication-receipt/receipt.d.ts +4 -4
  11. package/dist/engine/published-content.d.ts +4 -0
  12. package/dist/engine/published-content.d.ts.map +1 -1
  13. package/dist/engine/published-content.js +52 -16
  14. package/dist/engine/published-content.js.map +1 -1
  15. package/dist/index.d.ts +1 -0
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +1 -0
  18. package/dist/index.js.map +1 -1
  19. package/dist/integration/options.d.ts +226 -13
  20. package/dist/integration/options.d.ts.map +1 -1
  21. package/dist/integration/options.js +17 -2
  22. package/dist/integration/options.js.map +1 -1
  23. package/dist/providers/types.d.ts +10 -0
  24. package/dist/providers/types.d.ts.map +1 -1
  25. package/dist/providers/types.js.map +1 -1
  26. package/dist/routes/content-insights.d.ts +4 -0
  27. package/dist/routes/content-insights.d.ts.map +1 -1
  28. package/dist/routes/content-insights.js +137 -3
  29. package/dist/routes/content-insights.js.map +1 -1
  30. package/dist/routes/fronts-publish.d.ts +1 -1
  31. package/dist/routes/fronts-publish.d.ts.map +1 -1
  32. package/dist/routes/fronts-publish.js +2 -2
  33. package/dist/routes/fronts-publish.js.map +1 -1
  34. package/dist/routes/surveys.d.ts.map +1 -1
  35. package/dist/routes/surveys.js +80 -4
  36. package/dist/routes/surveys.js.map +1 -1
  37. package/dist/schema/insights-ingest.d.ts +961 -97
  38. package/dist/schema/insights-ingest.d.ts.map +1 -1
  39. package/dist/schema/insights-ingest.js +38 -0
  40. package/dist/schema/insights-ingest.js.map +1 -1
  41. package/dist/schema/types.d.ts +20 -0
  42. package/dist/schema/types.d.ts.map +1 -1
  43. package/dist/schema/types.js.map +1 -1
  44. package/dist/surveys/auto-label.d.ts +31 -4
  45. package/dist/surveys/auto-label.d.ts.map +1 -1
  46. package/dist/surveys/auto-label.js +160 -29
  47. package/dist/surveys/auto-label.js.map +1 -1
  48. package/dist/surveys/schema.d.ts +155 -0
  49. package/dist/surveys/schema.d.ts.map +1 -1
  50. package/dist/surveys/schema.js +49 -0
  51. package/dist/surveys/schema.js.map +1 -1
  52. package/dist/ui/api/content/insights-inputs.d.ts.map +1 -1
  53. package/dist/ui/api/content/insights-inputs.js +11 -1
  54. package/dist/ui/api/content/insights-inputs.js.map +1 -1
  55. package/dist/ui/client/mount.d.ts.map +1 -1
  56. package/dist/ui/client/mount.js +1 -0
  57. package/dist/ui/client/mount.js.map +1 -1
  58. package/dist/ui/screens/SurveyResponsesScreen.d.ts +18 -3
  59. package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -1
  60. package/dist/ui/screens/SurveyResponsesScreen.js +75 -18
  61. package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -1
  62. package/dist/ui/screens/registry.d.ts +3 -1
  63. package/dist/ui/screens/registry.d.ts.map +1 -1
  64. package/dist/ui/screens/registry.js +4 -2
  65. package/dist/ui/screens/registry.js.map +1 -1
  66. package/dist/ui/styles/broadsheet.css +1 -0
  67. package/package.json +1 -1
  68. package/src/engine/content-insights.ts +99 -6
  69. package/src/engine/index.ts +1 -0
  70. package/src/engine/published-content.ts +75 -16
  71. package/src/index.ts +1 -0
  72. package/src/integration/options.ts +21 -1
  73. package/src/providers/types.ts +9 -0
  74. package/src/routes/content-insights.ts +144 -2
  75. package/src/routes/fronts-publish.ts +2 -1
  76. package/src/routes/surveys.ts +106 -4
  77. package/src/schema/insights-ingest.ts +45 -0
  78. package/src/schema/types.ts +10 -0
  79. package/src/surveys/auto-label.ts +204 -33
  80. package/src/surveys/schema.ts +58 -0
  81. package/src/ui/api/content/insights-inputs.ts +8 -1
  82. package/src/ui/client/mount.tsx +1 -0
  83. package/src/ui/screens/SurveyResponsesScreen.tsx +198 -18
  84. package/src/ui/screens/registry.tsx +6 -2
  85. package/src/ui/styles/broadsheet.css +1 -0
@@ -12,8 +12,8 @@ import { TagInputError, validateRevisionTaxonomy } from './taxonomy.js'
12
12
  const CONTENT_TYPES = new Set<ContentType>(['article', 'video', 'podcast', 'newsletter', 'page'])
13
13
  const CONTENT_STATUSES = new Set(['draft', 'scheduled', 'review', 'published', 'archived'])
14
14
  const CONTENT_VISIBILITIES = new Set(['free', 'premium'])
15
- const LIST_OPTION_KEYS = new Set(['type', 'channel', 'limit', 'cursor', 'bodies'])
16
- const CURSOR_KEYS = new Set(['publishedAt', 'contentId'])
15
+ const LIST_OPTION_KEYS = new Set(['type', 'channel', 'limit', 'cursor', 'bodies', 'order'])
16
+ const CURSOR_KEYS = new Set(['publishedAt', 'contentId', 'contentDate'])
17
17
 
18
18
  export const MAX_PUBLISHED_CONTENT_PAGE_SIZE = 100
19
19
  export const MAX_PUBLISHED_CONTENT_PAYLOAD_BYTES = 4 * 1024 * 1024
@@ -71,6 +71,8 @@ export interface PublishedContent {
71
71
  export interface PublishedContentCursor {
72
72
  publishedAt: number
73
73
  contentId: string
74
+ /** Required when paging by the immutable snapshot's display date. */
75
+ contentDate?: number
74
76
  }
75
77
 
76
78
  export interface ListPublishedContentOptions {
@@ -78,6 +80,8 @@ export interface ListPublishedContentOptions {
78
80
  channel?: string
79
81
  limit?: number
80
82
  cursor?: PublishedContentCursor | null
83
+ /** Preserve canonical order by default; card lists may order by snapshot date. */
84
+ order?: 'published' | 'content-date'
81
85
  /**
82
86
  * `false` strips `content.body_markdown` / `content.body_html` from each
83
87
  * payload IN SQL, so corpus snapshots (lists, navs, hubs) stop hauling the
@@ -110,6 +114,7 @@ interface PublishedContentOutlineRow {
110
114
  pointer_revision_id: string
111
115
  canonical_published_at: number
112
116
  payload_bytes: number
117
+ content_date?: number
113
118
  }
114
119
 
115
120
  const SELECT_PUBLISHED_CONTENT = `
@@ -149,23 +154,22 @@ SELECT
149
154
  FROM content_items ci
150
155
  `
151
156
 
152
- const SELECT_PUBLISHED_CONTENT_OUTLINE = `
157
+ function publishedOutlineSelect(bodies: boolean, contentDate: string): string {
158
+ return `
153
159
  SELECT
154
160
  ci.id AS content_id,
155
161
  ci.published_revision_id AS pointer_revision_id,
156
162
  ci.published_at AS canonical_published_at,
157
- length(CAST(cr.payload_json AS BLOB)) AS payload_bytes
163
+ ${contentDate} AS content_date,
164
+ length(CAST(${bodies ? 'cr.payload_json' : BODYLESS_PAYLOAD_EXPR} AS BLOB)) AS payload_bytes
158
165
  FROM content_items ci
159
166
  `
167
+ }
160
168
 
161
- const SELECT_PUBLISHED_CONTENT_OUTLINE_BODYLESS = `
162
- SELECT
163
- ci.id AS content_id,
164
- ci.published_revision_id AS pointer_revision_id,
165
- ci.published_at AS canonical_published_at,
166
- length(CAST(${BODYLESS_PAYLOAD_EXPR} AS BLOB)) AS payload_bytes
167
- FROM content_items ci
168
- `
169
+ const SNAPSHOT_CONTENT_DATE = `CASE WHEN json_valid(cr.payload_json) = 1
170
+ THEN COALESCE(json_extract(cr.payload_json, '$.item.publish_at'),
171
+ json_extract(cr.payload_json, '$.item.published_at'), 0)
172
+ ELSE 0 END`
169
173
 
170
174
  const SELECT_PUBLISHED_CONTENT_BY_SLUG = `
171
175
  SELECT
@@ -465,6 +469,7 @@ function normalizeListOptions(options: unknown): {
465
469
  type?: ContentType
466
470
  channel?: string
467
471
  cursor?: PublishedContentCursor
472
+ order: 'published' | 'content-date'
468
473
  } {
469
474
  if (!isPlainRecord(options)) {
470
475
  throw new PublishedContentReadError(
@@ -506,6 +511,13 @@ function normalizeListOptions(options: unknown): {
506
511
  channel = options.channel
507
512
  }
508
513
  let cursor: PublishedContentCursor | undefined
514
+ const order = options.order ?? 'published'
515
+ if (order !== 'published' && order !== 'content-date') {
516
+ throw new PublishedContentReadError(
517
+ 'published_content_input_invalid',
518
+ 'unknown published content order',
519
+ )
520
+ }
509
521
  if (options.cursor !== undefined && options.cursor !== null) {
510
522
  if (
511
523
  !isPlainRecord(options.cursor) ||
@@ -527,9 +539,22 @@ function normalizeListOptions(options: unknown): {
527
539
  )
528
540
  }
529
541
  assertLookupValue(options.cursor.contentId, 'cursor contentId', 256)
542
+ if (
543
+ (order === 'content-date' &&
544
+ (typeof options.cursor.contentDate !== 'number' ||
545
+ !Number.isSafeInteger(options.cursor.contentDate) ||
546
+ options.cursor.contentDate < 0)) ||
547
+ (order === 'published' && options.cursor.contentDate !== undefined)
548
+ ) {
549
+ throw new PublishedContentReadError(
550
+ 'published_content_input_invalid',
551
+ 'cursor must match the requested content order',
552
+ )
553
+ }
530
554
  cursor = {
531
555
  publishedAt: options.cursor.publishedAt,
532
556
  contentId: options.cursor.contentId,
557
+ ...(order === 'content-date' ? { contentDate: options.cursor.contentDate as number } : {}),
533
558
  }
534
559
  }
535
560
  let bodies = true
@@ -548,6 +573,7 @@ function normalizeListOptions(options: unknown): {
548
573
  type,
549
574
  channel,
550
575
  cursor,
576
+ order,
551
577
  }
552
578
  }
553
579
 
@@ -563,6 +589,12 @@ export async function listPublishedContent(
563
589
  'ci.published_at IS NOT NULL',
564
590
  ]
565
591
  const values: unknown[] = []
592
+ const contentDate =
593
+ normalized.order === 'content-date' ? SNAPSHOT_CONTENT_DATE : 'ci.published_at'
594
+ const orderBy =
595
+ normalized.order === 'content-date'
596
+ ? `${contentDate} DESC, ci.published_at DESC, ci.id DESC`
597
+ : 'ci.published_at DESC, ci.id DESC'
566
598
  // A missing revision or type must reach the integrity checks, rather than
567
599
  // disappearing behind a type/channel filter as a successful empty list.
568
600
  const invalidRevisionType = `CASE WHEN json_valid(cr.payload_json) = 1
@@ -580,7 +612,15 @@ ELSE 1 END`
580
612
  values.push(normalized.channel)
581
613
  }
582
614
  if (normalized.cursor) {
583
- predicates.push('(ci.published_at < ? OR (ci.published_at = ? AND ci.id < ?))')
615
+ const canonicalCursor = '(ci.published_at < ? OR (ci.published_at = ? AND ci.id < ?))'
616
+ predicates.push(
617
+ normalized.order === 'content-date'
618
+ ? `(${contentDate} < ? OR (${contentDate} = ? AND ${canonicalCursor}))`
619
+ : canonicalCursor,
620
+ )
621
+ if (normalized.order === 'content-date') {
622
+ values.push(normalized.cursor.contentDate, normalized.cursor.contentDate)
623
+ }
584
624
  values.push(
585
625
  normalized.cursor.publishedAt,
586
626
  normalized.cursor.publishedAt,
@@ -591,10 +631,10 @@ ELSE 1 END`
591
631
  const reader = openReadSession(db)
592
632
  const outlineResult = await reader
593
633
  .prepare(
594
- `${normalized.bodies ? SELECT_PUBLISHED_CONTENT_OUTLINE : SELECT_PUBLISHED_CONTENT_OUTLINE_BODYLESS}
634
+ `${publishedOutlineSelect(normalized.bodies, contentDate)}
595
635
  LEFT JOIN content_revisions cr ON cr.id = ci.published_revision_id
596
636
  WHERE ${predicates.join('\n AND ')}
597
- ORDER BY ci.published_at DESC, ci.id DESC
637
+ ORDER BY ${orderBy}
598
638
  LIMIT ?`,
599
639
  )
600
640
  .bind(...values, normalized.limit + 1)
@@ -616,6 +656,17 @@ LIMIT ?`,
616
656
  'published revision outline is malformed',
617
657
  )
618
658
  }
659
+ if (
660
+ normalized.order === 'content-date' &&
661
+ (typeof outline.content_date !== 'number' ||
662
+ !Number.isSafeInteger(outline.content_date) ||
663
+ outline.content_date < 0)
664
+ ) {
665
+ throw new PublishedContentReadError(
666
+ 'published_payload_invalid',
667
+ 'published snapshot date is malformed',
668
+ )
669
+ }
619
670
  if (outline.payload_bytes > MAX_PUBLISHED_CONTENT_PAYLOAD_BYTES) {
620
671
  throw new PublishedContentReadError(
621
672
  'published_payload_limit_exceeded',
@@ -689,7 +740,15 @@ WHERE ci.status = 'published'
689
740
  return {
690
741
  items,
691
742
  nextCursor:
692
- hasMore && last ? { publishedAt: last.publishedAt, contentId: last.contentId } : null,
743
+ hasMore && last
744
+ ? {
745
+ publishedAt: last.publishedAt,
746
+ contentId: last.contentId,
747
+ ...(normalized.order === 'content-date'
748
+ ? { contentDate: selected.at(-1)?.content_date }
749
+ : {}),
750
+ }
751
+ : null,
693
752
  }
694
753
  } catch (error) {
695
754
  return wrapReadError(error)
package/src/index.ts CHANGED
@@ -85,5 +85,6 @@ export {
85
85
  type WorkspaceMembershipRow,
86
86
  type WorkspaceSettingsRow,
87
87
  } from './schema/index.js'
88
+ export { finalizeSurveyAutoLabel, kvSurveyAutoLabelAttemptStore } from './surveys/auto-label.js'
88
89
  export * from './surveys/errors.js'
89
90
  export * from './surveys/schema.js'
@@ -1,6 +1,6 @@
1
1
  import { isCanonicalHost } from '@growth-labs/foundry-signing'
2
2
  import { z } from 'zod'
3
- import { SurveyIdSchema } from '../surveys/schema.js'
3
+ import { SurveyFeedbackThemesSchema, SurveyIdSchema } from '../surveys/schema.js'
4
4
 
5
5
  const workspaceSchema = z.object({
6
6
  id: z.string(),
@@ -121,9 +121,29 @@ const socialSharingSchema = z
121
121
 
122
122
  const surveyResultsSchema = z
123
123
  .object({
124
+ // The survey the screen opens on.
124
125
  surveyId: SurveyIdSchema,
126
+ // Every survey the screen can switch between, in picker order. Absent
127
+ // means only surveyId; when given it must include surveyId.
128
+ surveys: z
129
+ .array(
130
+ z
131
+ .object({
132
+ surveyId: SurveyIdSchema,
133
+ label: z.string().trim().min(1).max(80),
134
+ feedbackThemes: SurveyFeedbackThemesSchema.optional(),
135
+ })
136
+ .strict(),
137
+ )
138
+ .min(1)
139
+ .max(10)
140
+ .optional(),
125
141
  })
126
142
  .strict()
143
+ .refine(
144
+ (value) => !value.surveys || value.surveys.some((survey) => survey.surveyId === value.surveyId),
145
+ { message: 'surveyResults.surveys must include surveyResults.surveyId' },
146
+ )
127
147
 
128
148
  const surveysOptionsSchema = z
129
149
  .object({
@@ -440,8 +440,17 @@ export type LlmDispatchResult =
440
440
  | { text: string; status?: 'ok' }
441
441
  | { text: string; status: 'unavailable'; reason?: string }
442
442
  | ReadableStream
443
+ export type LlmDispatchSubmission =
444
+ | { status: 'accepted'; taskId: string }
445
+ | { status: 'unavailable'; reason: string }
443
446
  export interface LlmDispatch {
444
447
  dispatch(input: LlmDispatchInput): Promise<LlmDispatchResult>
448
+ /** Admit background work without waiting for the signed terminal callback.
449
+ * Persist correlation before the outbound request; failure must prevent dispatch. */
450
+ submit?(
451
+ input: LlmDispatchInput,
452
+ beforeDispatch: (taskId: string) => Promise<void>,
453
+ ): Promise<LlmDispatchSubmission>
445
454
  }
446
455
 
447
456
  export interface FoundryJobSpec {
@@ -6,10 +6,12 @@ import {
6
6
  isMissingInsightPublicationSchema,
7
7
  isMissingInsightsTable,
8
8
  listInsightBoard,
9
+ readSelectedClusterMembership,
9
10
  replaceInsightsBoard,
10
11
  undismissInsight,
11
12
  } from '../engine/content-insights.js'
12
13
  import { verifyHmac } from '../engine/foundry-dispatch.js'
14
+ import { listPublishedContent, PublishedContentReadError } from '../engine/published-content.js'
13
15
  import { InsightsIngestPayloadSchema } from '../schema/insights-ingest.js'
14
16
  import { InsightDismissalReasonCodeSchema } from '../schema/types.js'
15
17
  import type { CmsRouteConfig } from './config.js'
@@ -29,6 +31,103 @@ const DismissSchema = z
29
31
  .strict()
30
32
  const IntentKeySchema = z.object({ intentKey: z.string().min(1) })
31
33
 
34
+ const PublishedCursorSchema = z
35
+ .object({
36
+ publishedAt: z.number().int().nonnegative(),
37
+ contentId: z.string().min(1).max(256),
38
+ })
39
+ .strict()
40
+
41
+ const PublishedArticleStateSchema = z
42
+ .object({
43
+ content_id: z.string().min(1),
44
+ revision_id: z.string().min(1),
45
+ slug: z.string().min(1),
46
+ title: z.string().min(1),
47
+ canonical_url: z.string().url().nullable(),
48
+ published_at: z.number().int().nonnegative(),
49
+ source_id: z.string().nullable(),
50
+ youtube_video_id: z
51
+ .string()
52
+ .regex(/^[A-Za-z0-9_-]{11}$/)
53
+ .nullable(),
54
+ hero_image_id: z.string().nullable(),
55
+ hero_image_caption: z.string().nullable(),
56
+ })
57
+ .strict()
58
+
59
+ const PublishedInputsResponseSchema = z
60
+ .object({
61
+ ok: z.literal(true),
62
+ site: z.string().min(1),
63
+ observed_at: z.string().datetime(),
64
+ published: z.array(PublishedArticleStateSchema),
65
+ next_cursor: PublishedCursorSchema.nullable(),
66
+ complete: z.boolean(),
67
+ })
68
+ .strict()
69
+
70
+ // Additive read mode of the existing signed inputs route. Read the immutable
71
+ // published snapshot, never mutable draft fields. Reuse the CMS reader's
72
+ // revision checks and pagination; return only named public fields, not a raw
73
+ // payload that may contain internal machine trigger tokens.
74
+ async function publishedInputs(ctx: RouteContext, url: URL, siteId: string): Promise<Response> {
75
+ if (url.searchParams.get('published') !== '1')
76
+ return json({ error: 'invalid_published_mode' }, 400)
77
+ if (siteId === 'default') return json({ error: 'publication_identity_not_configured' }, 503)
78
+ if (url.searchParams.get('site') !== siteId) return json({ error: 'publication_mismatch' }, 403)
79
+ const limit = z.coerce
80
+ .number()
81
+ .int()
82
+ .min(1)
83
+ .max(100)
84
+ .safeParse(url.searchParams.get('limit') ?? '100')
85
+ if (!limit.success) return json({ error: 'invalid_limit' }, 400)
86
+ let cursor: z.infer<typeof PublishedCursorSchema> | null = null
87
+ const cursorText = url.searchParams.get('cursor')
88
+ if (cursorText !== null) {
89
+ try {
90
+ const parsed = PublishedCursorSchema.safeParse(JSON.parse(cursorText))
91
+ if (!parsed.success) return json({ error: 'invalid_cursor' }, 400)
92
+ cursor = parsed.data
93
+ } catch {
94
+ return json({ error: 'invalid_cursor' }, 400)
95
+ }
96
+ }
97
+ try {
98
+ const page = await listPublishedContent(ctx.db, {
99
+ type: 'article',
100
+ bodies: false,
101
+ limit: limit.data,
102
+ cursor,
103
+ })
104
+ const result = PublishedInputsResponseSchema.safeParse({
105
+ ok: true,
106
+ site: siteId,
107
+ observed_at: new Date().toISOString(),
108
+ published: page.items.map((content) => ({
109
+ content_id: content.contentId,
110
+ revision_id: content.revisionId,
111
+ slug: content.slug,
112
+ title: content.payload.item.title,
113
+ canonical_url: content.payload.item.canonical_url ?? null,
114
+ published_at: content.publishedAt,
115
+ source_id: content.payload.item.source_id ?? null,
116
+ youtube_video_id: content.metadata.videoId ?? null,
117
+ hero_image_id: content.payload.item.hero_image_id ?? null,
118
+ hero_image_caption: content.payload.item.hero_image_caption ?? null,
119
+ })),
120
+ next_cursor: page.nextCursor,
121
+ complete: page.nextCursor === null,
122
+ })
123
+ if (!result.success) return json({ error: 'published_state_invalid' }, 409)
124
+ return json(result.data)
125
+ } catch (error) {
126
+ if (error instanceof PublishedContentReadError) return json({ error: error.code }, 409)
127
+ throw error
128
+ }
129
+ }
130
+
32
131
  /** Resolve YYYY-MM-DD; defaults to the current UTC date when no ?runDate is given. */
33
132
  function resolveRunDate(ctx: RouteContext): string {
34
133
  const url = new URL(ctx.request.url)
@@ -85,6 +184,10 @@ export interface ContentInsightsRouteHandlers {
85
184
  * fronts/schedule (X-Foundry-Timestamp + X-Foundry-Signature over the raw
86
185
  * query string, verified against FOUNDRY_HMAC_SECRET) -- a sibling poll,
87
186
  * not a new auth scheme.
187
+ * `published=1&site=<configured-publication>` reads a page of canonical
188
+ * published article revisions instead, with `limit` (1..100) and JSON
189
+ * `cursor` from the prior page. `complete` means the pagination ended;
190
+ * no total is inferred from one page. Missing URLs/video IDs are null.
88
191
  */
89
192
  insightsInputs(ctx: RouteContext): Promise<Response>
90
193
  }
@@ -210,7 +313,11 @@ export function createContentInsightsRoutes(
210
313
  } catch (error) {
211
314
  if (isMissingInsightPublicationSchema(error))
212
315
  return json({ error: 'schema_not_ready' }, 409)
213
- if (error instanceof InsightPublicationError) return json({ error: error.code }, 409)
316
+ if (error instanceof InsightPublicationError)
317
+ return json(
318
+ { error: error.code },
319
+ error.code === 'selected_membership_replacement_refused' ? 422 : 409,
320
+ )
214
321
  throw error
215
322
  }
216
323
  },
@@ -219,7 +326,13 @@ export function createContentInsightsRoutes(
219
326
  // family as fronts/schedule (timestamp-bound signature over the query
220
327
  // string; a GET has no body to bind freshness to instead).
221
328
  async insightsInputs(ctx) {
222
- const hmacSecret = await resolveFoundryHmacSecretForInputs(ctx.env)
329
+ // Channel publications use the existing shared CMS binding. An explicit
330
+ // legacy binding retains precedence, including loud failure if unreadable.
331
+ const binding =
332
+ ctx.env?.FOUNDRY_HMAC_SECRET === undefined
333
+ ? 'CMS_FOUNDRY_HMAC_SECRET'
334
+ : 'FOUNDRY_HMAC_SECRET'
335
+ const hmacSecret = await resolveFoundryHmacSecretForInputs(ctx.env, binding)
223
336
  if (!hmacSecret) return json({ error: 'FOUNDRY_HMAC_SECRET is not configured' }, 500)
224
337
 
225
338
  const url = new URL(ctx.request.url)
@@ -231,6 +344,8 @@ export function createContentInsightsRoutes(
231
344
  )
232
345
  if (denied) return denied
233
346
 
347
+ if (url.searchParams.has('published')) return publishedInputs(ctx, url, siteId)
348
+
234
349
  const requestedSite = url.searchParams.get('site')
235
350
  const authorsResult = await ctx.db.prepare(AUTHORS_QUERY).all<{
236
351
  name: string
@@ -240,11 +355,38 @@ export function createContentInsightsRoutes(
240
355
  title: string
241
356
  slug: string
242
357
  }>()
358
+ let selected: Awaited<ReturnType<typeof readSelectedClusterMembership>> | null = null
359
+ // Membership cannot ride the historical requested-site echo: it is
360
+ // returned only for this configured publication's signed request.
361
+ if (siteId !== 'default' && requestedSite === siteId) {
362
+ try {
363
+ selected = await readSelectedClusterMembership(ctx.db, siteId)
364
+ } catch (error) {
365
+ if (
366
+ !(error instanceof Error && error.message.startsWith('cluster_membership_unknown:')) &&
367
+ !isMissingInsightsTable(error)
368
+ )
369
+ throw error
370
+ }
371
+ }
243
372
  return json({
244
373
  ok: true,
245
374
  site: requestedSite ?? siteId,
246
375
  authors: authorsResult.results ?? [],
247
376
  existing: existingResult.results ?? [],
377
+ cluster_membership: selected ? 'selected' : 'unknown',
378
+ cluster_selections:
379
+ selected?.map((membership) => ({
380
+ site_id: membership.site_id,
381
+ intent_key: membership.intent_key,
382
+ desk: membership.desk,
383
+ decision_ref: membership.decision_ref,
384
+ taxonomy_ref: membership.taxonomy_ref,
385
+ hub_content_id: membership.hub.content_id,
386
+ hub_revision_id: membership.hub.revision_id,
387
+ child_content_ids: membership.children.map((page) => page.content_id),
388
+ child_revision_ids: membership.children.map((page) => page.revision_id),
389
+ })) ?? null,
248
390
  })
249
391
  },
250
392
  }
@@ -60,8 +60,9 @@ import { json, type RouteContext } from './context.js'
60
60
  // family rather than inventing a second one.
61
61
  export async function resolveFoundryHmacSecret(
62
62
  env: Record<string, unknown> | undefined,
63
+ binding: 'FOUNDRY_HMAC_SECRET' | 'CMS_FOUNDRY_HMAC_SECRET' = 'FOUNDRY_HMAC_SECRET',
63
64
  ): Promise<string | null> {
64
- const raw = env?.FOUNDRY_HMAC_SECRET
65
+ const raw = env?.[binding]
65
66
  if (typeof raw === 'string') return raw || null
66
67
  if (raw && typeof (raw as { get?: unknown }).get === 'function') {
67
68
  try {
@@ -5,10 +5,11 @@ import { type CmsProviders, type LlmDispatch, ProviderHttpError } from '../provi
5
5
  import { canLabelSurveyAnswers } from '../surveys/access.js'
6
6
  import {
7
7
  containsPersonalDetails,
8
- SURVEY_AUTO_LABEL_BATCH_LIMIT,
8
+ SURVEY_AUTO_LABEL_MAX_ATTEMPTS,
9
9
  type SurveyAutoLabelAttemptStore,
10
10
  type SurveyAutoLabelCandidate,
11
11
  scheduleSurveyAutoLabel,
12
+ surveyAutoLabelAttemptKey,
12
13
  } from '../surveys/auto-label.js'
13
14
  import {
14
15
  SURVEY_ERROR_CODE_HEADER,
@@ -86,7 +87,15 @@ export function createSurveyRoutes(config: SurveyRouteConfig): SurveyRouteHandle
86
87
  scheduleAutoLabelIfEnabled(ctx, config, surveyId, unlabeled)
87
88
  return responseJson(
88
89
  canLabelSurveyAnswers(caller?.role)
89
- ? withOwnerLabelCounts(withOwnerLabels(parsed.data, labels), unlabeled)
90
+ ? await withOwnerClassificationStatus(
91
+ withOwnerLabelCounts(
92
+ withOwnerLabels(parsed.data, labels, !!config.autoLabel),
93
+ unlabeled,
94
+ ),
95
+ config,
96
+ surveyId,
97
+ unlabeled,
98
+ )
90
99
  : withholdNonGeneralAnswers(parsed.data, labels),
91
100
  )
92
101
  } catch (error) {
@@ -334,9 +343,43 @@ function nonOwnerAnswer(
334
343
  * automatic ones). Non-owner payloads are never decorated — they pass through
335
344
  * withholdNonGeneralAnswers untouched.
336
345
  */
337
- function withOwnerLabels(data: SurveyResults, labels: AnswerLabelIndex): SurveyResults {
346
+ function withOwnerLabels(
347
+ data: SurveyResults,
348
+ labels: AnswerLabelIndex,
349
+ exposeWrittenChoices = false,
350
+ ): SurveyResults {
351
+ const questions = questionIndex(data)
352
+ const writtenChoices = new Map(
353
+ unlabeledWrittenAnswers(data, new Map())
354
+ .filter((candidate) => !questions.textQuestionIds.has(candidate.questionId))
355
+ .map((candidate) => [
356
+ answerLabelKey(candidate.responseId, candidate.questionId),
357
+ candidate.answerText,
358
+ ]),
359
+ )
338
360
  return {
339
361
  ...data,
362
+ responses: exposeWrittenChoices
363
+ ? data.responses.map((response) => ({
364
+ ...response,
365
+ answers: response.answers?.map((answer) => {
366
+ const key = answerLabelKey(response.id, answer.questionId)
367
+ const writtenValue = writtenChoices.get(key)
368
+ if (writtenValue === undefined) return answer
369
+ const row = labels.get(key)
370
+ return {
371
+ ...answer,
372
+ writtenValue,
373
+ ...(row
374
+ ? {
375
+ label: row.label,
376
+ ...(isAnswerLabelSource(row.source) ? { labelSource: row.source } : {}),
377
+ }
378
+ : {}),
379
+ }
380
+ }),
381
+ }))
382
+ : data.responses,
340
383
  freeText: data.freeText.map((entry) => {
341
384
  const row = labels.get(answerLabelKey(entry.responseId, entry.questionId))
342
385
  if (row === undefined) return entry
@@ -372,6 +415,64 @@ function withOwnerLabelCounts(
372
415
  }
373
416
  }
374
417
 
418
+ /** Classification status contains fixed states and timestamps, never model replies/errors. */
419
+ async function withOwnerClassificationStatus(
420
+ data: SurveyResults,
421
+ config: SurveyRouteConfig,
422
+ surveyId: string,
423
+ unlabeled: SurveyAutoLabelCandidate[],
424
+ ): Promise<SurveyResults> {
425
+ if (!config.autoLabel) return data
426
+ type Status = Pick<SurveyResults['freeText'][number], 'classificationStatus' | 'nextAttemptAt'>
427
+ const states = new Map<string, Status>(
428
+ await Promise.all(
429
+ unlabeled.map(async (candidate): Promise<[string, Status]> => {
430
+ const key = answerLabelKey(candidate.responseId, candidate.questionId)
431
+ if (containsPersonalDetails(candidate.answerText))
432
+ return [key, { classificationStatus: 'manual_review' }]
433
+ try {
434
+ const record = await config.autoLabel!.attempts.get(
435
+ surveyAutoLabelAttemptKey(surveyId, candidate.responseId, candidate.questionId),
436
+ )
437
+ const now = Date.now()
438
+ const classificationStatus: Status['classificationStatus'] = !record
439
+ ? 'queued'
440
+ : record.inflightUntil > now
441
+ ? 'pending'
442
+ : record.inflightUntil > 0
443
+ ? 'expired'
444
+ : record.attempts >= SURVEY_AUTO_LABEL_MAX_ATTEMPTS
445
+ ? 'manual_review'
446
+ : record.reason
447
+ ? 'retry'
448
+ : 'queued'
449
+ return [
450
+ key,
451
+ { classificationStatus, ...(record?.nextAt ? { nextAttemptAt: record.nextAt } : {}) },
452
+ ]
453
+ } catch {
454
+ return [key, { classificationStatus: 'unavailable' }]
455
+ }
456
+ }),
457
+ ),
458
+ )
459
+ return {
460
+ ...data,
461
+ freeText: data.freeText.map((entry) => ({
462
+ ...entry,
463
+ ...states.get(answerLabelKey(entry.responseId, entry.questionId)),
464
+ })),
465
+ responses: data.responses.map((response) => ({
466
+ ...response,
467
+ answers: response.answers?.map((answer) =>
468
+ answer.writtenValue === undefined
469
+ ? answer
470
+ : { ...answer, ...states.get(answerLabelKey(response.id, answer.questionId)) },
471
+ ),
472
+ })),
473
+ }
474
+ }
475
+
375
476
  /**
376
477
  * Hand the current page's unlabeled written answers to the background
377
478
  * labeler (surveys.autoLabel). A no-op unless the option is on, the host
@@ -386,7 +487,7 @@ function scheduleAutoLabelIfEnabled(
386
487
  unlabeled: SurveyAutoLabelCandidate[],
387
488
  ): void {
388
489
  if (!config.autoLabel?.llm || !ctx.waitUntil) return
389
- const batch = unlabeled.slice(0, SURVEY_AUTO_LABEL_BATCH_LIMIT)
490
+ const batch = unlabeled
390
491
  if (batch.length === 0) return
391
492
  scheduleSurveyAutoLabel(
392
493
  {
@@ -418,6 +519,7 @@ function unlabeledWrittenAnswers(
418
519
  const candidates: SurveyAutoLabelCandidate[] = []
419
520
  const seen = new Set<string>()
420
521
  const consider = (responseId: string, questionId: string, answerText: string) => {
522
+ if (answerText.trim().length === 0) return
421
523
  const key = answerLabelKey(responseId, questionId)
422
524
  if (seen.has(key) || labels.has(key)) return
423
525
  seen.add(key)
@@ -115,12 +115,42 @@ export const InsightCompetitorArticleSchema = z.object({
115
115
  covered: z.boolean().optional(),
116
116
  })
117
117
 
118
+ const PublishedClusterPageSchema = z
119
+ .object({
120
+ content_id: z.string().min(1),
121
+ revision_id: z.string().min(1),
122
+ url: z.string().url(),
123
+ title: z.string().min(1),
124
+ })
125
+ .strict()
126
+
127
+ export const InsightSelectedClusterMembershipSchema = z
128
+ .object({
129
+ site_id: z.string().min(1),
130
+ run_id: z.string().min(1),
131
+ intent_key: z.string().min(1),
132
+ desk: z.string().min(1),
133
+ decision_ref: z.string().min(1),
134
+ taxonomy_ref: z.string().min(1),
135
+ hub: PublishedClusterPageSchema,
136
+ children: z.array(PublishedClusterPageSchema).min(4),
137
+ })
138
+ .strict()
139
+ .refine((membership) => {
140
+ const members = [membership.hub, ...membership.children]
141
+ return (
142
+ new Set(members.map((page) => page.content_id)).size === members.length &&
143
+ new Set(members.map((page) => page.url)).size === members.length
144
+ )
145
+ }, 'selected hub and children must be distinct published pages')
146
+
118
147
  export const InsightBriefSchema = z.object({
119
148
  why_now: z.string().min(1),
120
149
  angle: z.string().min(1),
121
150
  cluster: z.object({
122
151
  canonical: z.string().min(1),
123
152
  spokes: z.array(z.string().min(1)),
153
+ selected: InsightSelectedClusterMembershipSchema.optional(),
124
154
  }),
125
155
  sources: z.array(
126
156
  z.object({
@@ -200,6 +230,21 @@ export const InsightsIngestPayloadSchema = z
200
230
  // replace DELETE by meta.site_id + meta.run_date — never run_date alone).
201
231
  .superRefine((payload, ctx) => {
202
232
  for (const [i, row] of payload.rows.entries()) {
233
+ const selected = row.brief.cluster.selected
234
+ if (
235
+ selected &&
236
+ (selected.site_id !== row.site_id ||
237
+ selected.run_id !== payload.meta.run_id ||
238
+ selected.intent_key !== row.intent_key ||
239
+ selected.desk !== row.desk)
240
+ ) {
241
+ ctx.addIssue({
242
+ code: z.ZodIssueCode.custom,
243
+ path: ['rows', i, 'brief', 'cluster', 'selected'],
244
+ message:
245
+ 'selected membership must bind the same site, run, desk and intent as its signed row',
246
+ })
247
+ }
203
248
  if (row.site_id !== payload.meta.site_id || row.run_date !== payload.meta.run_date) {
204
249
  ctx.addIssue({
205
250
  code: z.ZodIssueCode.custom,