@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.
- package/README.md +23 -0
- package/dist/engine/published-content.d.ts +4 -0
- package/dist/engine/published-content.d.ts.map +1 -1
- package/dist/engine/published-content.js +61 -19
- package/dist/engine/published-content.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/integration/options.d.ts +226 -13
- package/dist/integration/options.d.ts.map +1 -1
- package/dist/integration/options.js +17 -2
- package/dist/integration/options.js.map +1 -1
- package/dist/providers/types.d.ts +17 -1
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/providers/types.js.map +1 -1
- package/dist/routes/content-insights.d.ts +4 -0
- package/dist/routes/content-insights.d.ts.map +1 -1
- package/dist/routes/content-insights.js +104 -0
- package/dist/routes/content-insights.js.map +1 -1
- package/dist/routes/surveys.d.ts.map +1 -1
- package/dist/routes/surveys.js +80 -4
- package/dist/routes/surveys.js.map +1 -1
- package/dist/schema/insights-ingest.d.ts +64 -64
- package/dist/schema/migrations.d.ts.map +1 -1
- package/dist/schema/migrations.js +7 -4
- package/dist/schema/migrations.js.map +1 -1
- package/dist/surveys/auto-label.d.ts +31 -4
- package/dist/surveys/auto-label.d.ts.map +1 -1
- package/dist/surveys/auto-label.js +160 -29
- package/dist/surveys/auto-label.js.map +1 -1
- package/dist/surveys/readout.d.ts +69 -0
- package/dist/surveys/readout.d.ts.map +1 -0
- package/dist/surveys/readout.js +157 -0
- package/dist/surveys/readout.js.map +1 -0
- package/dist/surveys/schema.d.ts +156 -0
- package/dist/surveys/schema.d.ts.map +1 -1
- package/dist/surveys/schema.js +50 -1
- package/dist/surveys/schema.js.map +1 -1
- package/dist/ui/api/content/insights-inputs.d.ts.map +1 -1
- package/dist/ui/api/content/insights-inputs.js +11 -1
- package/dist/ui/api/content/insights-inputs.js.map +1 -1
- package/dist/ui/client/mount.d.ts.map +1 -1
- package/dist/ui/client/mount.js +1 -0
- package/dist/ui/client/mount.js.map +1 -1
- package/dist/ui/screens/PodcastListeningScreen.d.ts.map +1 -1
- package/dist/ui/screens/PodcastListeningScreen.js +16 -2
- package/dist/ui/screens/PodcastListeningScreen.js.map +1 -1
- package/dist/ui/screens/SurveyResponsesScreen.d.ts +36 -3
- package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -1
- package/dist/ui/screens/SurveyResponsesScreen.js +271 -107
- package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -1
- package/dist/ui/screens/podcast-listening-data.d.ts +1 -1
- package/dist/ui/screens/podcast-listening-data.d.ts.map +1 -1
- package/dist/ui/screens/podcast-listening-data.js +26 -1
- package/dist/ui/screens/podcast-listening-data.js.map +1 -1
- package/dist/ui/screens/registry.d.ts +3 -1
- package/dist/ui/screens/registry.d.ts.map +1 -1
- package/dist/ui/screens/registry.js +4 -2
- package/dist/ui/screens/registry.js.map +1 -1
- package/dist/ui/styles/broadsheet.css +136 -0
- package/package.json +2 -3
- package/src/engine/published-content.ts +86 -19
- package/src/index.ts +1 -0
- package/src/integration/options.ts +21 -1
- package/src/providers/types.ts +16 -1
- package/src/routes/content-insights.ts +104 -0
- package/src/routes/surveys.ts +106 -4
- package/src/schema/migrations.ts +7 -4
- package/src/surveys/auto-label.ts +204 -33
- package/src/surveys/readout.ts +243 -0
- package/src/surveys/schema.ts +59 -1
- package/src/ui/api/content/insights-inputs.ts +8 -1
- package/src/ui/client/mount.tsx +1 -0
- package/src/ui/screens/PodcastListeningScreen.tsx +30 -0
- package/src/ui/screens/SurveyResponsesScreen.tsx +964 -305
- package/src/ui/screens/podcast-listening-data.ts +33 -1
- package/src/ui/screens/registry.tsx +6 -2
- 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
|
-
|
|
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
|
-
|
|
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
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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({
|
package/src/providers/types.ts
CHANGED
|
@@ -110,7 +110,9 @@ export interface PodcastListeningEpisode {
|
|
|
110
110
|
* must render "—", never a silent zero.
|
|
111
111
|
*/
|
|
112
112
|
totalListeningSeconds: number | null
|
|
113
|
-
/**
|
|
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
|
package/src/routes/surveys.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
?
|
|
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(
|
|
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
|
|
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)
|
package/src/schema/migrations.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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'
|