@growth-labs/cms 0.8.24 → 0.8.28

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 (79) hide show
  1. package/README.md +23 -0
  2. package/dist/engine/published-content.d.ts +4 -0
  3. package/dist/engine/published-content.d.ts.map +1 -1
  4. package/dist/engine/published-content.js +61 -19
  5. package/dist/engine/published-content.js.map +1 -1
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/integration/options.d.ts +226 -13
  11. package/dist/integration/options.d.ts.map +1 -1
  12. package/dist/integration/options.js +17 -2
  13. package/dist/integration/options.js.map +1 -1
  14. package/dist/providers/types.d.ts +17 -1
  15. package/dist/providers/types.d.ts.map +1 -1
  16. package/dist/providers/types.js.map +1 -1
  17. package/dist/routes/content-insights.d.ts +4 -0
  18. package/dist/routes/content-insights.d.ts.map +1 -1
  19. package/dist/routes/content-insights.js +104 -0
  20. package/dist/routes/content-insights.js.map +1 -1
  21. package/dist/routes/surveys.d.ts.map +1 -1
  22. package/dist/routes/surveys.js +80 -4
  23. package/dist/routes/surveys.js.map +1 -1
  24. package/dist/schema/insights-ingest.d.ts +64 -64
  25. package/dist/schema/migrations.d.ts.map +1 -1
  26. package/dist/schema/migrations.js +7 -4
  27. package/dist/schema/migrations.js.map +1 -1
  28. package/dist/surveys/auto-label.d.ts +31 -4
  29. package/dist/surveys/auto-label.d.ts.map +1 -1
  30. package/dist/surveys/auto-label.js +160 -29
  31. package/dist/surveys/auto-label.js.map +1 -1
  32. package/dist/surveys/readout.d.ts +69 -0
  33. package/dist/surveys/readout.d.ts.map +1 -0
  34. package/dist/surveys/readout.js +157 -0
  35. package/dist/surveys/readout.js.map +1 -0
  36. package/dist/surveys/schema.d.ts +156 -0
  37. package/dist/surveys/schema.d.ts.map +1 -1
  38. package/dist/surveys/schema.js +50 -1
  39. package/dist/surveys/schema.js.map +1 -1
  40. package/dist/ui/api/content/insights-inputs.d.ts.map +1 -1
  41. package/dist/ui/api/content/insights-inputs.js +11 -1
  42. package/dist/ui/api/content/insights-inputs.js.map +1 -1
  43. package/dist/ui/client/mount.d.ts.map +1 -1
  44. package/dist/ui/client/mount.js +1 -0
  45. package/dist/ui/client/mount.js.map +1 -1
  46. package/dist/ui/screens/PodcastListeningScreen.d.ts.map +1 -1
  47. package/dist/ui/screens/PodcastListeningScreen.js +16 -2
  48. package/dist/ui/screens/PodcastListeningScreen.js.map +1 -1
  49. package/dist/ui/screens/SurveyResponsesScreen.d.ts +36 -3
  50. package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -1
  51. package/dist/ui/screens/SurveyResponsesScreen.js +271 -107
  52. package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -1
  53. package/dist/ui/screens/podcast-listening-data.d.ts +1 -1
  54. package/dist/ui/screens/podcast-listening-data.d.ts.map +1 -1
  55. package/dist/ui/screens/podcast-listening-data.js +26 -1
  56. package/dist/ui/screens/podcast-listening-data.js.map +1 -1
  57. package/dist/ui/screens/registry.d.ts +3 -1
  58. package/dist/ui/screens/registry.d.ts.map +1 -1
  59. package/dist/ui/screens/registry.js +4 -2
  60. package/dist/ui/screens/registry.js.map +1 -1
  61. package/dist/ui/styles/broadsheet.css +136 -0
  62. package/package.json +2 -3
  63. package/src/engine/published-content.ts +86 -19
  64. package/src/index.ts +1 -0
  65. package/src/integration/options.ts +21 -1
  66. package/src/providers/types.ts +16 -1
  67. package/src/routes/content-insights.ts +104 -0
  68. package/src/routes/surveys.ts +106 -4
  69. package/src/schema/migrations.ts +7 -4
  70. package/src/surveys/auto-label.ts +204 -33
  71. package/src/surveys/readout.ts +243 -0
  72. package/src/surveys/schema.ts +59 -1
  73. package/src/ui/api/content/insights-inputs.ts +8 -1
  74. package/src/ui/client/mount.tsx +1 -0
  75. package/src/ui/screens/PodcastListeningScreen.tsx +30 -0
  76. package/src/ui/screens/SurveyResponsesScreen.tsx +964 -305
  77. package/src/ui/screens/podcast-listening-data.ts +33 -1
  78. package/src/ui/screens/registry.tsx +6 -2
  79. package/src/ui/styles/broadsheet.css +136 -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,16 +589,38 @@ 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'
598
+ // A missing revision or type must reach the integrity checks, rather than
599
+ // disappearing behind a type/channel filter as a successful empty list.
600
+ const invalidRevisionType = `CASE WHEN json_valid(cr.payload_json) = 1
601
+ THEN COALESCE(json_type(cr.payload_json, '$.item.type') != 'text'
602
+ OR json_extract(cr.payload_json, '$.item.type') NOT IN ('article', 'video', 'podcast', 'newsletter', 'page'), 1)
603
+ ELSE 1 END`
566
604
  if (normalized.type) {
567
- predicates.push("json_extract(cr.payload_json, '$.item.type') = ?")
605
+ predicates.push(`(${invalidRevisionType} OR json_extract(cr.payload_json, '$.item.type') = ?)`)
568
606
  values.push(normalized.type)
569
607
  }
570
608
  if (normalized.channel) {
571
- predicates.push("json_extract(cr.payload_json, '$.item.channel') = ?")
609
+ predicates.push(
610
+ `(${invalidRevisionType} OR json_extract(cr.payload_json, '$.item.channel') = ?)`,
611
+ )
572
612
  values.push(normalized.channel)
573
613
  }
574
614
  if (normalized.cursor) {
575
- 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
+ }
576
624
  values.push(
577
625
  normalized.cursor.publishedAt,
578
626
  normalized.cursor.publishedAt,
@@ -583,10 +631,10 @@ export async function listPublishedContent(
583
631
  const reader = openReadSession(db)
584
632
  const outlineResult = await reader
585
633
  .prepare(
586
- `${normalized.bodies ? SELECT_PUBLISHED_CONTENT_OUTLINE : SELECT_PUBLISHED_CONTENT_OUTLINE_BODYLESS}
587
- JOIN content_revisions cr ON cr.id = ci.published_revision_id
634
+ `${publishedOutlineSelect(normalized.bodies, contentDate)}
635
+ LEFT JOIN content_revisions cr ON cr.id = ci.published_revision_id
588
636
  WHERE ${predicates.join('\n AND ')}
589
- ORDER BY ci.published_at DESC, ci.id DESC
637
+ ORDER BY ${orderBy}
590
638
  LIMIT ?`,
591
639
  )
592
640
  .bind(...values, normalized.limit + 1)
@@ -608,6 +656,17 @@ LIMIT ?`,
608
656
  'published revision outline is malformed',
609
657
  )
610
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
+ }
611
670
  if (outline.payload_bytes > MAX_PUBLISHED_CONTENT_PAYLOAD_BYTES) {
612
671
  throw new PublishedContentReadError(
613
672
  'published_payload_limit_exceeded',
@@ -681,7 +740,15 @@ WHERE ci.status = 'published'
681
740
  return {
682
741
  items,
683
742
  nextCursor:
684
- 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,
685
752
  }
686
753
  } catch (error) {
687
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({
@@ -110,7 +110,9 @@ export interface PodcastListeningEpisode {
110
110
  * must render "—", never a silent zero.
111
111
  */
112
112
  totalListeningSeconds: number | null
113
- /** totalListeningSeconds / listeners; null when either input is unmeasured. */
113
+ /** Sessions with measured listening seconds; absent on legacy providers. */
114
+ measuredListeners?: number | null
115
+ /** totalListeningSeconds / measuredListeners; null when either is unmeasured. */
114
116
  avgListeningSecondsPerListener: number | null
115
117
  }
116
118
 
@@ -129,9 +131,13 @@ export interface PodcastListening {
129
131
  completes: number
130
132
  completionRate: number
131
133
  totalListeningSeconds: number | null
134
+ measuredListeners?: number | null
135
+ avgListeningSecondsPerListener?: number | null
132
136
  }
133
137
  /** Echo of the requested window, ISO 8601 instants. */
134
138
  range: { from: string; to: string }
139
+ /** First recorded listening-time event; plays may cover an earlier period. */
140
+ listeningStartedAt?: string
135
141
  }
136
142
 
137
143
  export interface AnalyticsProvider {
@@ -434,8 +440,17 @@ export type LlmDispatchResult =
434
440
  | { text: string; status?: 'ok' }
435
441
  | { text: string; status: 'unavailable'; reason?: string }
436
442
  | ReadableStream
443
+ export type LlmDispatchSubmission =
444
+ | { status: 'accepted'; taskId: string }
445
+ | { status: 'unavailable'; reason: string }
437
446
  export interface LlmDispatch {
438
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>
439
454
  }
440
455
 
441
456
  export interface FoundryJobSpec {
@@ -10,6 +10,7 @@ import {
10
10
  undismissInsight,
11
11
  } from '../engine/content-insights.js'
12
12
  import { verifyHmac } from '../engine/foundry-dispatch.js'
13
+ import { listPublishedContent, PublishedContentReadError } from '../engine/published-content.js'
13
14
  import { InsightsIngestPayloadSchema } from '../schema/insights-ingest.js'
14
15
  import { InsightDismissalReasonCodeSchema } from '../schema/types.js'
15
16
  import type { CmsRouteConfig } from './config.js'
@@ -29,6 +30,103 @@ const DismissSchema = z
29
30
  .strict()
30
31
  const IntentKeySchema = z.object({ intentKey: z.string().min(1) })
31
32
 
33
+ const PublishedCursorSchema = z
34
+ .object({
35
+ publishedAt: z.number().int().nonnegative(),
36
+ contentId: z.string().min(1).max(256),
37
+ })
38
+ .strict()
39
+
40
+ const PublishedArticleStateSchema = z
41
+ .object({
42
+ content_id: z.string().min(1),
43
+ revision_id: z.string().min(1),
44
+ slug: z.string().min(1),
45
+ title: z.string().min(1),
46
+ canonical_url: z.string().url().nullable(),
47
+ published_at: z.number().int().nonnegative(),
48
+ source_id: z.string().nullable(),
49
+ youtube_video_id: z
50
+ .string()
51
+ .regex(/^[A-Za-z0-9_-]{11}$/)
52
+ .nullable(),
53
+ hero_image_id: z.string().nullable(),
54
+ hero_image_caption: z.string().nullable(),
55
+ })
56
+ .strict()
57
+
58
+ const PublishedInputsResponseSchema = z
59
+ .object({
60
+ ok: z.literal(true),
61
+ site: z.string().min(1),
62
+ observed_at: z.string().datetime(),
63
+ published: z.array(PublishedArticleStateSchema),
64
+ next_cursor: PublishedCursorSchema.nullable(),
65
+ complete: z.boolean(),
66
+ })
67
+ .strict()
68
+
69
+ // Additive read mode of the existing signed inputs route. Read the immutable
70
+ // published snapshot, never mutable draft fields. Reuse the CMS reader's
71
+ // revision checks and pagination; return only named public fields, not a raw
72
+ // payload that may contain internal machine trigger tokens.
73
+ async function publishedInputs(ctx: RouteContext, url: URL, siteId: string): Promise<Response> {
74
+ if (url.searchParams.get('published') !== '1')
75
+ return json({ error: 'invalid_published_mode' }, 400)
76
+ if (siteId === 'default') return json({ error: 'publication_identity_not_configured' }, 503)
77
+ if (url.searchParams.get('site') !== siteId) return json({ error: 'publication_mismatch' }, 403)
78
+ const limit = z.coerce
79
+ .number()
80
+ .int()
81
+ .min(1)
82
+ .max(100)
83
+ .safeParse(url.searchParams.get('limit') ?? '100')
84
+ if (!limit.success) return json({ error: 'invalid_limit' }, 400)
85
+ let cursor: z.infer<typeof PublishedCursorSchema> | null = null
86
+ const cursorText = url.searchParams.get('cursor')
87
+ if (cursorText !== null) {
88
+ try {
89
+ const parsed = PublishedCursorSchema.safeParse(JSON.parse(cursorText))
90
+ if (!parsed.success) return json({ error: 'invalid_cursor' }, 400)
91
+ cursor = parsed.data
92
+ } catch {
93
+ return json({ error: 'invalid_cursor' }, 400)
94
+ }
95
+ }
96
+ try {
97
+ const page = await listPublishedContent(ctx.db, {
98
+ type: 'article',
99
+ bodies: false,
100
+ limit: limit.data,
101
+ cursor,
102
+ })
103
+ const result = PublishedInputsResponseSchema.safeParse({
104
+ ok: true,
105
+ site: siteId,
106
+ observed_at: new Date().toISOString(),
107
+ published: page.items.map((content) => ({
108
+ content_id: content.contentId,
109
+ revision_id: content.revisionId,
110
+ slug: content.slug,
111
+ title: content.payload.item.title,
112
+ canonical_url: content.payload.item.canonical_url ?? null,
113
+ published_at: content.publishedAt,
114
+ source_id: content.payload.item.source_id ?? null,
115
+ youtube_video_id: content.metadata.videoId ?? null,
116
+ hero_image_id: content.payload.item.hero_image_id ?? null,
117
+ hero_image_caption: content.payload.item.hero_image_caption ?? null,
118
+ })),
119
+ next_cursor: page.nextCursor,
120
+ complete: page.nextCursor === null,
121
+ })
122
+ if (!result.success) return json({ error: 'published_state_invalid' }, 409)
123
+ return json(result.data)
124
+ } catch (error) {
125
+ if (error instanceof PublishedContentReadError) return json({ error: error.code }, 409)
126
+ throw error
127
+ }
128
+ }
129
+
32
130
  /** Resolve YYYY-MM-DD; defaults to the current UTC date when no ?runDate is given. */
33
131
  function resolveRunDate(ctx: RouteContext): string {
34
132
  const url = new URL(ctx.request.url)
@@ -85,6 +183,10 @@ export interface ContentInsightsRouteHandlers {
85
183
  * fronts/schedule (X-Foundry-Timestamp + X-Foundry-Signature over the raw
86
184
  * query string, verified against FOUNDRY_HMAC_SECRET) -- a sibling poll,
87
185
  * not a new auth scheme.
186
+ * `published=1&site=<configured-publication>` reads a page of canonical
187
+ * published article revisions instead, with `limit` (1..100) and JSON
188
+ * `cursor` from the prior page. `complete` means the pagination ended;
189
+ * no total is inferred from one page. Missing URLs/video IDs are null.
88
190
  */
89
191
  insightsInputs(ctx: RouteContext): Promise<Response>
90
192
  }
@@ -231,6 +333,8 @@ export function createContentInsightsRoutes(
231
333
  )
232
334
  if (denied) return denied
233
335
 
336
+ if (url.searchParams.has('published')) return publishedInputs(ctx, url, siteId)
337
+
234
338
  const requestedSite = url.searchParams.get('site')
235
339
  const authorsResult = await ctx.db.prepare(AUTHORS_QUERY).all<{
236
340
  name: string
@@ -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)
@@ -946,16 +946,19 @@ CREATE TABLE survey_answer_labels (
946
946
  * `wrangler d1 migrations apply` for real deploys.
947
947
  */
948
948
  export async function applyMigrations(db: D1Like): Promise<void> {
949
+ // One statement per line (whitespace collapsed so D1's per-line exec parser
950
+ // sees each complete), all sent in a single exec: a call per statement cost
951
+ // ~600 ms per schema (192 round trips), a single call ~60 ms.
952
+ const lines: string[] = []
949
953
  for (const statement of splitStatements(CMS_MIGRATION_SQL)) {
950
- // Collapse internal whitespace/newlines so D1's per-line exec parser sees
951
- // one complete statement.
952
- await db.exec(`${statement.replace(/\s+/g, ' ').trim()};`)
954
+ lines.push(`${statement.replace(/\s+/g, ' ').trim()};`)
953
955
  }
954
956
  for (const migration of CMS_MIGRATIONS_0002_PLUS) {
955
957
  for (const statement of splitStatements(migration.sql)) {
956
- await db.exec(`${statement.replace(/\s+/g, ' ').trim()};`)
958
+ lines.push(`${statement.replace(/\s+/g, ' ').trim()};`)
957
959
  }
958
960
  }
961
+ await db.exec(lines.join('\n'))
959
962
  }
960
963
 
961
964
  export type { CmsTableName } from './tables.js'