@growth-labs/cms 0.8.1 → 0.8.3

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 (91) hide show
  1. package/dist/engine/ai-dek-notifications.d.ts +2 -0
  2. package/dist/engine/ai-dek-notifications.d.ts.map +1 -1
  3. package/dist/engine/ai-dek-notifications.js +11 -2
  4. package/dist/engine/ai-dek-notifications.js.map +1 -1
  5. package/dist/engine/ai-dek.d.ts +23 -0
  6. package/dist/engine/ai-dek.d.ts.map +1 -1
  7. package/dist/engine/ai-dek.js +94 -31
  8. package/dist/engine/ai-dek.js.map +1 -1
  9. package/dist/engine/foundry-dispatch.d.ts +13 -3
  10. package/dist/engine/foundry-dispatch.d.ts.map +1 -1
  11. package/dist/engine/foundry-dispatch.js +80 -25
  12. package/dist/engine/foundry-dispatch.js.map +1 -1
  13. package/dist/engine/fronts-publish-intent.d.ts +55 -0
  14. package/dist/engine/fronts-publish-intent.d.ts.map +1 -1
  15. package/dist/engine/fronts-publish-intent.js +181 -55
  16. package/dist/engine/fronts-publish-intent.js.map +1 -1
  17. package/dist/engine/fronts-publish.d.ts +11 -0
  18. package/dist/engine/fronts-publish.d.ts.map +1 -1
  19. package/dist/engine/fronts-publish.js +106 -4
  20. package/dist/engine/fronts-publish.js.map +1 -1
  21. package/dist/engine/index.d.ts +1 -1
  22. package/dist/engine/index.d.ts.map +1 -1
  23. package/dist/engine/index.js +1 -1
  24. package/dist/engine/index.js.map +1 -1
  25. package/dist/engine/podcast-source.d.ts +3 -2
  26. package/dist/engine/podcast-source.d.ts.map +1 -1
  27. package/dist/engine/podcast-source.js +6 -4
  28. package/dist/engine/podcast-source.js.map +1 -1
  29. package/dist/engine/publisher.d.ts.map +1 -1
  30. package/dist/engine/publisher.js +16 -8
  31. package/dist/engine/publisher.js.map +1 -1
  32. package/dist/engine/soft-delete.d.ts +3 -1
  33. package/dist/engine/soft-delete.d.ts.map +1 -1
  34. package/dist/engine/soft-delete.js +21 -5
  35. package/dist/engine/soft-delete.js.map +1 -1
  36. package/dist/engine/video-source.d.ts +4 -2
  37. package/dist/engine/video-source.d.ts.map +1 -1
  38. package/dist/engine/video-source.js +7 -4
  39. package/dist/engine/video-source.js.map +1 -1
  40. package/dist/integration/index.d.ts.map +1 -1
  41. package/dist/integration/index.js +7 -0
  42. package/dist/integration/index.js.map +1 -1
  43. package/dist/routes/content-insights.d.ts +9 -0
  44. package/dist/routes/content-insights.d.ts.map +1 -1
  45. package/dist/routes/content-insights.js +35 -0
  46. package/dist/routes/content-insights.js.map +1 -1
  47. package/dist/routes/content.d.ts +14 -0
  48. package/dist/routes/content.d.ts.map +1 -1
  49. package/dist/routes/content.js +37 -24
  50. package/dist/routes/content.js.map +1 -1
  51. package/dist/routes/context.d.ts +6 -0
  52. package/dist/routes/context.d.ts.map +1 -1
  53. package/dist/routes/context.js.map +1 -1
  54. package/dist/routes/fronts-publish.d.ts +16 -0
  55. package/dist/routes/fronts-publish.d.ts.map +1 -1
  56. package/dist/routes/fronts-publish.js +185 -4
  57. package/dist/routes/fronts-publish.js.map +1 -1
  58. package/dist/ui/api/_content-config.d.ts.map +1 -1
  59. package/dist/ui/api/_content-config.js +7 -0
  60. package/dist/ui/api/_content-config.js.map +1 -1
  61. package/dist/ui/api/content/insights-inputs.d.ts +3 -0
  62. package/dist/ui/api/content/insights-inputs.d.ts.map +1 -0
  63. package/dist/ui/api/content/insights-inputs.js +15 -0
  64. package/dist/ui/api/content/insights-inputs.js.map +1 -0
  65. package/dist/ui/api/fronts/schedule.d.ts +2 -0
  66. package/dist/ui/api/fronts/schedule.d.ts.map +1 -1
  67. package/dist/ui/api/fronts/schedule.js +2 -0
  68. package/dist/ui/api/fronts/schedule.js.map +1 -1
  69. package/dist/ui/editor/ContentForm.d.ts.map +1 -1
  70. package/dist/ui/editor/ContentForm.js +2 -2
  71. package/dist/ui/editor/ContentForm.js.map +1 -1
  72. package/package.json +1 -1
  73. package/src/engine/ai-dek-notifications.ts +19 -2
  74. package/src/engine/ai-dek.ts +131 -40
  75. package/src/engine/foundry-dispatch.ts +110 -24
  76. package/src/engine/fronts-publish-intent.ts +289 -65
  77. package/src/engine/fronts-publish.ts +121 -4
  78. package/src/engine/index.ts +1 -0
  79. package/src/engine/podcast-source.ts +6 -4
  80. package/src/engine/publisher.ts +20 -8
  81. package/src/engine/soft-delete.ts +28 -8
  82. package/src/engine/video-source.ts +7 -4
  83. package/src/integration/index.ts +7 -0
  84. package/src/routes/content-insights.ts +61 -0
  85. package/src/routes/content.ts +56 -31
  86. package/src/routes/context.ts +6 -0
  87. package/src/routes/fronts-publish.ts +220 -4
  88. package/src/ui/api/_content-config.ts +15 -0
  89. package/src/ui/api/content/insights-inputs.ts +26 -0
  90. package/src/ui/api/fronts/schedule.ts +4 -0
  91. package/src/ui/editor/ContentForm.tsx +4 -5
@@ -24,7 +24,14 @@
24
24
  // additive and gated by FRONTS_PUBLISH_ENABLED (default ON — a rollback lever,
25
25
  // not a dark-staging gate).
26
26
 
27
+ import type { LlmDispatch } from '../providers/types.js'
27
28
  import type { ContentType, FrontsPublishStatus } from '../schema/types.js'
29
+ import {
30
+ applyGeneratedContentDek,
31
+ generateContentDekSuggestion,
32
+ loadDekGenerationContextFromSnapshot,
33
+ } from './ai-dek.js'
34
+ import { parseLockedFields } from './ai-writeback.js'
28
35
  import type { D1Database } from './d1.js'
29
36
  import { FrontsScheduleOverflowError } from './fronts-publish-intent.js'
30
37
  import { podcastPublishBlocker } from './podcast-source.js'
@@ -35,6 +42,7 @@ import {
35
42
  snapshotRecord,
36
43
  } from './publication-current-guard.js'
37
44
  import { getContentSnapshot } from './publisher.js'
45
+ import { getRevision } from './revisions.js'
38
46
 
39
47
  /** The five lifecycle states foundryd reports for a Fronts publish intent. */
40
48
  export const FRONTS_PUBLISH_STATUSES: readonly FrontsPublishStatus[] = [
@@ -161,6 +169,8 @@ export interface FrontsPublishCallbackInput {
161
169
  now?: number
162
170
  /** Optional occurrence evidence carried by current publish workers. */
163
171
  publishAt?: number | null
172
+ /** Optional existing CMS LLM hook for best-effort missing-dek generation. */
173
+ llm?: LlmDispatch
164
174
  /** The route supplies the same host hook used by the editorial publish action. */
165
175
  checkReadiness?: (item: {
166
176
  id: string
@@ -183,6 +193,10 @@ export interface FrontsPublishCallbackResult {
183
193
  ledgerError: string | null
184
194
  /** A retryable hold or stale/cancelled snapshot; never consumes event_id. */
185
195
  rejection?: { code: string; status: 404 | 409 | 503; blockers?: string[]; message: string }
196
+ /** Safe post-commit reason for a missing-dek editor notification. */
197
+ dekFailureReason?: 'ai_generation_unavailable' | 'ai_generation_timeout' | null
198
+ /** Existing lifecycle payload fields, present only for this call's committed publication. */
199
+ publishedItem?: { id: string; type: ContentType; title: string } | null
186
200
  }
187
201
 
188
202
  function hasReceipt(receipt: FrontsPublishReceipt | null | undefined): boolean {
@@ -210,6 +224,8 @@ function resolveOccurredAtSeconds(
210
224
  return Math.min(seconds, now)
211
225
  }
212
226
 
227
+ class DekGenerationTimeoutError extends Error {}
228
+
213
229
  /**
214
230
  * Apply one foundryd publish-callback. Idempotent by `event_id`: a repeat
215
231
  * delivery returns the stored outcome without re-applying. See the module
@@ -250,6 +266,8 @@ export async function applyFrontsPublishCallback(
250
266
  let published = item.status === 'published'
251
267
  let attemptId: string | null = null
252
268
  let ledgerError: string | null = null
269
+ let committedDekFailureReason: FrontsPublishCallbackResult['dekFailureReason'] = null
270
+ let committedPublishedItem: FrontsPublishCallbackResult['publishedItem'] = null
253
271
  const shouldPublish = input.status === 'live' && hasReceipt(input.receipt)
254
272
  if (shouldPublish && item.status === 'published') {
255
273
  const current = await getContentSnapshot(db, input.contentId)
@@ -326,9 +344,88 @@ export async function applyFrontsPublishCallback(
326
344
  ['publish_not_due'],
327
345
  )
328
346
  }
347
+ let dekFailureReason: FrontsPublishCallbackResult['dekFailureReason'] = null
348
+ const initialExcerpt =
349
+ typeof expectedItem.excerpt === 'string' ? expectedItem.excerpt.trim() : ''
350
+ const initialLocks = parseLockedFields(
351
+ typeof expectedItem.ai_locked_fields === 'string' ? expectedItem.ai_locked_fields : null,
352
+ )
353
+ if (!initialExcerpt && !initialLocks.includes('excerpt')) {
354
+ if (!input.llm) {
355
+ dekFailureReason = 'ai_generation_unavailable'
356
+ } else {
357
+ const deadlineAt = performance.now() + 8_000
358
+ let timeout: ReturnType<typeof setTimeout> | undefined
359
+ try {
360
+ const generated = await Promise.race([
361
+ (async () => {
362
+ const context = await loadDekGenerationContextFromSnapshot(db, expectedSnapshot)
363
+ if (!context) {
364
+ return {
365
+ context: null,
366
+ suggestion: {
367
+ status: 'unavailable' as const,
368
+ dek: null,
369
+ reason: 'unavailable',
370
+ },
371
+ }
372
+ }
373
+ return {
374
+ context,
375
+ suggestion: await generateContentDekSuggestion({
376
+ context,
377
+ llm: input.llm!,
378
+ }),
379
+ }
380
+ })(),
381
+ new Promise<never>((_resolve, rejectTimeout) => {
382
+ timeout = setTimeout(() => rejectTimeout(new DekGenerationTimeoutError()), 8_000)
383
+ }),
384
+ ])
385
+ if (generated.suggestion.status !== 'generated') {
386
+ dekFailureReason = 'ai_generation_unavailable'
387
+ } else if (!generated.context || performance.now() >= deadlineAt) {
388
+ dekFailureReason = 'ai_generation_timeout'
389
+ } else {
390
+ const applied = await applyGeneratedContentDek({
391
+ db,
392
+ contentId: input.contentId,
393
+ dek: generated.suggestion.dek,
394
+ context: generated.context,
395
+ expectedPublicationSnapshot: expectedSnapshot,
396
+ })
397
+ if (applied.status === 'unavailable') dekFailureReason = 'ai_generation_unavailable'
398
+ }
399
+ } catch (error) {
400
+ dekFailureReason =
401
+ error instanceof DekGenerationTimeoutError
402
+ ? 'ai_generation_timeout'
403
+ : 'ai_generation_unavailable'
404
+ } finally {
405
+ if (timeout !== undefined) clearTimeout(timeout)
406
+ }
407
+ }
408
+ }
409
+
410
+ const currentSnapshot = await getContentSnapshot(db, input.contentId)
411
+ try {
412
+ assertCurrentPublicationSnapshot(currentSnapshot ?? {}, expectedSnapshot)
413
+ } catch {
414
+ return reject(
415
+ 'publish_snapshot_changed',
416
+ 409,
417
+ 'Scheduled content or media changed before publication.',
418
+ )
419
+ }
420
+ const currentItem = snapshotRecord(currentSnapshot?.item)
421
+ const currentExcerpt = typeof currentItem.excerpt === 'string' ? currentItem.excerpt.trim() : ''
422
+ const currentLocks = parseLockedFields(
423
+ typeof currentItem.ai_locked_fields === 'string' ? currentItem.ai_locked_fields : null,
424
+ )
425
+ if (currentExcerpt || currentLocks.includes('excerpt')) dekFailureReason = null
329
426
  const blocker =
330
- expectedItem.type === 'podcast'
331
- ? podcastPublishBlocker(snapshotRecord(expectedSnapshot.content))
427
+ currentItem.type === 'podcast'
428
+ ? podcastPublishBlocker(snapshotRecord(currentSnapshot?.content))
332
429
  : null
333
430
  if (blocker) return reject('publish_not_ready', 503, blocker.message, [blocker.code])
334
431
  if (input.checkReadiness) {
@@ -337,7 +434,7 @@ export async function applyFrontsPublishCallback(
337
434
  const readiness = await Promise.race([
338
435
  input.checkReadiness({
339
436
  id: input.contentId,
340
- type: expectedItem.type as ContentType,
437
+ type: currentItem.type as ContentType,
341
438
  status: 'scheduled',
342
439
  }),
343
440
  new Promise<never>((_resolve, rejectTimeout) => {
@@ -378,11 +475,29 @@ export async function applyFrontsPublishCallback(
378
475
  ...(publishedAt !== null ? { publishedAt } : {}),
379
476
  createdBy: 'foundry.fronts-publish',
380
477
  surfaces: [surface],
381
- expectedCurrentSnapshot: expectedSnapshot,
478
+ expectedCurrentSnapshot: currentSnapshot!,
382
479
  expectedReceiptIdentity: receiptIdentity,
383
480
  })
384
481
  attemptId = receipt.attemptId
385
482
  published = true
483
+ const committedRevision = await getRevision(db, input.contentId, receipt.revisionId)
484
+ if (attemptId && committedRevision) {
485
+ const committedItem = snapshotRecord(committedRevision.payload.item)
486
+ const committedExcerpt =
487
+ typeof committedItem.excerpt === 'string' ? committedItem.excerpt.trim() : ''
488
+ const committedLocks = parseLockedFields(
489
+ typeof committedItem.ai_locked_fields === 'string'
490
+ ? committedItem.ai_locked_fields
491
+ : null,
492
+ )
493
+ committedDekFailureReason =
494
+ committedExcerpt || committedLocks.includes('excerpt') ? null : dekFailureReason
495
+ committedPublishedItem = {
496
+ id: String(committedItem.id ?? input.contentId),
497
+ type: committedItem.type as ContentType,
498
+ title: String(committedItem.title ?? ''),
499
+ }
500
+ }
386
501
  } catch (error) {
387
502
  // A concurrently published row may belong to a newer editor/source
388
503
  // revision. Only a matching committed snapshot and full receipt prove
@@ -496,6 +611,8 @@ export async function applyFrontsPublishCallback(
496
611
  attemptId,
497
612
  contentFound: true,
498
613
  ledgerError,
614
+ dekFailureReason: committedDekFailureReason,
615
+ publishedItem: committedPublishedItem,
499
616
  }
500
617
  }
501
618
 
@@ -78,6 +78,7 @@ export type { D1Database, D1DatabaseSession, D1PreparedStatement, D1Result } fro
78
78
  export {
79
79
  applyFoundryCallback,
80
80
  type CallbackInput,
81
+ createFoundrySourceCaptureToken,
81
82
  type DispatchResult,
82
83
  dispatchToFoundry,
83
84
  type ProcessingState,
@@ -9,19 +9,21 @@ export interface FrontsPodcastSourceSnapshot {
9
9
  }
10
10
 
11
11
  /**
12
- * Stable over polling, machine media callbacks and rescheduling. Canonical
13
- * editor/source changes invalidate the old production. Neither the raw source
12
+ * V1 captures keep their canonical-version binding. New v2 captures remain
13
+ * stable over editorial metadata edits, polling, callbacks and rescheduling;
14
+ * source/slug/token changes invalidate production. Neither the raw source
14
15
  * token nor URL needs to appear in callback/notification identities.
15
16
  */
16
17
  export async function frontsPodcastSourceGeneration(
17
18
  input: FrontsPodcastSourceSnapshot,
18
19
  ): Promise<string> {
20
+ const version = input.processing_trigger_token?.startsWith('fronts-source-v2:') ? 2 : 1
19
21
  const bytes = new TextEncoder().encode(
20
22
  JSON.stringify([
21
- 'fronts-podcast-source.v1',
23
+ `fronts-podcast-source.v${version}`,
22
24
  input.content_id,
23
25
  input.slug,
24
- input.canonical_version,
26
+ ...(version === 1 ? [input.canonical_version] : []),
25
27
  input.processing_source_url,
26
28
  input.processing_source_kind,
27
29
  input.processing_trigger_token,
@@ -793,7 +793,10 @@ export async function updateContentItem(
793
793
  // Resolve all media identity/duration invariants before the base content row,
794
794
  // tags, or relations can be mutated. D1 does not provide a transaction on the
795
795
  // structural surface used here, so validation must be a strict preflight.
796
- if (input.content && existing.type === 'video') {
796
+ if (
797
+ (input.content || input.slug !== undefined || input.visibility !== undefined) &&
798
+ existing.type === 'video'
799
+ ) {
797
800
  const current = await db
798
801
  .prepare(
799
802
  `SELECT script, video_id, duration_seconds, thumbnail_image_id,
@@ -813,7 +816,7 @@ export async function updateContentItem(
813
816
  }>()
814
817
 
815
818
  if (current) {
816
- const videoContent = input.content as Partial<VideoInput['content']>
819
+ const videoContent = (input.content ?? {}) as Partial<VideoInput['content']>
817
820
  const sourceUrl = hasOwn(videoContent, 'processingSourceUrl')
818
821
  ? (videoContent.processingSourceUrl ?? null)
819
822
  : current.processing_source_url
@@ -851,14 +854,19 @@ export async function updateContentItem(
851
854
  : current.thumbnail_image_id,
852
855
  sourceUrl,
853
856
  sourceKind,
854
- sourceChanged,
857
+ sourceChanged:
858
+ sourceChanged ||
859
+ resolvedDuration !== current.duration_seconds ||
860
+ (input.slug !== undefined && input.slug !== existing.slug) ||
861
+ (input.visibility !== undefined && input.visibility !== existing.visibility) ||
862
+ (hasOwn(videoContent, 'videoId') && videoContent.videoId !== current.video_id),
855
863
  }
856
864
  } else {
857
865
  videoUpdatePlan = null
858
866
  }
859
867
  }
860
868
 
861
- if (input.content && existing.type === 'podcast') {
869
+ if ((input.content || input.slug !== undefined) && existing.type === 'podcast') {
862
870
  const current = await db
863
871
  .prepare(
864
872
  `SELECT transcript, audio_r2_key, duration_seconds, processing_trigger_token,
@@ -878,7 +886,7 @@ export async function updateContentItem(
878
886
  }>()
879
887
 
880
888
  if (current) {
881
- const podcastContent = input.content as Partial<PodcastInput['content']>
889
+ const podcastContent = (input.content ?? {}) as Partial<PodcastInput['content']>
882
890
  const audioR2Key = hasOwn(podcastContent, 'audioR2Key')
883
891
  ? (podcastContent.audioR2Key ?? current.audio_r2_key)
884
892
  : current.audio_r2_key
@@ -910,9 +918,13 @@ export async function updateContentItem(
910
918
  )
911
919
  const replacementTranscriptProvided =
912
920
  hasOwn(podcastContent, 'transcript') && typeof podcastContent.transcript === 'string'
921
+ const preparationChanged =
922
+ sourceChanged ||
923
+ resolvedDuration !== current.duration_seconds ||
924
+ (input.slug !== undefined && input.slug !== existing.slug)
913
925
 
914
926
  podcastUpdatePlan = {
915
- transcript: sourceChanged
927
+ transcript: preparationChanged
916
928
  ? replacementTranscriptProvided
917
929
  ? (podcastContent.transcript as string)
918
930
  : ''
@@ -924,7 +936,7 @@ export async function updateContentItem(
924
936
  durationProvided || sourceChanged
925
937
  ? resolvedDuration
926
938
  : (resolvedDuration ?? current.duration_seconds),
927
- processingTriggerToken: sourceChanged ? null : current.processing_trigger_token,
939
+ processingTriggerToken: preparationChanged ? null : current.processing_trigger_token,
928
940
  sourceUrl,
929
941
  sourceKind,
930
942
  }
@@ -1196,7 +1208,7 @@ export async function updateContentItem(
1196
1208
  }
1197
1209
  if (!options.expectedMediaSource) await updateBaseContent()
1198
1210
 
1199
- if (input.content) {
1211
+ if (input.content || videoUpdatePlan || podcastUpdatePlan) {
1200
1212
  if (isBodyBackedContentType(existing.type)) {
1201
1213
  const current = await db
1202
1214
  .prepare(
@@ -1,4 +1,21 @@
1
- import type { D1Database } from './d1.js'
1
+ import type { D1Database, D1PreparedStatement } from './d1.js'
2
+
3
+ /** V2 metadata-stable captures must not survive an invalidating lifecycle transition. */
4
+ export function invalidateMediaCaptureStatements(
5
+ db: D1Database,
6
+ id: string,
7
+ ): D1PreparedStatement[] {
8
+ return [
9
+ db
10
+ .prepare(`UPDATE video_content SET processing_trigger_token = NULL, processing_correlation_id = NULL
11
+ WHERE content_id = ? AND processing_trigger_token LIKE 'fronts-source-v2:%'`)
12
+ .bind(id),
13
+ db
14
+ .prepare(`UPDATE podcast_content SET processing_trigger_token = NULL
15
+ WHERE content_id = ? AND processing_trigger_token LIKE 'fronts-source-v2:%'`)
16
+ .bind(id),
17
+ ]
18
+ }
2
19
 
3
20
  export async function softDeleteContent(
4
21
  db: D1Database,
@@ -6,13 +23,16 @@ export async function softDeleteContent(
6
23
  now?: number,
7
24
  ): Promise<boolean> {
8
25
  const ts = now ?? Math.floor(Date.now() / 1000)
9
- const res = await db
10
- .prepare(
11
- 'UPDATE content_items SET deleted_at = ?, updated_at = unixepoch(), canonical_version = canonical_version + 1 WHERE id = ? AND deleted_at IS NULL',
12
- )
13
- .bind(ts, id)
14
- .run()
15
- return Boolean(res.meta && (res.meta as { changes?: number }).changes)
26
+ const results = await db.batch([
27
+ ...invalidateMediaCaptureStatements(db, id),
28
+ db
29
+ .prepare(
30
+ 'UPDATE content_items SET deleted_at = ?, updated_at = unixepoch(), canonical_version = canonical_version + 1 WHERE id = ? AND deleted_at IS NULL',
31
+ )
32
+ .bind(ts, id),
33
+ ])
34
+ const res = results[2]
35
+ return Boolean(res?.meta && (res.meta as { changes?: number }).changes)
16
36
  }
17
37
 
18
38
  export async function restoreContent(db: D1Database, id: string): Promise<boolean> {
@@ -9,8 +9,10 @@ export interface FrontsVideoSourceSnapshot {
9
9
  }
10
10
 
11
11
  /**
12
- * Stable over polling, machine media callbacks and rescheduling; canonical
13
- * editor/source changes invalidate the old production. The raw trigger token
12
+ * V1 captures retain their original canonical-version binding. New v2 capture
13
+ * tokens separate media identity from editorial revisions: a title/body edit
14
+ * or reschedule cannot cancel useful media work. Slug/source/token changes
15
+ * still change the generation. The raw trigger token
14
16
  * and URL never need to appear in callback or notification identities. The
15
17
  * Go consumer's contract is `fronts-video-source.v1` (foundry
16
18
  * `publishplane/masthead/videosource.schema.json`, exported by
@@ -19,12 +21,13 @@ export interface FrontsVideoSourceSnapshot {
19
21
  export async function frontsVideoSourceGeneration(
20
22
  input: FrontsVideoSourceSnapshot,
21
23
  ): Promise<string> {
24
+ const version = input.processing_trigger_token?.startsWith('fronts-source-v2:') ? 2 : 1
22
25
  const bytes = new TextEncoder().encode(
23
26
  JSON.stringify([
24
- 'fronts-video-source.v1',
27
+ `fronts-video-source.v${version}`,
25
28
  input.content_id,
26
29
  input.slug,
27
- input.canonical_version,
30
+ ...(version === 1 ? [input.canonical_version] : []),
28
31
  input.processing_source_url,
29
32
  input.processing_source_kind,
30
33
  input.processing_trigger_token,
@@ -111,6 +111,13 @@ export default function growthLabsCms(userOptions: CmsIntegrationOptions): Astro
111
111
  pattern: `${base}/api/content/insights-ingest`,
112
112
  entrypoint: resolveEntrypoint('../ui/api/content/insights-ingest'),
113
113
  })
114
+ // Author denylist + existing-content coverage index for foundryd's
115
+ // weekly content-insights funnel (W1-07 PR 6) — M2M poll, HMAC-guarded
116
+ // same as fronts/schedule below, sibling of insights-ingest.
117
+ injectRoute({
118
+ pattern: `${base}/api/content/insights-inputs`,
119
+ entrypoint: resolveEntrypoint('../ui/api/content/insights-inputs'),
120
+ })
114
121
  // Fronts publish path (additive, flag-gated) — M2M siblings of the
115
122
  // foundry-callback / insights-ingest routes. HMAC-guarded.
116
123
  injectRoute({
@@ -13,6 +13,10 @@ import { InsightDismissalReasonCodeSchema } from '../schema/types.js'
13
13
  import type { CmsRouteConfig } from './config.js'
14
14
  import { resolveConfig } from './config.js'
15
15
  import { json, type RouteContext } from './context.js'
16
+ import {
17
+ resolveFoundryHmacSecret as resolveFoundryHmacSecretForInputs,
18
+ verifyFoundrySignedRequest,
19
+ } from './fronts-publish.js'
16
20
 
17
21
  const DismissSchema = z
18
22
  .object({
@@ -72,8 +76,32 @@ export interface ContentInsightsRouteHandlers {
72
76
  undismiss(ctx: RouteContext): Promise<Response>
73
77
  /** POST /content/insights-ingest — HMAC machine ingest (replay-safe replace). */
74
78
  ingest(ctx: RouteContext): Promise<Response>
79
+ /**
80
+ * GET /content/insights-inputs?site=<id> — the funnel's author denylist +
81
+ * existing-content coverage index for this site, read by foundryd's
82
+ * sitebrain.insights.run schedule (W1-07 PR 6). Same M2M signing family as
83
+ * fronts/schedule (X-Foundry-Timestamp + X-Foundry-Signature over the raw
84
+ * query string, verified against FOUNDRY_HMAC_SECRET) -- a sibling poll,
85
+ * not a new auth scheme.
86
+ */
87
+ insightsInputs(ctx: RouteContext): Promise<Response>
75
88
  }
76
89
 
90
+ /** authors row shape this route returns: just enough for the author denylist,
91
+ * never email/bio/social -- the funnel matches on name/slug only. */
92
+ const AUTHORS_QUERY = 'SELECT name, slug FROM authors ORDER BY name LIMIT 1000'
93
+
94
+ /**
95
+ * Existing-content index for lexical coverage: every content item an editor
96
+ * could currently be working on or has already shipped, so the funnel can
97
+ * skip a candidate that duplicates it. status/deleted_at match the-foundry's
98
+ * own SiteContentItemSchema (content-insights/ledger.ts, read-only) exactly,
99
+ * so this row shape ports verbatim.
100
+ */
101
+ const EXISTING_CONTENT_QUERY = `SELECT title, slug FROM content_items
102
+ WHERE status IN ('published', 'draft', 'scheduled', 'review') AND deleted_at IS NULL
103
+ LIMIT 5000`
104
+
77
105
  export function createContentInsightsRoutes(
78
106
  config: ContentInsightsRouteConfig,
79
107
  ): ContentInsightsRouteHandlers {
@@ -181,5 +209,38 @@ export function createContentInsightsRoutes(
181
209
  throw error
182
210
  }
183
211
  },
212
+
213
+ // HMAC-guarded machine poll -- NOT behind requirePublisher, same M2M
214
+ // family as fronts/schedule (timestamp-bound signature over the query
215
+ // string; a GET has no body to bind freshness to instead).
216
+ async insightsInputs(ctx) {
217
+ const hmacSecret = await resolveFoundryHmacSecretForInputs(ctx.env)
218
+ if (!hmacSecret) return json({ error: 'FOUNDRY_HMAC_SECRET is not configured' }, 500)
219
+
220
+ const url = new URL(ctx.request.url)
221
+ const denied = await verifyFoundrySignedRequest(
222
+ ctx.request,
223
+ hmacSecret,
224
+ url.search,
225
+ 'content/insights-inputs',
226
+ )
227
+ if (denied) return denied
228
+
229
+ const requestedSite = url.searchParams.get('site')
230
+ const authorsResult = await ctx.db.prepare(AUTHORS_QUERY).all<{
231
+ name: string
232
+ slug: string
233
+ }>()
234
+ const existingResult = await ctx.db.prepare(EXISTING_CONTENT_QUERY).all<{
235
+ title: string
236
+ slug: string
237
+ }>()
238
+ return json({
239
+ ok: true,
240
+ site: requestedSite ?? siteId,
241
+ authors: authorsResult.results ?? [],
242
+ existing: existingResult.results ?? [],
243
+ })
244
+ },
184
245
  }
185
246
  }
@@ -33,6 +33,10 @@ import {
33
33
  type ProcessingState,
34
34
  verifyHmac,
35
35
  } from '../engine/foundry-dispatch.js'
36
+ import {
37
+ parseFrontsDefaultChannelTargets,
38
+ setFrontsChannelTargets,
39
+ } from '../engine/fronts-publish-intent.js'
36
40
  import { evaluateArticleBody } from '../engine/publish-guard.js'
37
41
  import {
38
42
  createContent,
@@ -55,7 +59,7 @@ import {
55
59
  restoreRevision,
56
60
  } from '../engine/revisions.js'
57
61
  import { applySlugRenameRedirects } from '../engine/slug-redirects.js'
58
- import { softDeleteContent } from '../engine/soft-delete.js'
62
+ import { invalidateMediaCaptureStatements, softDeleteContent } from '../engine/soft-delete.js'
59
63
  import { normalizeSourceSections } from '../engine/source-sections.js'
60
64
  import { TagInputError } from '../engine/taxonomy.js'
61
65
  import type { ValidationError } from '../engine/validator/index.js'
@@ -190,7 +194,23 @@ export type PublishReadinessHook = (input: {
190
194
  status: string
191
195
  }) => Promise<PublishReadinessOutcome>
192
196
 
197
+ export type FrontsScheduleMaintenanceOperation =
198
+ | 'recover-video-sources'
199
+ | 'reconcile-article-enrichment'
200
+
201
+ /** Host-owned bounded recovery/repair work invoked by the signed Fronts schedule route. */
202
+ export type FrontsScheduleMaintenanceHook = (input: {
203
+ ctx: RouteContext
204
+ operation: FrontsScheduleMaintenanceOperation
205
+ limit: 3 | 5
206
+ signal: AbortSignal
207
+ }) => Promise<{ contentIds: string[] }>
208
+
193
209
  export interface ContentRouteConfig extends CmsRouteConfig {
210
+ /** Host-configured publish destinations; only absent stored targets are armed. No global default. */
211
+ frontsDefaultChannelTargets?: readonly string[]
212
+ /** Optional host-owned bounded recovery/repair hook for the signed schedule POST. */
213
+ frontsScheduleMaintenance?: FrontsScheduleMaintenanceHook
194
214
  /** Optional Foundry takeaways dispatcher (bead 08). Omit to disable. */
195
215
  dispatchTakeaways?: DispatchTakeawaysHook
196
216
  /** Optional FAQ enrichment dispatcher (P4 Task 7). Omit to disable. */
@@ -836,6 +856,17 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
836
856
  const dispatchWebhook = config.dispatchWebhook
837
857
  const publishReadiness = config.publishReadiness
838
858
  const activeWorkspaceId = config.activeWorkspaceId ?? 'default'
859
+ const targetDefaults =
860
+ config.frontsDefaultChannelTargets === undefined
861
+ ? null
862
+ : parseFrontsDefaultChannelTargets(config.frontsDefaultChannelTargets)
863
+ const defaultTargetsError = () =>
864
+ targetDefaults && 'error' in targetDefaults ? json({ error: targetDefaults.error }, 500) : null
865
+ async function armDefaultTargets(ctx: RouteContext, contentId: string): Promise<void> {
866
+ if (targetDefaults && 'targets' in targetDefaults) {
867
+ await setFrontsChannelTargets(ctx.db, contentId, targetDefaults.targets, { onlyIfNull: true })
868
+ }
869
+ }
839
870
 
840
871
  async function loadStoredMediaSource(
841
872
  db: D1Database,
@@ -1305,6 +1336,8 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1305
1336
  }
1306
1337
 
1307
1338
  if (op === 'schedule') {
1339
+ const configError = defaultTargetsError()
1340
+ if (configError) return configError
1308
1341
  const { publishAt } = parsed.data
1309
1342
  const publishAtMs = Date.parse(publishAt)
1310
1343
  if (!Number.isFinite(publishAtMs)) {
@@ -1313,6 +1346,7 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1313
1346
  const publishAtSeconds = Math.floor(publishAtMs / 1000)
1314
1347
  for (const id of ids) {
1315
1348
  await scheduleContent(ctx.db, id, publishAtSeconds)
1349
+ await armDefaultTargets(ctx, id)
1316
1350
  }
1317
1351
  return json({ scheduled: ids })
1318
1352
  }
@@ -1329,6 +1363,8 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1329
1363
  const ownAuthor = actor?.subject ?? ctx.userId ?? null
1330
1364
  const denied = await requireContentAction(ctx, 'write', ownAuthor)
1331
1365
  if (denied) return denied
1366
+ const configError = defaultTargetsError()
1367
+ if (configError) return configError
1332
1368
 
1333
1369
  const parsed = CreateSchema.safeParse(await readJson(ctx))
1334
1370
  if (!parsed.success) {
@@ -1360,6 +1396,7 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1360
1396
  if (conflict) return conflict
1361
1397
  throw error
1362
1398
  }
1399
+ await armDefaultTargets(ctx, created.id)
1363
1400
  const podcastAttempt: PodcastFoundryAttempt =
1364
1401
  parsed.data.type === 'podcast'
1365
1402
  ? await dispatchPodcastAfterSave(ctx, created.id)
@@ -1484,6 +1521,8 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1484
1521
  if (config.updateStatusConstraint && existing.status !== config.updateStatusConstraint) {
1485
1522
  return json({ error: 'update_status_conflict', state: existing.status }, 409)
1486
1523
  }
1524
+ const configError = defaultTargetsError()
1525
+ if (configError) return configError
1487
1526
 
1488
1527
  const parsed = UpdateSchema.safeParse(await readJson(ctx))
1489
1528
  if (!parsed.success) {
@@ -1607,6 +1646,7 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1607
1646
  if (updated.mediaSourceConflict) {
1608
1647
  return json({ error: 'media_source_conflict' }, 409)
1609
1648
  }
1649
+ await armDefaultTargets(ctx, id)
1610
1650
  const podcastAttempt: PodcastFoundryAttempt =
1611
1651
  existing.type === 'podcast'
1612
1652
  ? await dispatchPodcastAfterSave(ctx, id)
@@ -1649,6 +1689,8 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1649
1689
  if (denied) return denied
1650
1690
 
1651
1691
  if (action === 'schedule') {
1692
+ const configError = defaultTargetsError()
1693
+ if (configError) return configError
1652
1694
  const parsed = ScheduleSchema.safeParse(await readJson(ctx))
1653
1695
  if (!parsed.success) {
1654
1696
  return json({ error: 'Invalid request', details: parsed.error.flatten() }, 400)
@@ -1680,6 +1722,7 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1680
1722
  }
1681
1723
 
1682
1724
  await scheduleContent(ctx.db, id, publishAtSeconds as number, publishTimeZone)
1725
+ await armDefaultTargets(ctx, id)
1683
1726
 
1684
1727
  // Fire content.scheduled webhook.
1685
1728
  await fireWebhook(ctx, 'content.scheduled', {
@@ -1895,12 +1938,14 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1895
1938
  }
1896
1939
 
1897
1940
  if (action === 'archive') {
1898
- await ctx.db
1899
- .prepare(
1900
- "UPDATE content_items SET status = 'archived', updated_at = unixepoch(), canonical_version = canonical_version + 1 WHERE id = ? AND status <> 'archived'",
1901
- )
1902
- .bind(id)
1903
- .run()
1941
+ await ctx.db.batch([
1942
+ ...invalidateMediaCaptureStatements(ctx.db, id),
1943
+ ctx.db
1944
+ .prepare(
1945
+ "UPDATE content_items SET status = 'archived', updated_at = unixepoch(), canonical_version = canonical_version + 1 WHERE id = ? AND status <> 'archived'",
1946
+ )
1947
+ .bind(id),
1948
+ ])
1904
1949
 
1905
1950
  // Fire content.deleted (archive = soft delete lifecycle) webhook.
1906
1951
  await fireWebhook(ctx, 'content.deleted', {
@@ -2005,36 +2050,16 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
2005
2050
  if (!resolved.hooks.foundryVideo) {
2006
2051
  return json({ error: 'Foundry video dispatch is not configured' }, 501)
2007
2052
  }
2008
- if (existing.type === 'podcast') {
2009
- const row = await ctx.db
2010
- .prepare(
2011
- `SELECT processing_trigger_token
2012
- FROM podcast_content
2013
- WHERE content_id = ?
2014
- LIMIT 1`,
2015
- )
2016
- .bind(id)
2017
- .first<{ processing_trigger_token: string | null }>()
2018
- const existingToken = row?.processing_trigger_token?.trim() ?? ''
2019
- if (existingToken) {
2020
- return json(
2021
- {
2022
- status: 'queued',
2023
- correlationId: null,
2024
- triggerToken: existingToken,
2025
- skipped: true,
2026
- },
2027
- 202,
2028
- )
2029
- }
2030
- }
2031
2053
  try {
2032
- const result = await dispatchToFoundry(ctx.db, id, resolved.hooks.foundryVideo)
2054
+ const result = await dispatchToFoundry(ctx.db, id, resolved.hooks.foundryVideo, {
2055
+ ifUncaptured: url.searchParams.get('capture') === '1',
2056
+ })
2033
2057
  return json(
2034
2058
  {
2035
2059
  status: 'queued',
2036
2060
  correlationId: result.correlationId,
2037
2061
  triggerToken: result.triggerToken,
2062
+ ...(result.skipped ? { skipped: true } : {}),
2038
2063
  },
2039
2064
  202,
2040
2065
  )