@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.
Files changed (172) hide show
  1. package/dist/cli/foundry-emit.d.ts +14 -0
  2. package/dist/cli/foundry-emit.d.ts.map +1 -0
  3. package/dist/cli/foundry-emit.js +69 -0
  4. package/dist/cli/foundry-emit.js.map +1 -0
  5. package/dist/engine/fronts-publish-intent.d.ts +1 -1
  6. package/dist/engine/fronts-publish-intent.d.ts.map +1 -1
  7. package/dist/engine/fronts-publish-intent.js +12 -1
  8. package/dist/engine/fronts-publish-intent.js.map +1 -1
  9. package/dist/engine/publication-receipt/receipt.d.ts +20 -20
  10. package/dist/engine/publisher.d.ts +16 -1
  11. package/dist/engine/publisher.d.ts.map +1 -1
  12. package/dist/engine/publisher.js +55 -4
  13. package/dist/engine/publisher.js.map +1 -1
  14. package/dist/engine/tags.d.ts.map +1 -1
  15. package/dist/engine/tags.js +18 -1
  16. package/dist/engine/tags.js.map +1 -1
  17. package/dist/engine/topics.d.ts.map +1 -1
  18. package/dist/engine/topics.js +4 -1
  19. package/dist/engine/topics.js.map +1 -1
  20. package/dist/engine/video-source.d.ts +21 -0
  21. package/dist/engine/video-source.d.ts.map +1 -1
  22. package/dist/engine/video-source.js +40 -0
  23. package/dist/engine/video-source.js.map +1 -1
  24. package/dist/foundry-emit.d.ts +146 -0
  25. package/dist/foundry-emit.d.ts.map +1 -0
  26. package/dist/foundry-emit.js +189 -0
  27. package/dist/foundry-emit.js.map +1 -0
  28. package/dist/integration/index.d.ts.map +1 -1
  29. package/dist/integration/index.js +9 -0
  30. package/dist/integration/index.js.map +1 -1
  31. package/dist/integration/options.d.ts +24 -0
  32. package/dist/integration/options.d.ts.map +1 -1
  33. package/dist/integration/options.js +22 -0
  34. package/dist/integration/options.js.map +1 -1
  35. package/dist/providers/null.d.ts.map +1 -1
  36. package/dist/providers/null.js +6 -0
  37. package/dist/providers/null.js.map +1 -1
  38. package/dist/providers/types.d.ts +56 -0
  39. package/dist/providers/types.d.ts.map +1 -1
  40. package/dist/providers/types.js.map +1 -1
  41. package/dist/routes/ai.d.ts.map +1 -1
  42. package/dist/routes/ai.js +21 -13
  43. package/dist/routes/ai.js.map +1 -1
  44. package/dist/routes/analytics.d.ts +1 -0
  45. package/dist/routes/analytics.d.ts.map +1 -1
  46. package/dist/routes/analytics.js +20 -0
  47. package/dist/routes/analytics.js.map +1 -1
  48. package/dist/routes/content.d.ts +27 -0
  49. package/dist/routes/content.d.ts.map +1 -1
  50. package/dist/routes/content.js +58 -12
  51. package/dist/routes/content.js.map +1 -1
  52. package/dist/routes/foundry-publish.d.ts +50 -0
  53. package/dist/routes/foundry-publish.d.ts.map +1 -1
  54. package/dist/routes/foundry-publish.js +540 -27
  55. package/dist/routes/foundry-publish.js.map +1 -1
  56. package/dist/routes/machine-context.d.ts +6 -0
  57. package/dist/routes/machine-context.d.ts.map +1 -0
  58. package/dist/routes/machine-context.js +20 -0
  59. package/dist/routes/machine-context.js.map +1 -0
  60. package/dist/routes/surveys.d.ts +14 -1
  61. package/dist/routes/surveys.d.ts.map +1 -1
  62. package/dist/routes/surveys.js +515 -10
  63. package/dist/routes/surveys.js.map +1 -1
  64. package/dist/schema/insights-ingest.d.ts +8 -8
  65. package/dist/schema/migrations.d.ts.map +1 -1
  66. package/dist/schema/migrations.js +15 -0
  67. package/dist/schema/migrations.js.map +1 -1
  68. package/dist/schema/tables.d.ts +1 -1
  69. package/dist/schema/tables.d.ts.map +1 -1
  70. package/dist/schema/tables.js +2 -0
  71. package/dist/schema/tables.js.map +1 -1
  72. package/dist/surveys/access.d.ts +1 -0
  73. package/dist/surveys/access.d.ts.map +1 -1
  74. package/dist/surveys/access.js +13 -4
  75. package/dist/surveys/access.js.map +1 -1
  76. package/dist/surveys/auto-label.d.ts +100 -0
  77. package/dist/surveys/auto-label.d.ts.map +1 -0
  78. package/dist/surveys/auto-label.js +325 -0
  79. package/dist/surveys/auto-label.js.map +1 -0
  80. package/dist/surveys/schema.d.ts +38 -12
  81. package/dist/surveys/schema.d.ts.map +1 -1
  82. package/dist/surveys/schema.js +18 -1
  83. package/dist/surveys/schema.js.map +1 -1
  84. package/dist/ui/api/_content-config.d.ts.map +1 -1
  85. package/dist/ui/api/_content-config.js +4 -0
  86. package/dist/ui/api/_content-config.js.map +1 -1
  87. package/dist/ui/api/analytics.d.ts.map +1 -1
  88. package/dist/ui/api/analytics.js +5 -2
  89. package/dist/ui/api/analytics.js.map +1 -1
  90. package/dist/ui/api/foundry/articles/[external_id]/publish.d.ts +3 -0
  91. package/dist/ui/api/foundry/articles/[external_id]/publish.d.ts.map +1 -0
  92. package/dist/ui/api/foundry/articles/[external_id]/publish.js +14 -0
  93. package/dist/ui/api/foundry/articles/[external_id]/publish.js.map +1 -0
  94. package/dist/ui/api/surveys/[surveyId]/answers/label.d.ts +3 -0
  95. package/dist/ui/api/surveys/[surveyId]/answers/label.d.ts.map +1 -0
  96. package/dist/ui/api/surveys/[surveyId]/answers/label.js +17 -0
  97. package/dist/ui/api/surveys/[surveyId]/answers/label.js.map +1 -0
  98. package/dist/ui/api/surveys/[surveyId].d.ts.map +1 -1
  99. package/dist/ui/api/surveys/[surveyId].js +28 -1
  100. package/dist/ui/api/surveys/[surveyId].js.map +1 -1
  101. package/dist/ui/commands.d.ts.map +1 -1
  102. package/dist/ui/commands.js +2 -0
  103. package/dist/ui/commands.js.map +1 -1
  104. package/dist/ui/hash-router.d.ts +1 -1
  105. package/dist/ui/hash-router.d.ts.map +1 -1
  106. package/dist/ui/hash-router.js +1 -0
  107. package/dist/ui/hash-router.js.map +1 -1
  108. package/dist/ui/nav.d.ts.map +1 -1
  109. package/dist/ui/nav.js +1 -0
  110. package/dist/ui/nav.js.map +1 -1
  111. package/dist/ui/screens/AnalyticsScreen.d.ts +6 -1
  112. package/dist/ui/screens/AnalyticsScreen.d.ts.map +1 -1
  113. package/dist/ui/screens/AnalyticsScreen.js +13 -8
  114. package/dist/ui/screens/AnalyticsScreen.js.map +1 -1
  115. package/dist/ui/screens/PodcastListeningScreen.d.ts +6 -0
  116. package/dist/ui/screens/PodcastListeningScreen.d.ts.map +1 -0
  117. package/dist/ui/screens/PodcastListeningScreen.js +244 -0
  118. package/dist/ui/screens/PodcastListeningScreen.js.map +1 -0
  119. package/dist/ui/screens/SurveyResponsesScreen.d.ts +4 -2
  120. package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -1
  121. package/dist/ui/screens/SurveyResponsesScreen.js +71 -12
  122. package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -1
  123. package/dist/ui/screens/analytics-data.d.ts +14 -0
  124. package/dist/ui/screens/analytics-data.d.ts.map +1 -1
  125. package/dist/ui/screens/analytics-data.js +16 -0
  126. package/dist/ui/screens/analytics-data.js.map +1 -1
  127. package/dist/ui/screens/podcast-listening-data.d.ts +19 -0
  128. package/dist/ui/screens/podcast-listening-data.d.ts.map +1 -0
  129. package/dist/ui/screens/podcast-listening-data.js +98 -0
  130. package/dist/ui/screens/podcast-listening-data.js.map +1 -0
  131. package/dist/ui/screens/registry.d.ts.map +1 -1
  132. package/dist/ui/screens/registry.js +2 -0
  133. package/dist/ui/screens/registry.js.map +1 -1
  134. package/docs/foundry-publish.md +208 -0
  135. package/migrations/0033_survey_answer_labels.sql +15 -0
  136. package/package.json +9 -3
  137. package/src/cli/foundry-emit.ts +85 -0
  138. package/src/engine/fronts-publish-intent.ts +23 -1
  139. package/src/engine/publisher.ts +80 -4
  140. package/src/engine/tags.ts +21 -1
  141. package/src/engine/topics.ts +4 -1
  142. package/src/engine/video-source.ts +44 -0
  143. package/src/foundry-emit.ts +255 -0
  144. package/src/integration/index.ts +9 -0
  145. package/src/integration/options.ts +23 -0
  146. package/src/providers/null.ts +6 -0
  147. package/src/providers/types.ts +52 -0
  148. package/src/routes/ai.ts +29 -17
  149. package/src/routes/analytics.ts +21 -0
  150. package/src/routes/content.ts +81 -15
  151. package/src/routes/foundry-publish.ts +746 -29
  152. package/src/routes/machine-context.ts +24 -0
  153. package/src/routes/surveys.ts +587 -12
  154. package/src/schema/migrations.ts +15 -0
  155. package/src/schema/tables.ts +2 -0
  156. package/src/surveys/access.ts +14 -4
  157. package/src/surveys/auto-label.ts +420 -0
  158. package/src/surveys/schema.ts +25 -1
  159. package/src/ui/api/_content-config.ts +6 -0
  160. package/src/ui/api/analytics.ts +5 -2
  161. package/src/ui/api/foundry/articles/[external_id]/publish.ts +18 -0
  162. package/src/ui/api/surveys/[surveyId]/answers/label.ts +23 -0
  163. package/src/ui/api/surveys/[surveyId].ts +37 -3
  164. package/src/ui/commands.ts +2 -0
  165. package/src/ui/hash-router.ts +1 -0
  166. package/src/ui/nav.ts +1 -0
  167. package/src/ui/screens/AnalyticsScreen.tsx +34 -6
  168. package/src/ui/screens/PodcastListeningScreen.tsx +529 -0
  169. package/src/ui/screens/SurveyResponsesScreen.tsx +179 -13
  170. package/src/ui/screens/analytics-data.ts +18 -0
  171. package/src/ui/screens/podcast-listening-data.ts +114 -0
  172. package/src/ui/screens/registry.tsx +2 -0
@@ -66,6 +66,7 @@ import { invalidateMediaCaptureStatements, softDeleteContent } from '../engine/s
66
66
  import { normalizeSourceSections } from '../engine/source-sections.js'
67
67
  import { TagInputError } from '../engine/taxonomy.js'
68
68
  import type { ValidationError } from '../engine/validator/index.js'
69
+ import { VideoSourceNotIngestableError } from '../engine/video-source.js'
69
70
  import {
70
71
  isValidTimeZone,
71
72
  PUBLISH_TIMEZONE_FALLBACK,
@@ -79,6 +80,8 @@ import { type ContentAction, canPerformContentAction } from './authz-matrix.js'
79
80
  import type { CmsRouteConfig } from './config.js'
80
81
  import { resolveConfig } from './config.js'
81
82
  import { json, type RouteContext, requireId } from './context.js'
83
+ import type { FoundryPublishOptions } from './foundry-publish.js'
84
+ import { isFoundryMachineContext } from './machine-context.js'
82
85
  import type { DispatchWebhookHook } from './webhooks.js'
83
86
 
84
87
  export type { DispatchWebhookHook }
@@ -146,6 +149,11 @@ function mediaSourceProviderErrorResponse(error: unknown): Response | null {
146
149
  return json({ error: failure.message, reason: failure.code }, failure.status)
147
150
  }
148
151
 
152
+ function videoSourceNotIngestableResponse(error: unknown): Response | null {
153
+ if (!(error instanceof VideoSourceNotIngestableError)) return null
154
+ return json({ error: error.message, reason: 'video_source_not_ingestable' }, 400)
155
+ }
156
+
149
157
  function verifiedMediaDurationConflictResponse(error: unknown): Response | null {
150
158
  if (!(error instanceof VerifiedMediaDurationConflictError)) return null
151
159
  return json(
@@ -231,6 +239,14 @@ export interface ContentRouteConfig extends CmsRouteConfig {
231
239
  videoSourceProductionEnabled?: boolean
232
240
  /** Internal machine-route constraint: atomically update only in this status. */
233
241
  updateStatusConstraint?: ContentStatus
242
+ /**
243
+ * Internal (Foundry machine route, M3-18): replaces ONLY the publish action's
244
+ * commit step (publishContent + createRevision) with the caller's — publishOne
245
+ * with the publication's live surfaces. Every other step of the native publish
246
+ * action (readiness, body guard, dek, tag check, activity log, takeaways, FAQ,
247
+ * the content.published webhook) runs unchanged, through the same handler.
248
+ */
249
+ publishCommit?: (ctx: RouteContext, input: { contentId: string; now: number }) => Promise<void>
234
250
  /**
235
251
  * Optional webhook dispatcher hook (P7 Task 9). Fired at each content
236
252
  * lifecycle transition (publish, unpublish, schedule, archive, create, etc.).
@@ -245,6 +261,21 @@ export interface ContentRouteConfig extends CmsRouteConfig {
245
261
  * when absent so callers without a workspace concept still produce a scoped row.
246
262
  */
247
263
  activeWorkspaceId?: string
264
+ /**
265
+ * Per-publication opt-in for the Foundry machine route's publish transition
266
+ * (M3-18). Omitted, or `autoPublish` anything but `true`, keeps that route
267
+ * draft-only and byte-identical to the v1 route. Read only by routes/foundry-publish.ts.
268
+ */
269
+ foundryPublish?: FoundryPublishOptions
270
+ /**
271
+ * This publication's canonical host for the Foundry route family, declared
272
+ * statically (the integration's `foundryCanonicalHost`, so it reaches every
273
+ * request whatever the host's per-request hooks set). Once a host is declared
274
+ * here or in `foundryPublish.canonicalHost`, the Foundry route verifies only
275
+ * the host-bound `foundry-cms-v2` string and never the legacy dot form.
276
+ * Read only by routes/foundry-publish.ts.
277
+ */
278
+ foundryCanonicalHost?: string
248
279
  }
249
280
 
250
281
  type ContentType = 'article' | 'video' | 'podcast' | 'newsletter' | 'page'
@@ -422,6 +453,8 @@ const NEXTCLOUD_SHARE_HOSTS = ['drive.fulcrum-portal.com']
422
453
  const NEXTCLOUD_SHARE_PATH_RE = /^\/s\/([A-Za-z0-9_-]+)(?:\/download)?\/?$/
423
454
  const NEXTCLOUD_DIRECT_PATH_RE = /^\/public\.php\/dav\/files\/[A-Za-z0-9_-]+$/
424
455
 
456
+ const NEXTCLOUD_SHARE_NOISE_PARAMS: ReadonlySet<string> = new Set(['dir', 'editing', 'openfile'])
457
+
425
458
  export function normaliseNextcloudShareUrl(value: string): string {
426
459
  let url: URL
427
460
  try {
@@ -440,13 +473,19 @@ export function normaliseNextcloudShareUrl(value: string): string {
440
473
  ) {
441
474
  return value
442
475
  }
443
- const onlyDirQuery =
444
- url.search === '' || (url.searchParams.get('dir') !== null && url.searchParams.size === 1)
476
+ // Nextcloud's copy-link output adds page-navigation noise -- `?dir=/` from a
477
+ // folder listing, `?dir=/&editing=false&openfile=true` from the viewer --
478
+ // none of which changes the file a share serves. Any other parameter may
479
+ // select a different resource, so the URL is kept and the publisher's
480
+ // ingest guard refuses it (HomeFronts 2026-10-01T18:05Z).
481
+ const onlyNoiseQuery =
482
+ url.search === '' ||
483
+ [...url.searchParams.keys()].every((key) => NEXTCLOUD_SHARE_NOISE_PARAMS.has(key))
445
484
  if (NEXTCLOUD_DIRECT_PATH_RE.test(url.pathname)) {
446
- return url.search === '' ? value : onlyDirQuery ? `https://${host}${url.pathname}` : value
485
+ return url.search === '' ? value : onlyNoiseQuery ? `https://${host}${url.pathname}` : value
447
486
  }
448
487
  const share = NEXTCLOUD_SHARE_PATH_RE.exec(url.pathname)
449
- if (!share || !onlyDirQuery) return value
488
+ if (!share || !onlyNoiseQuery) return value
450
489
  return `https://${host}/public.php/dav/files/${share[1]}`
451
490
  }
452
491
 
@@ -1182,6 +1221,15 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1182
1221
  * — identical guard pattern to dispatchTakeaways / logActivity.
1183
1222
  * When no hook is injected this is a no-op (no 501, no error).
1184
1223
  */
1224
+ /**
1225
+ * Only a context the HMAC-verified Foundry route marked is the machine
1226
+ * (routes/machine-context.ts); a subject or userId string never decides it,
1227
+ * so no person can act as the machine. Any other actor is a person.
1228
+ */
1229
+ async function isFoundryActor(ctx: RouteContext): Promise<boolean> {
1230
+ return isFoundryMachineContext(ctx)
1231
+ }
1232
+
1185
1233
  async function fireWebhook(
1186
1234
  ctx: RouteContext,
1187
1235
  eventType: string,
@@ -1425,12 +1473,14 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1425
1473
  if (op === 'topic') {
1426
1474
  const { topic } = parsed.data
1427
1475
  for (const id of ids) {
1428
- await ctx.db
1429
- .prepare(
1430
- 'UPDATE content_items SET primary_topic = ?, updated_at = unixepoch() WHERE id = ? AND deleted_at IS NULL',
1431
- )
1432
- .bind(topic, id)
1433
- .run()
1476
+ // Through the editor's own save path, so it is a canonical edit; the
1477
+ // existing category is passed through so the save changes nothing else.
1478
+ const existing = await getContentItem(ctx.db, id)
1479
+ if (!existing || (existing as { deleted_at?: number | null }).deleted_at != null) continue
1480
+ await updateContentItem(ctx.db, id, {
1481
+ primaryTopic: topic,
1482
+ primaryCategory: existing.channel,
1483
+ })
1434
1484
  }
1435
1485
  return json({ updated: ids })
1436
1486
  }
@@ -1532,6 +1582,8 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1532
1582
  created = await createContent(ctx.db, parsed.data)
1533
1583
  } catch (error) {
1534
1584
  if (error instanceof TagInputError) return tagInputErrorResponse(error)
1585
+ const notIngestable = videoSourceNotIngestableResponse(error)
1586
+ if (notIngestable) return notIngestable
1535
1587
  const conflict = verifiedMediaDurationConflictResponse(error)
1536
1588
  if (conflict) return conflict
1537
1589
  throw error
@@ -1778,6 +1830,8 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1778
1830
  })
1779
1831
  } catch (error) {
1780
1832
  if (error instanceof TagInputError) return tagInputErrorResponse(error)
1833
+ const notIngestable = videoSourceNotIngestableResponse(error)
1834
+ if (notIngestable) return notIngestable
1781
1835
  const conflict = verifiedMediaDurationConflictResponse(error)
1782
1836
  if (conflict) return conflict
1783
1837
  throw error
@@ -2034,8 +2088,12 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
2034
2088
  throw error
2035
2089
  }
2036
2090
  try {
2037
- await publishContent(ctx.db, id, publishedAt, now)
2038
- await createRevision(ctx.db, id, ctx.userId || null)
2091
+ if (config.publishCommit) {
2092
+ await config.publishCommit(ctx, { contentId: id, now })
2093
+ } else {
2094
+ await publishContent(ctx.db, id, publishedAt, now)
2095
+ await createRevision(ctx.db, id, ctx.userId || null)
2096
+ }
2039
2097
  } catch (error) {
2040
2098
  if (error instanceof TagInputError) return tagInputErrorResponse(error)
2041
2099
  throw error
@@ -2127,7 +2185,10 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
2127
2185
  }
2128
2186
 
2129
2187
  if (action === 'unpublish') {
2130
- await unpublishContent(ctx.db, id)
2188
+ // A person's unpublish sticks: it is a canonical edit, so a machine
2189
+ // publisher cannot put the item back without a new write. The
2190
+ // machine's own unpublish is not, so it can republish its item.
2191
+ await unpublishContent(ctx.db, id, { canonicalEdit: !(await isFoundryActor(ctx)) })
2131
2192
 
2132
2193
  // Fire content.unpublished webhook.
2133
2194
  await fireWebhook(ctx, 'content.unpublished', {
@@ -2163,9 +2224,11 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
2163
2224
  if (action === 'unschedule') {
2164
2225
  // publish_tz is NOT NULL (DEFAULT 'Europe/Paris'); reset it to the
2165
2226
  // default rather than nulling it (a NULL violates the constraint).
2227
+ // A person pulling a scheduled item is a canonical edit (see unpublish).
2228
+ const pulledByPerson = !(await isFoundryActor(ctx))
2166
2229
  await ctx.db
2167
2230
  .prepare(
2168
- "UPDATE content_items SET status = 'draft', publish_at = NULL, publish_tz = 'Europe/Paris', updated_at = unixepoch() WHERE id = ?",
2231
+ `UPDATE content_items SET status = 'draft', publish_at = NULL, publish_tz = 'Europe/Paris', updated_at = unixepoch()${pulledByPerson ? ', canonical_version = canonical_version + 1' : ''} WHERE id = ?`,
2169
2232
  )
2170
2233
  .bind(id)
2171
2234
  .run()
@@ -2204,9 +2267,12 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
2204
2267
  // publish_at so a de-scheduled item doesn't carry a phantom publish time.
2205
2268
  // publish_tz is NOT NULL (DEFAULT 'Europe/Paris'), so it is reset to the
2206
2269
  // default rather than nulled.
2270
+ // A person moving the item back to draft or review is a canonical
2271
+ // edit (see unpublish); the machine never calls set-status.
2272
+ const movedByPerson = !(await isFoundryActor(ctx))
2207
2273
  await ctx.db
2208
2274
  .prepare(
2209
- "UPDATE content_items SET status = ?, publish_at = NULL, publish_tz = 'Europe/Paris', updated_at = unixepoch() WHERE id = ?",
2275
+ `UPDATE content_items SET status = ?, publish_at = NULL, publish_tz = 'Europe/Paris', updated_at = unixepoch()${movedByPerson ? ', canonical_version = canonical_version + 1' : ''} WHERE id = ?`,
2210
2276
  )
2211
2277
  .bind(status, id)
2212
2278
  .run()