@growth-labs/cms 0.8.20 → 0.8.23
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/dist/cli/foundry-emit.d.ts +14 -0
- package/dist/cli/foundry-emit.d.ts.map +1 -0
- package/dist/cli/foundry-emit.js +69 -0
- package/dist/cli/foundry-emit.js.map +1 -0
- package/dist/engine/fronts-publish-intent.d.ts +1 -1
- package/dist/engine/fronts-publish-intent.d.ts.map +1 -1
- package/dist/engine/fronts-publish-intent.js +12 -1
- package/dist/engine/fronts-publish-intent.js.map +1 -1
- package/dist/engine/publication-receipt/receipt.d.ts +20 -20
- package/dist/engine/publisher.d.ts +16 -1
- package/dist/engine/publisher.d.ts.map +1 -1
- package/dist/engine/publisher.js +55 -4
- package/dist/engine/publisher.js.map +1 -1
- package/dist/engine/tags.d.ts.map +1 -1
- package/dist/engine/tags.js +18 -1
- package/dist/engine/tags.js.map +1 -1
- package/dist/engine/topics.d.ts.map +1 -1
- package/dist/engine/topics.js +4 -1
- package/dist/engine/topics.js.map +1 -1
- package/dist/engine/video-source.d.ts +21 -0
- package/dist/engine/video-source.d.ts.map +1 -1
- package/dist/engine/video-source.js +40 -0
- package/dist/engine/video-source.js.map +1 -1
- package/dist/foundry-emit.d.ts +146 -0
- package/dist/foundry-emit.d.ts.map +1 -0
- package/dist/foundry-emit.js +189 -0
- package/dist/foundry-emit.js.map +1 -0
- package/dist/integration/index.d.ts.map +1 -1
- package/dist/integration/index.js +9 -0
- package/dist/integration/index.js.map +1 -1
- package/dist/integration/options.d.ts +24 -0
- package/dist/integration/options.d.ts.map +1 -1
- package/dist/integration/options.js +22 -0
- package/dist/integration/options.js.map +1 -1
- package/dist/providers/null.d.ts.map +1 -1
- package/dist/providers/null.js +6 -0
- package/dist/providers/null.js.map +1 -1
- package/dist/providers/types.d.ts +56 -0
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/providers/types.js.map +1 -1
- package/dist/routes/ai.d.ts.map +1 -1
- package/dist/routes/ai.js +21 -13
- package/dist/routes/ai.js.map +1 -1
- package/dist/routes/analytics.d.ts +1 -0
- package/dist/routes/analytics.d.ts.map +1 -1
- package/dist/routes/analytics.js +20 -0
- package/dist/routes/analytics.js.map +1 -1
- package/dist/routes/content.d.ts +27 -0
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +58 -12
- package/dist/routes/content.js.map +1 -1
- package/dist/routes/foundry-publish.d.ts +50 -0
- package/dist/routes/foundry-publish.d.ts.map +1 -1
- package/dist/routes/foundry-publish.js +540 -27
- package/dist/routes/foundry-publish.js.map +1 -1
- package/dist/routes/machine-context.d.ts +6 -0
- package/dist/routes/machine-context.d.ts.map +1 -0
- package/dist/routes/machine-context.js +20 -0
- package/dist/routes/machine-context.js.map +1 -0
- package/dist/routes/surveys.d.ts +14 -1
- package/dist/routes/surveys.d.ts.map +1 -1
- package/dist/routes/surveys.js +515 -10
- package/dist/routes/surveys.js.map +1 -1
- package/dist/schema/insights-ingest.d.ts +8 -8
- package/dist/schema/migrations.d.ts.map +1 -1
- package/dist/schema/migrations.js +15 -0
- package/dist/schema/migrations.js.map +1 -1
- package/dist/schema/tables.d.ts +1 -1
- package/dist/schema/tables.d.ts.map +1 -1
- package/dist/schema/tables.js +2 -0
- package/dist/schema/tables.js.map +1 -1
- package/dist/surveys/access.d.ts +1 -0
- package/dist/surveys/access.d.ts.map +1 -1
- package/dist/surveys/access.js +13 -4
- package/dist/surveys/access.js.map +1 -1
- package/dist/surveys/auto-label.d.ts +100 -0
- package/dist/surveys/auto-label.d.ts.map +1 -0
- package/dist/surveys/auto-label.js +325 -0
- package/dist/surveys/auto-label.js.map +1 -0
- package/dist/surveys/schema.d.ts +38 -12
- package/dist/surveys/schema.d.ts.map +1 -1
- package/dist/surveys/schema.js +18 -1
- package/dist/surveys/schema.js.map +1 -1
- package/dist/ui/api/_content-config.d.ts.map +1 -1
- package/dist/ui/api/_content-config.js +4 -0
- package/dist/ui/api/_content-config.js.map +1 -1
- package/dist/ui/api/analytics.d.ts.map +1 -1
- package/dist/ui/api/analytics.js +5 -2
- package/dist/ui/api/analytics.js.map +1 -1
- package/dist/ui/api/foundry/articles/[external_id]/publish.d.ts +3 -0
- package/dist/ui/api/foundry/articles/[external_id]/publish.d.ts.map +1 -0
- package/dist/ui/api/foundry/articles/[external_id]/publish.js +14 -0
- package/dist/ui/api/foundry/articles/[external_id]/publish.js.map +1 -0
- package/dist/ui/api/surveys/[surveyId]/answers/label.d.ts +3 -0
- package/dist/ui/api/surveys/[surveyId]/answers/label.d.ts.map +1 -0
- package/dist/ui/api/surveys/[surveyId]/answers/label.js +17 -0
- package/dist/ui/api/surveys/[surveyId]/answers/label.js.map +1 -0
- package/dist/ui/api/surveys/[surveyId].d.ts.map +1 -1
- package/dist/ui/api/surveys/[surveyId].js +28 -1
- package/dist/ui/api/surveys/[surveyId].js.map +1 -1
- package/dist/ui/commands.d.ts.map +1 -1
- package/dist/ui/commands.js +2 -0
- package/dist/ui/commands.js.map +1 -1
- package/dist/ui/hash-router.d.ts +1 -1
- package/dist/ui/hash-router.d.ts.map +1 -1
- package/dist/ui/hash-router.js +1 -0
- package/dist/ui/hash-router.js.map +1 -1
- package/dist/ui/nav.d.ts.map +1 -1
- package/dist/ui/nav.js +1 -0
- package/dist/ui/nav.js.map +1 -1
- package/dist/ui/screens/AnalyticsScreen.d.ts +6 -1
- package/dist/ui/screens/AnalyticsScreen.d.ts.map +1 -1
- package/dist/ui/screens/AnalyticsScreen.js +13 -8
- package/dist/ui/screens/AnalyticsScreen.js.map +1 -1
- package/dist/ui/screens/PodcastListeningScreen.d.ts +6 -0
- package/dist/ui/screens/PodcastListeningScreen.d.ts.map +1 -0
- package/dist/ui/screens/PodcastListeningScreen.js +244 -0
- package/dist/ui/screens/PodcastListeningScreen.js.map +1 -0
- package/dist/ui/screens/SurveyResponsesScreen.d.ts +4 -2
- package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -1
- package/dist/ui/screens/SurveyResponsesScreen.js +71 -12
- package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -1
- package/dist/ui/screens/analytics-data.d.ts +14 -0
- package/dist/ui/screens/analytics-data.d.ts.map +1 -1
- package/dist/ui/screens/analytics-data.js +16 -0
- package/dist/ui/screens/analytics-data.js.map +1 -1
- package/dist/ui/screens/podcast-listening-data.d.ts +19 -0
- package/dist/ui/screens/podcast-listening-data.d.ts.map +1 -0
- package/dist/ui/screens/podcast-listening-data.js +98 -0
- package/dist/ui/screens/podcast-listening-data.js.map +1 -0
- package/dist/ui/screens/registry.d.ts.map +1 -1
- package/dist/ui/screens/registry.js +2 -0
- package/dist/ui/screens/registry.js.map +1 -1
- package/docs/foundry-publish.md +208 -0
- package/migrations/0033_survey_answer_labels.sql +15 -0
- package/package.json +9 -3
- package/src/cli/foundry-emit.ts +85 -0
- package/src/engine/fronts-publish-intent.ts +23 -1
- package/src/engine/publisher.ts +80 -4
- package/src/engine/tags.ts +21 -1
- package/src/engine/topics.ts +4 -1
- package/src/engine/video-source.ts +44 -0
- package/src/foundry-emit.ts +255 -0
- package/src/integration/index.ts +9 -0
- package/src/integration/options.ts +23 -0
- package/src/providers/null.ts +6 -0
- package/src/providers/types.ts +52 -0
- package/src/routes/ai.ts +29 -17
- package/src/routes/analytics.ts +21 -0
- package/src/routes/content.ts +81 -15
- package/src/routes/foundry-publish.ts +746 -29
- package/src/routes/machine-context.ts +24 -0
- package/src/routes/surveys.ts +587 -12
- package/src/schema/migrations.ts +15 -0
- package/src/schema/tables.ts +2 -0
- package/src/surveys/access.ts +14 -4
- package/src/surveys/auto-label.ts +420 -0
- package/src/surveys/schema.ts +25 -1
- package/src/ui/api/_content-config.ts +6 -0
- package/src/ui/api/analytics.ts +5 -2
- package/src/ui/api/foundry/articles/[external_id]/publish.ts +18 -0
- package/src/ui/api/surveys/[surveyId]/answers/label.ts +23 -0
- package/src/ui/api/surveys/[surveyId].ts +37 -3
- package/src/ui/commands.ts +2 -0
- package/src/ui/hash-router.ts +1 -0
- package/src/ui/nav.ts +1 -0
- package/src/ui/screens/AnalyticsScreen.tsx +34 -6
- package/src/ui/screens/PodcastListeningScreen.tsx +529 -0
- package/src/ui/screens/SurveyResponsesScreen.tsx +179 -13
- package/src/ui/screens/analytics-data.ts +18 -0
- package/src/ui/screens/podcast-listening-data.ts +114 -0
- package/src/ui/screens/registry.tsx +2 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
// @growth-labs/cms/foundry-emit — the one v2 schema for the Foundry machine
|
|
2
|
+
// article route, shared by the route (routes/foundry-publish.ts) and by every
|
|
3
|
+
// producer that emits into it, plus a pure mapper from the archive-import item
|
|
4
|
+
// shape (ContentArchiveItem, as the channel repos' compiler.mjs builds it) to a
|
|
5
|
+
// v2 body.
|
|
6
|
+
//
|
|
7
|
+
// This module must stay importable in pure Node: zod, the pure slug helpers and
|
|
8
|
+
// type-only imports. Nothing here touches D1, the network or `cloudflare:*`.
|
|
9
|
+
|
|
10
|
+
import { sha256Hex } from '@growth-labs/foundry-signing'
|
|
11
|
+
import { z } from 'zod'
|
|
12
|
+
import type { ContentArchiveItem } from './engine/publication.js'
|
|
13
|
+
import { slugify } from './engine/slug.js'
|
|
14
|
+
|
|
15
|
+
export const FOUNDRY_ARTICLE_SCHEMA_VERSION = 2 as const
|
|
16
|
+
|
|
17
|
+
/** The machine route's own source namespace; an adoption never targets it. */
|
|
18
|
+
export const FOUNDRY_SOURCE_PREFIX = 'foundry:'
|
|
19
|
+
|
|
20
|
+
/** Upper bound the publication kernel accepts for a revision's slug. */
|
|
21
|
+
export const FOUNDRY_SLUG_MAX_LENGTH = 512
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The engine's slug rule, applied verbatim: a v2 slug is accepted only when it
|
|
25
|
+
* is already canonical (`slugify(slug) === slug`), because the route never
|
|
26
|
+
* derives, rewrites or suffixes a v2 slug.
|
|
27
|
+
*/
|
|
28
|
+
export function isCanonicalSlug(value: string): boolean {
|
|
29
|
+
return value.length >= 3 && value.length <= FOUNDRY_SLUG_MAX_LENGTH && slugify(value) === value
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const HERO_IMAGE_KEY = /^[A-Za-z0-9][A-Za-z0-9._\-/]*$/
|
|
33
|
+
|
|
34
|
+
export const FoundryArticleV2Schema = z
|
|
35
|
+
.object({
|
|
36
|
+
schemaVersion: z.literal(FOUNDRY_ARTICLE_SCHEMA_VERSION),
|
|
37
|
+
slug: z.string().refine(isCanonicalSlug, {
|
|
38
|
+
message: 'slug must already be canonical: lowercase a-z, 0-9 and single hyphens, 3-512 chars',
|
|
39
|
+
}),
|
|
40
|
+
title: z.string().trim().min(3),
|
|
41
|
+
body_html: z.string().min(1),
|
|
42
|
+
hero_image_key: z
|
|
43
|
+
.string()
|
|
44
|
+
.min(1)
|
|
45
|
+
.max(1024)
|
|
46
|
+
.refine((key) => HERO_IMAGE_KEY.test(key) && !key.split('/').includes('..'), {
|
|
47
|
+
message: 'hero_image_key must be a relative object key',
|
|
48
|
+
})
|
|
49
|
+
.optional(),
|
|
50
|
+
hero_image_url: z
|
|
51
|
+
.string()
|
|
52
|
+
.url()
|
|
53
|
+
.refine((url) => url.startsWith('https://'), { message: 'hero_image_url must be https' })
|
|
54
|
+
.optional(),
|
|
55
|
+
publishAt: z.string().datetime({ offset: true }).optional(),
|
|
56
|
+
author: z.string().trim().min(1).max(200).optional(),
|
|
57
|
+
desk: z.string().trim().min(1).max(100).optional(),
|
|
58
|
+
tags: z.array(z.string().trim().min(1).max(100)).max(50).optional(),
|
|
59
|
+
description: z.string().trim().min(1).optional(),
|
|
60
|
+
visibility: z.enum(['free', 'premium']).optional(),
|
|
61
|
+
adoptSourceId: z
|
|
62
|
+
.string()
|
|
63
|
+
.min(1)
|
|
64
|
+
.max(256)
|
|
65
|
+
.refine((value) => value === value.trim() && !value.startsWith(FOUNDRY_SOURCE_PREFIX), {
|
|
66
|
+
message: 'adoptSourceId must be a trimmed bridge source id, never a foundry: id',
|
|
67
|
+
})
|
|
68
|
+
.optional(),
|
|
69
|
+
})
|
|
70
|
+
.strict()
|
|
71
|
+
.refine((body) => !(body.hero_image_key && body.hero_image_url), {
|
|
72
|
+
message: 'send hero_image_key or hero_image_url, not both',
|
|
73
|
+
path: ['hero_image_key'],
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
export type FoundryArticleV2 = z.output<typeof FoundryArticleV2Schema>
|
|
77
|
+
|
|
78
|
+
export class FoundryEmitError extends Error {
|
|
79
|
+
readonly code: string
|
|
80
|
+
|
|
81
|
+
constructor(code: string, message: string) {
|
|
82
|
+
super(message)
|
|
83
|
+
this.name = 'FoundryEmitError'
|
|
84
|
+
this.code = code
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface FoundryEmitOptions {
|
|
89
|
+
/**
|
|
90
|
+
* Overrides the publication time. Omitted: the item's `publishedAt` (unix
|
|
91
|
+
* seconds) is used when present. `null`: no publishAt, so the route writes a
|
|
92
|
+
* draft only.
|
|
93
|
+
*/
|
|
94
|
+
publishAt?: string | null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function present(value: string | null | undefined): value is string {
|
|
98
|
+
return typeof value === 'string' && value.trim().length > 0
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Map one archive-import item to a v2 route body. Pure: the same item always
|
|
103
|
+
* yields the same body, and the result is parsed by the route's own schema, so
|
|
104
|
+
* a body this function returns is one the route accepts.
|
|
105
|
+
*/
|
|
106
|
+
export function toFoundryArticleV2(
|
|
107
|
+
item: ContentArchiveItem,
|
|
108
|
+
options: FoundryEmitOptions = {},
|
|
109
|
+
): FoundryArticleV2 {
|
|
110
|
+
if (item.type !== undefined && item.type !== 'article') {
|
|
111
|
+
throw new FoundryEmitError('unsupported_type', `only articles are emitted, got ${item.type}`)
|
|
112
|
+
}
|
|
113
|
+
if (!present(item.slug)) throw new FoundryEmitError('slug_missing', 'archive item has no slug')
|
|
114
|
+
if (!present(item.title)) throw new FoundryEmitError('title_missing', 'archive item has no title')
|
|
115
|
+
if (!present(item.bodyHtml)) {
|
|
116
|
+
throw new FoundryEmitError('body_html_missing', 'archive item has no rendered bodyHtml')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let publishAt: string | undefined
|
|
120
|
+
if (options.publishAt !== undefined) {
|
|
121
|
+
publishAt = options.publishAt ?? undefined
|
|
122
|
+
} else if (typeof item.publishedAt === 'number' && Number.isFinite(item.publishedAt)) {
|
|
123
|
+
publishAt = new Date(item.publishedAt * 1_000).toISOString()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const hero = present(item.heroImageId) ? item.heroImageId.trim() : null
|
|
127
|
+
const author = present(item.byline) ? item.byline : present(item.authorId) ? item.authorId : null
|
|
128
|
+
const description = present(item.description)
|
|
129
|
+
? item.description
|
|
130
|
+
: present(item.excerpt)
|
|
131
|
+
? item.excerpt
|
|
132
|
+
: null
|
|
133
|
+
|
|
134
|
+
const body: Record<string, unknown> = {
|
|
135
|
+
schemaVersion: FOUNDRY_ARTICLE_SCHEMA_VERSION,
|
|
136
|
+
slug: item.slug,
|
|
137
|
+
title: item.title,
|
|
138
|
+
body_html: item.bodyHtml,
|
|
139
|
+
...(hero
|
|
140
|
+
? /^https?:\/\//i.test(hero)
|
|
141
|
+
? { hero_image_url: hero }
|
|
142
|
+
: { hero_image_key: hero }
|
|
143
|
+
: {}),
|
|
144
|
+
...(publishAt ? { publishAt } : {}),
|
|
145
|
+
...(author ? { author } : {}),
|
|
146
|
+
...(present(item.primaryCategory) ? { desk: item.primaryCategory } : {}),
|
|
147
|
+
...(item.tags && item.tags.length > 0 ? { tags: [...item.tags] } : {}),
|
|
148
|
+
...(description ? { description } : {}),
|
|
149
|
+
...(item.visibility ? { visibility: item.visibility } : {}),
|
|
150
|
+
...(present(item.sourceId) ? { adoptSourceId: item.sourceId } : {}),
|
|
151
|
+
}
|
|
152
|
+
return FoundryArticleV2Schema.parse(body)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// --- content-emit.json: the whole archive as the exact PUT bodies foundryd sends ---
|
|
156
|
+
|
|
157
|
+
export const CONTENT_EMIT_SCHEMA_VERSION = 1 as const
|
|
158
|
+
|
|
159
|
+
/** An item the producer retires (frontmatter `status: archived`): no request is sent. */
|
|
160
|
+
export interface ContentEmitRetireItem {
|
|
161
|
+
path: string
|
|
162
|
+
videoId: string | null
|
|
163
|
+
retire: true
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* An item to PUT. `request` is the exact PUT body as a JSON STRING, never a
|
|
168
|
+
* nested object, so re-indenting the envelope cannot change its bytes;
|
|
169
|
+
* `requestSha256` is the lowercase hex SHA-256 of its UTF-8 bytes, which is the
|
|
170
|
+
* `request_sha256` the route stores and echoes for that PUT.
|
|
171
|
+
*/
|
|
172
|
+
export interface ContentEmitPutItem {
|
|
173
|
+
path: string
|
|
174
|
+
videoId: string | null
|
|
175
|
+
retire: false
|
|
176
|
+
requestSha256: string
|
|
177
|
+
request: string
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export type ContentEmitItem = ContentEmitRetireItem | ContentEmitPutItem
|
|
181
|
+
|
|
182
|
+
export interface ContentEmit {
|
|
183
|
+
schemaVersion: typeof CONTENT_EMIT_SCHEMA_VERSION
|
|
184
|
+
app: string
|
|
185
|
+
sourceSha: string
|
|
186
|
+
items: ContentEmitItem[]
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const APP_ID = /^[a-z0-9][a-z0-9-]{0,63}$/
|
|
190
|
+
const SOURCE_SHA = /^[0-9a-f]{7,64}$/
|
|
191
|
+
|
|
192
|
+
/** Lowercase hex SHA-256 of a string's UTF-8 bytes (the shared implementation). */
|
|
193
|
+
export { sha256Hex }
|
|
194
|
+
|
|
195
|
+
function archivePath(item: ContentArchiveItem, index: number): string {
|
|
196
|
+
const sourceId = item.sourceId
|
|
197
|
+
const separator = typeof sourceId === 'string' ? sourceId.indexOf(':') : -1
|
|
198
|
+
if (!sourceId || separator < 1 || separator === sourceId.length - 1) {
|
|
199
|
+
throw new FoundryEmitError(
|
|
200
|
+
'source_id_missing',
|
|
201
|
+
`archive item ${index} has no "<ns>:<relpath>" sourceId`,
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
return sourceId.slice(separator + 1)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Build content-emit.json for a WHOLE archive. Every item is either retired
|
|
209
|
+
* (`metadata.originalStatus === 'archived'`, the compiler's carry of frontmatter
|
|
210
|
+
* `status: archived`) or carries its exact v2 PUT body. Any item that cannot be
|
|
211
|
+
* emitted fails the whole build: a partial emit would silently drop articles.
|
|
212
|
+
*/
|
|
213
|
+
export async function buildContentEmit(
|
|
214
|
+
archive: { items?: ContentArchiveItem[] },
|
|
215
|
+
options: { app: string; sourceSha: string },
|
|
216
|
+
): Promise<ContentEmit> {
|
|
217
|
+
if (!APP_ID.test(options.app)) {
|
|
218
|
+
throw new FoundryEmitError('app_invalid', 'app must be a lowercase id (a-z, 0-9, -)')
|
|
219
|
+
}
|
|
220
|
+
if (!SOURCE_SHA.test(options.sourceSha)) {
|
|
221
|
+
throw new FoundryEmitError('source_sha_invalid', 'sourceSha must be a lowercase hex commit')
|
|
222
|
+
}
|
|
223
|
+
if (!Array.isArray(archive.items)) {
|
|
224
|
+
throw new FoundryEmitError('archive_invalid', 'archive has no items array')
|
|
225
|
+
}
|
|
226
|
+
const seen = new Set<string>()
|
|
227
|
+
const items: ContentEmitItem[] = []
|
|
228
|
+
for (const [index, item] of archive.items.entries()) {
|
|
229
|
+
const path = archivePath(item, index)
|
|
230
|
+
if (seen.has(path)) throw new FoundryEmitError('path_duplicate', `duplicate path ${path}`)
|
|
231
|
+
seen.add(path)
|
|
232
|
+
const videoId =
|
|
233
|
+
typeof item.metadata?.videoId === 'string' && item.metadata.videoId
|
|
234
|
+
? item.metadata.videoId
|
|
235
|
+
: null
|
|
236
|
+
if (item.metadata?.originalStatus === 'archived') {
|
|
237
|
+
items.push({ path, videoId, retire: true })
|
|
238
|
+
continue
|
|
239
|
+
}
|
|
240
|
+
let request: string
|
|
241
|
+
try {
|
|
242
|
+
request = JSON.stringify(toFoundryArticleV2(item))
|
|
243
|
+
} catch (error) {
|
|
244
|
+
const detail = error instanceof Error ? error.message : String(error)
|
|
245
|
+
throw new FoundryEmitError('item_invalid', `${path}: ${detail}`)
|
|
246
|
+
}
|
|
247
|
+
items.push({ path, videoId, retire: false, requestSha256: await sha256Hex(request), request })
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
schemaVersion: CONTENT_EMIT_SCHEMA_VERSION,
|
|
251
|
+
app: options.app,
|
|
252
|
+
sourceSha: options.sourceSha,
|
|
253
|
+
items,
|
|
254
|
+
}
|
|
255
|
+
}
|
package/src/integration/index.ts
CHANGED
|
@@ -33,6 +33,10 @@ export default function growthLabsCms(userOptions: CmsIntegrationOptions): Astro
|
|
|
33
33
|
pattern: '/api/cms/foundry/articles/[external_id]/unpublish',
|
|
34
34
|
entrypoint: resolveEntrypoint('../ui/api/foundry/articles/[external_id]/unpublish'),
|
|
35
35
|
})
|
|
36
|
+
injectRoute({
|
|
37
|
+
pattern: '/api/cms/foundry/articles/[external_id]/publish',
|
|
38
|
+
entrypoint: resolveEntrypoint('../ui/api/foundry/articles/[external_id]/publish'),
|
|
39
|
+
})
|
|
36
40
|
// the SPA shell index (SSR, prerender=false)
|
|
37
41
|
injectRoute({ pattern: base, entrypoint: resolveAstro('../ui/pages/admin.astro') })
|
|
38
42
|
// the /admin/api/* JSON routes
|
|
@@ -270,6 +274,11 @@ export default function growthLabsCms(userOptions: CmsIntegrationOptions): Astro
|
|
|
270
274
|
pattern: `${base}/api/surveys/[surveyId]/responses.csv`,
|
|
271
275
|
entrypoint: resolveEntrypoint('../ui/api/surveys/[surveyId]/responses.csv'),
|
|
272
276
|
})
|
|
277
|
+
// Owner-only classification of a written answer (technical/general).
|
|
278
|
+
injectRoute({
|
|
279
|
+
pattern: `${base}/api/surveys/[surveyId]/answers/label`,
|
|
280
|
+
entrypoint: resolveEntrypoint('../ui/api/surveys/[surveyId]/answers/label'),
|
|
281
|
+
})
|
|
273
282
|
// Calendar
|
|
274
283
|
injectRoute({
|
|
275
284
|
pattern: `${base}/api/calendar`,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isCanonicalHost } from '@growth-labs/foundry-signing'
|
|
1
2
|
import { z } from 'zod'
|
|
2
3
|
import { SurveyIdSchema } from '../surveys/schema.js'
|
|
3
4
|
|
|
@@ -124,6 +125,17 @@ const surveyResultsSchema = z
|
|
|
124
125
|
})
|
|
125
126
|
.strict()
|
|
126
127
|
|
|
128
|
+
const surveysOptionsSchema = z
|
|
129
|
+
.object({
|
|
130
|
+
// Automatic technical/general labeling of new written survey answers
|
|
131
|
+
// through the host's LlmDispatch (2026-10-02, packet 08), scheduled off
|
|
132
|
+
// the survey results request via waitUntil. Off by default: a site
|
|
133
|
+
// opts in, and only a site that wired the LlmDispatch seam pays for
|
|
134
|
+
// the background dispatches.
|
|
135
|
+
autoLabel: z.boolean().default(false),
|
|
136
|
+
})
|
|
137
|
+
.default({})
|
|
138
|
+
|
|
127
139
|
export const cmsIntegrationOptionsSchema = z
|
|
128
140
|
.object({
|
|
129
141
|
workspaces: z.array(workspaceSchema).min(1, 'at least one workspace is required'),
|
|
@@ -143,6 +155,17 @@ export const cmsIntegrationOptionsSchema = z
|
|
|
143
155
|
theme: themeSchema,
|
|
144
156
|
socialSharing: socialSharingSchema,
|
|
145
157
|
surveyResults: surveyResultsSchema.optional(),
|
|
158
|
+
surveys: surveysOptionsSchema,
|
|
159
|
+
// This publication's canonical host for the Foundry machine route
|
|
160
|
+
// (/api/cms/foundry/*), e.g. `warfronts.pub`. Declared, the route verifies
|
|
161
|
+
// only the host-bound foundry-cms-v2 signature on every request and never
|
|
162
|
+
// the legacy form, whatever the per-request hooks set.
|
|
163
|
+
foundryCanonicalHost: z
|
|
164
|
+
.string()
|
|
165
|
+
.refine(isCanonicalHost, {
|
|
166
|
+
message: 'foundryCanonicalHost must be a lowercase host with no scheme or port',
|
|
167
|
+
})
|
|
168
|
+
.optional(),
|
|
146
169
|
})
|
|
147
170
|
.transform((o) => ({ ...o, activeWorkspaceId: o.activeWorkspaceId ?? o.workspaces[0].id }))
|
|
148
171
|
|
package/src/providers/null.ts
CHANGED
|
@@ -69,6 +69,12 @@ export const nullAnalytics: AnalyticsProvider = {
|
|
|
69
69
|
},
|
|
70
70
|
})
|
|
71
71
|
},
|
|
72
|
+
// Podcast listening analytics is a host opt-in: null (not an empty
|
|
73
|
+
// envelope) so the route 404s and the panel reports "not available"
|
|
74
|
+
// instead of rendering silent zeros for a site that has none.
|
|
75
|
+
async getPodcastListening() {
|
|
76
|
+
return null
|
|
77
|
+
},
|
|
72
78
|
}
|
|
73
79
|
|
|
74
80
|
export const nullSubscriptions: SubscriptionsProvider = {
|
package/src/providers/types.ts
CHANGED
|
@@ -89,6 +89,51 @@ export interface ArticleAnalyticsDetail {
|
|
|
89
89
|
funnel?: ArticleFunnelStages
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
export interface PodcastListeningEpisode {
|
|
93
|
+
slug: string
|
|
94
|
+
title?: string
|
|
95
|
+
/** Real press-play attempts (user-initiated audio_play; autoplay excluded). */
|
|
96
|
+
plays: number
|
|
97
|
+
/** Distinct sessions that played this episode. */
|
|
98
|
+
listeners: number
|
|
99
|
+
reached10: number
|
|
100
|
+
reached25: number
|
|
101
|
+
reached50: number
|
|
102
|
+
reached75: number
|
|
103
|
+
reached90: number
|
|
104
|
+
completes: number
|
|
105
|
+
/** completes / plays; 0 when plays is 0. */
|
|
106
|
+
completionRate: number
|
|
107
|
+
/**
|
|
108
|
+
* Total seconds actually listened. Null means NOT MEASURED — no
|
|
109
|
+
* `audio_played` rows exist for this episode in the range — and the UI
|
|
110
|
+
* must render "—", never a silent zero.
|
|
111
|
+
*/
|
|
112
|
+
totalListeningSeconds: number | null
|
|
113
|
+
/** totalListeningSeconds / listeners; null when either input is unmeasured. */
|
|
114
|
+
avgListeningSecondsPerListener: number | null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface PodcastListening {
|
|
118
|
+
episodes: PodcastListeningEpisode[]
|
|
119
|
+
totals: {
|
|
120
|
+
/** Episodes with at least one real play in the range. */
|
|
121
|
+
episodes: number
|
|
122
|
+
plays: number
|
|
123
|
+
/**
|
|
124
|
+
* Distinct sessions across every episode, deduplicated — never the sum
|
|
125
|
+
* of per-episode listeners, which would double-count a session that
|
|
126
|
+
* listened to two episodes.
|
|
127
|
+
*/
|
|
128
|
+
listeners: number
|
|
129
|
+
completes: number
|
|
130
|
+
completionRate: number
|
|
131
|
+
totalListeningSeconds: number | null
|
|
132
|
+
}
|
|
133
|
+
/** Echo of the requested window, ISO 8601 instants. */
|
|
134
|
+
range: { from: string; to: string }
|
|
135
|
+
}
|
|
136
|
+
|
|
92
137
|
export interface AnalyticsProvider {
|
|
93
138
|
getKpis(range: DateRange): Promise<ProviderResult<Record<string, number | null>>>
|
|
94
139
|
getReadsTimeSeries(range: DateRange): Promise<ProviderResult<Array<{ t: number; reads: number }>>>
|
|
@@ -99,6 +144,13 @@ export interface AnalyticsProvider {
|
|
|
99
144
|
range: DateRange,
|
|
100
145
|
): Promise<ProviderResult<Array<{ source: string; sessions: number }>>>
|
|
101
146
|
getArticleDetail(slug: string, range: DateRange): Promise<ProviderResult<ArticleAnalyticsDetail>>
|
|
147
|
+
/**
|
|
148
|
+
* Per-episode podcast listening analytics. Null means this site has no
|
|
149
|
+
* podcast listening analytics at all (the null provider) — the CMS hides
|
|
150
|
+
* the panel. A site that implements it but fails to read its source
|
|
151
|
+
* throws, so the route surfaces a loud error instead of zeros.
|
|
152
|
+
*/
|
|
153
|
+
getPodcastListening(input: { from: string; to: string }): Promise<PodcastListening | null>
|
|
102
154
|
}
|
|
103
155
|
|
|
104
156
|
export interface SubscriptionMember {
|
package/src/routes/ai.ts
CHANGED
|
@@ -23,7 +23,12 @@ import {
|
|
|
23
23
|
} from '../engine/ai-prompts.js'
|
|
24
24
|
import { applyAiWrite, parseLockedFields } from '../engine/ai-writeback.js'
|
|
25
25
|
import { buildOgCardSpec } from '../engine/og-render.js'
|
|
26
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
type ContentItemRecord,
|
|
28
|
+
canonicalEditStatement,
|
|
29
|
+
getContentItem,
|
|
30
|
+
updateContentItem,
|
|
31
|
+
} from '../engine/publisher.js'
|
|
27
32
|
import type { LlmDispatchResult } from '../providers/types.js'
|
|
28
33
|
import { canPerformContentAction } from './authz-matrix.js'
|
|
29
34
|
import type { CmsRouteConfig } from './config.js'
|
|
@@ -207,6 +212,8 @@ export function createAiRoutes(config: CmsRouteConfig): AiRouteHandlers {
|
|
|
207
212
|
if (result.status === 'unavailable') {
|
|
208
213
|
return llmUnavailableResponse({ reason: result.reason }, { dek: null, written: false })
|
|
209
214
|
}
|
|
215
|
+
// An editor-triggered dek is a human-initiated canonical change.
|
|
216
|
+
if (result.written) await canonicalEditStatement(ctx.db, idResult.id).run()
|
|
210
217
|
return json({
|
|
211
218
|
dek: result.dek,
|
|
212
219
|
written: result.written,
|
|
@@ -322,16 +329,19 @@ export function createAiRoutes(config: CmsRouteConfig): AiRouteHandlers {
|
|
|
322
329
|
|
|
323
330
|
if (writeResult.written) {
|
|
324
331
|
const now = Math.floor(Date.now() / 1000)
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
332
|
+
// An editor-triggered write-back is a human-initiated canonical change.
|
|
333
|
+
await ctx.db.batch([
|
|
334
|
+
ctx.db
|
|
335
|
+
.prepare(
|
|
336
|
+
`UPDATE article_content
|
|
328
337
|
SET ai_takeaways = ?,
|
|
329
338
|
ai_takeaways_at = ?,
|
|
330
339
|
ai_takeaways_model = ?
|
|
331
340
|
WHERE content_id = ?`,
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
.
|
|
341
|
+
)
|
|
342
|
+
.bind(JSON.stringify(takeaways), now, 'llm-dispatch', id),
|
|
343
|
+
canonicalEditStatement(ctx.db, id),
|
|
344
|
+
])
|
|
335
345
|
}
|
|
336
346
|
|
|
337
347
|
return json({
|
|
@@ -392,10 +402,9 @@ export function createAiRoutes(config: CmsRouteConfig): AiRouteHandlers {
|
|
|
392
402
|
})
|
|
393
403
|
|
|
394
404
|
if (writeResult.written) {
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
.run()
|
|
405
|
+
// Through the editor's own save path, so it is a canonical edit. The
|
|
406
|
+
// existing category is passed through so the save changes nothing else.
|
|
407
|
+
await updateContentItem(ctx.db, id, { description, primaryCategory: item.channel })
|
|
399
408
|
}
|
|
400
409
|
|
|
401
410
|
return json({
|
|
@@ -478,12 +487,15 @@ export function createAiRoutes(config: CmsRouteConfig): AiRouteHandlers {
|
|
|
478
487
|
})
|
|
479
488
|
|
|
480
489
|
if (writeResult.written) {
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
490
|
+
// An editor-triggered write-back is a human-initiated canonical change.
|
|
491
|
+
await ctx.db.batch([
|
|
492
|
+
ctx.db
|
|
493
|
+
.prepare(
|
|
494
|
+
'UPDATE content_items SET ai_og_image_id = ?, updated_at = unixepoch() WHERE id = ?',
|
|
495
|
+
)
|
|
496
|
+
.bind(imageKey, id),
|
|
497
|
+
canonicalEditStatement(ctx.db, id),
|
|
498
|
+
])
|
|
487
499
|
}
|
|
488
500
|
|
|
489
501
|
return json({
|
package/src/routes/analytics.ts
CHANGED
|
@@ -21,6 +21,7 @@ export interface AnalyticsRouteHandlers {
|
|
|
21
21
|
topContent(ctx: RouteContext): Promise<Response>
|
|
22
22
|
trafficSources(ctx: RouteContext): Promise<Response>
|
|
23
23
|
articleDetail(ctx: RouteContext): Promise<Response>
|
|
24
|
+
podcastListening(ctx: RouteContext): Promise<Response>
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
/** Parse ?range=<Nd> from the request URL, defaulting to 30d. */
|
|
@@ -74,5 +75,25 @@ export function createAnalyticsRoutes(config: AnalyticsRouteConfig): AnalyticsRo
|
|
|
74
75
|
const slug = url.searchParams.get('slug') ?? ''
|
|
75
76
|
return json(await providers.analytics.getArticleDetail(slug, range))
|
|
76
77
|
},
|
|
78
|
+
|
|
79
|
+
// Podcast listening panel — same publisher gate and ?range=<Nd> window
|
|
80
|
+
// as the other analytics handlers, but NOT a ProviderResult envelope:
|
|
81
|
+
// the provider returns null when the site has no podcast listening
|
|
82
|
+
// analytics at all, which this route answers with a 404 the screen
|
|
83
|
+
// renders as an explicit "not available" state. A provider that is
|
|
84
|
+
// implemented but fails throws — the route 500s loudly, never zeros.
|
|
85
|
+
async podcastListening(ctx: RouteContext): Promise<Response> {
|
|
86
|
+
const denied = await config.authz.requirePublisher(ctx)
|
|
87
|
+
if (denied) return denied
|
|
88
|
+
const range = parseRange(ctx.request.url)
|
|
89
|
+
const listening = await providers.analytics.getPodcastListening({
|
|
90
|
+
from: new Date(range.from * 1000).toISOString(),
|
|
91
|
+
to: new Date(range.to * 1000).toISOString(),
|
|
92
|
+
})
|
|
93
|
+
if (!listening) {
|
|
94
|
+
return json({ error: 'podcast listening analytics is not available' }, 404)
|
|
95
|
+
}
|
|
96
|
+
return json(listening)
|
|
97
|
+
},
|
|
77
98
|
}
|
|
78
99
|
}
|