@growth-labs/cms 0.8.2 → 0.8.4

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 (98) 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 +115 -39
  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 +187 -57
  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 +135 -10
  20. package/dist/engine/fronts-publish.js.map +1 -1
  21. package/dist/engine/index.d.ts +2 -2
  22. package/dist/engine/index.d.ts.map +1 -1
  23. package/dist/engine/index.js +2 -2
  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/publication-current-guard.d.ts +7 -0
  30. package/dist/engine/publication-current-guard.d.ts.map +1 -1
  31. package/dist/engine/publication-current-guard.js +66 -0
  32. package/dist/engine/publication-current-guard.js.map +1 -1
  33. package/dist/engine/publication.d.ts +30 -0
  34. package/dist/engine/publication.d.ts.map +1 -1
  35. package/dist/engine/publication.js +306 -34
  36. package/dist/engine/publication.js.map +1 -1
  37. package/dist/engine/publisher.d.ts.map +1 -1
  38. package/dist/engine/publisher.js +32 -19
  39. package/dist/engine/publisher.js.map +1 -1
  40. package/dist/engine/soft-delete.d.ts +3 -1
  41. package/dist/engine/soft-delete.d.ts.map +1 -1
  42. package/dist/engine/soft-delete.js +21 -5
  43. package/dist/engine/soft-delete.js.map +1 -1
  44. package/dist/engine/video-source.d.ts +4 -2
  45. package/dist/engine/video-source.d.ts.map +1 -1
  46. package/dist/engine/video-source.js +7 -4
  47. package/dist/engine/video-source.js.map +1 -1
  48. package/dist/routes/content.d.ts +14 -0
  49. package/dist/routes/content.d.ts.map +1 -1
  50. package/dist/routes/content.js +56 -28
  51. package/dist/routes/content.js.map +1 -1
  52. package/dist/routes/context.d.ts +6 -0
  53. package/dist/routes/context.d.ts.map +1 -1
  54. package/dist/routes/context.js.map +1 -1
  55. package/dist/routes/fronts-publish.d.ts +2 -0
  56. package/dist/routes/fronts-publish.d.ts.map +1 -1
  57. package/dist/routes/fronts-publish.js +178 -2
  58. package/dist/routes/fronts-publish.js.map +1 -1
  59. package/dist/schema/insights-ingest.d.ts +42 -42
  60. package/dist/schema/migrations.d.ts.map +1 -1
  61. package/dist/schema/migrations.js +8 -0
  62. package/dist/schema/migrations.js.map +1 -1
  63. package/dist/schema/types.d.ts +6 -0
  64. package/dist/schema/types.d.ts.map +1 -1
  65. package/dist/schema/types.js.map +1 -1
  66. package/dist/surveys/schema.d.ts +48 -48
  67. package/dist/ui/api/_content-config.d.ts.map +1 -1
  68. package/dist/ui/api/_content-config.js +7 -0
  69. package/dist/ui/api/_content-config.js.map +1 -1
  70. package/dist/ui/api/fronts/schedule.d.ts +2 -0
  71. package/dist/ui/api/fronts/schedule.d.ts.map +1 -1
  72. package/dist/ui/api/fronts/schedule.js +2 -0
  73. package/dist/ui/api/fronts/schedule.js.map +1 -1
  74. package/dist/ui/editor/ContentForm.d.ts.map +1 -1
  75. package/dist/ui/editor/ContentForm.js +2 -2
  76. package/dist/ui/editor/ContentForm.js.map +1 -1
  77. package/migrations/0030_media_publication_guard.sql +5 -0
  78. package/package.json +1 -1
  79. package/src/engine/ai-dek-notifications.ts +19 -2
  80. package/src/engine/ai-dek.ts +131 -40
  81. package/src/engine/foundry-dispatch.ts +189 -49
  82. package/src/engine/fronts-publish-intent.ts +295 -67
  83. package/src/engine/fronts-publish.ts +167 -11
  84. package/src/engine/index.ts +4 -0
  85. package/src/engine/podcast-source.ts +6 -4
  86. package/src/engine/publication-current-guard.ts +86 -0
  87. package/src/engine/publication.ts +411 -59
  88. package/src/engine/publisher.ts +37 -17
  89. package/src/engine/soft-delete.ts +28 -8
  90. package/src/engine/video-source.ts +7 -4
  91. package/src/routes/content.ts +78 -35
  92. package/src/routes/context.ts +6 -0
  93. package/src/routes/fronts-publish.ts +213 -2
  94. package/src/schema/migrations.ts +8 -0
  95. package/src/schema/types.ts +6 -0
  96. package/src/ui/api/_content-config.ts +15 -0
  97. package/src/ui/api/fronts/schedule.ts +4 -0
  98. package/src/ui/editor/ContentForm.tsx +4 -5
@@ -22,7 +22,6 @@
22
22
  import type { ContentStatus, ContentType, FrontsPublishStatus } from '../schema/types.js'
23
23
  import { type ContentMetadata, serializeContentMetadata } from './content-metadata.js'
24
24
  import type { D1Database } from './d1.js'
25
- import { armFrontsPublishTriggerToken } from './fronts-publish-intent.js'
26
25
  import { evaluateArticleBody, type PublishGuardOutcome } from './publish-guard.js'
27
26
  import { slugify } from './slug.js'
28
27
  import {
@@ -793,7 +792,10 @@ export async function updateContentItem(
793
792
  // Resolve all media identity/duration invariants before the base content row,
794
793
  // tags, or relations can be mutated. D1 does not provide a transaction on the
795
794
  // structural surface used here, so validation must be a strict preflight.
796
- if (input.content && existing.type === 'video') {
795
+ if (
796
+ (input.content || input.slug !== undefined || input.visibility !== undefined) &&
797
+ existing.type === 'video'
798
+ ) {
797
799
  const current = await db
798
800
  .prepare(
799
801
  `SELECT script, video_id, duration_seconds, thumbnail_image_id,
@@ -813,7 +815,7 @@ export async function updateContentItem(
813
815
  }>()
814
816
 
815
817
  if (current) {
816
- const videoContent = input.content as Partial<VideoInput['content']>
818
+ const videoContent = (input.content ?? {}) as Partial<VideoInput['content']>
817
819
  const sourceUrl = hasOwn(videoContent, 'processingSourceUrl')
818
820
  ? (videoContent.processingSourceUrl ?? null)
819
821
  : current.processing_source_url
@@ -851,14 +853,19 @@ export async function updateContentItem(
851
853
  : current.thumbnail_image_id,
852
854
  sourceUrl,
853
855
  sourceKind,
854
- sourceChanged,
856
+ sourceChanged:
857
+ sourceChanged ||
858
+ resolvedDuration !== current.duration_seconds ||
859
+ (input.slug !== undefined && input.slug !== existing.slug) ||
860
+ (input.visibility !== undefined && input.visibility !== existing.visibility) ||
861
+ (hasOwn(videoContent, 'videoId') && videoContent.videoId !== current.video_id),
855
862
  }
856
863
  } else {
857
864
  videoUpdatePlan = null
858
865
  }
859
866
  }
860
867
 
861
- if (input.content && existing.type === 'podcast') {
868
+ if ((input.content || input.slug !== undefined) && existing.type === 'podcast') {
862
869
  const current = await db
863
870
  .prepare(
864
871
  `SELECT transcript, audio_r2_key, duration_seconds, processing_trigger_token,
@@ -878,7 +885,7 @@ export async function updateContentItem(
878
885
  }>()
879
886
 
880
887
  if (current) {
881
- const podcastContent = input.content as Partial<PodcastInput['content']>
888
+ const podcastContent = (input.content ?? {}) as Partial<PodcastInput['content']>
882
889
  const audioR2Key = hasOwn(podcastContent, 'audioR2Key')
883
890
  ? (podcastContent.audioR2Key ?? current.audio_r2_key)
884
891
  : current.audio_r2_key
@@ -910,9 +917,13 @@ export async function updateContentItem(
910
917
  )
911
918
  const replacementTranscriptProvided =
912
919
  hasOwn(podcastContent, 'transcript') && typeof podcastContent.transcript === 'string'
920
+ const preparationChanged =
921
+ sourceChanged ||
922
+ resolvedDuration !== current.duration_seconds ||
923
+ (input.slug !== undefined && input.slug !== existing.slug)
913
924
 
914
925
  podcastUpdatePlan = {
915
- transcript: sourceChanged
926
+ transcript: preparationChanged
916
927
  ? replacementTranscriptProvided
917
928
  ? (podcastContent.transcript as string)
918
929
  : ''
@@ -924,7 +935,7 @@ export async function updateContentItem(
924
935
  durationProvided || sourceChanged
925
936
  ? resolvedDuration
926
937
  : (resolvedDuration ?? current.duration_seconds),
927
- processingTriggerToken: sourceChanged ? null : current.processing_trigger_token,
938
+ processingTriggerToken: preparationChanged ? null : current.processing_trigger_token,
928
939
  sourceUrl,
929
940
  sourceKind,
930
941
  }
@@ -1196,7 +1207,7 @@ export async function updateContentItem(
1196
1207
  }
1197
1208
  if (!options.expectedMediaSource) await updateBaseContent()
1198
1209
 
1199
- if (input.content) {
1210
+ if (input.content || videoUpdatePlan || podcastUpdatePlan) {
1200
1211
  if (isBodyBackedContentType(existing.type)) {
1201
1212
  const current = await db
1202
1213
  .prepare(
@@ -1307,6 +1318,7 @@ export async function updateContentItem(
1307
1318
  processing_state = CASE WHEN ? THEN 'pending' ELSE processing_state END,
1308
1319
  processing_correlation_id = CASE WHEN ? THEN NULL ELSE processing_correlation_id END,
1309
1320
  processing_trigger_token = CASE WHEN ? THEN NULL ELSE processing_trigger_token END,
1321
+ processing_publication_guard = CASE WHEN ? THEN NULL ELSE processing_publication_guard END,
1310
1322
  processing_started_at = CASE WHEN ? THEN NULL ELSE processing_started_at END,
1311
1323
  processing_completed_at = CASE WHEN ? THEN NULL ELSE processing_completed_at END,
1312
1324
  processing_last_event_at = CASE WHEN ? THEN NULL ELSE processing_last_event_at END,
@@ -1341,6 +1353,7 @@ export async function updateContentItem(
1341
1353
  videoUpdatePlan.sourceChanged ? 1 : 0,
1342
1354
  videoUpdatePlan.sourceChanged ? 1 : 0,
1343
1355
  videoUpdatePlan.sourceChanged ? 1 : 0,
1356
+ videoUpdatePlan.sourceChanged ? 1 : 0,
1344
1357
  id,
1345
1358
  options.expectedMediaSource ? 1 : 0,
1346
1359
  options.expectedMediaSource?.sourceUrl ?? null,
@@ -1362,6 +1375,8 @@ export async function updateContentItem(
1362
1375
  audio_r2_key = ?,
1363
1376
  duration_seconds = ?,
1364
1377
  processing_trigger_token = ?,
1378
+ processing_publication_guard = CASE WHEN ? IS processing_trigger_token THEN processing_publication_guard ELSE NULL END,
1379
+ processing_state = CASE WHEN ? IS processing_trigger_token THEN processing_state ELSE NULL END,
1365
1380
  processing_source_url = ?,
1366
1381
  processing_source_kind = ?
1367
1382
  WHERE content_id = ?
@@ -1373,6 +1388,8 @@ export async function updateContentItem(
1373
1388
  podcastUpdatePlan.audioR2Key,
1374
1389
  podcastUpdatePlan.durationSeconds,
1375
1390
  podcastUpdatePlan.processingTriggerToken,
1391
+ podcastUpdatePlan.processingTriggerToken,
1392
+ podcastUpdatePlan.processingTriggerToken,
1376
1393
  podcastUpdatePlan.sourceUrl,
1377
1394
  podcastUpdatePlan.sourceKind,
1378
1395
  id,
@@ -1399,16 +1416,19 @@ export async function scheduleContent(
1399
1416
  ): Promise<void> {
1400
1417
  await db
1401
1418
  .prepare(
1402
- `UPDATE content_items SET status = 'scheduled', publish_at = ?, publish_tz = ?, updated_at = unixepoch() WHERE id = ?`,
1419
+ `UPDATE content_items SET status = 'scheduled', publish_at = ?, publish_tz = ?,
1420
+ canonical_version = canonical_version + CASE WHEN status = 'published' OR fronts_publish_status = 'live' THEN 1 ELSE 0 END,
1421
+ fronts_publish_status = CASE WHEN status = 'published' OR fronts_publish_status = 'live'
1422
+ THEN 'queued' ELSE fronts_publish_status END,
1423
+ fronts_publish_trigger_token = CASE WHEN status = 'published' OR fronts_publish_status = 'live'
1424
+ OR fronts_publish_trigger_token IS NULL THEN ? ELSE fronts_publish_trigger_token END,
1425
+ updated_at = unixepoch() WHERE id = ?`,
1403
1426
  )
1404
- .bind(publishAt, publishTz, id)
1427
+ .bind(publishAt, publishTz, crypto.randomUUID(), id)
1405
1428
  .run()
1406
- // Arming an item for the additive Fronts publish path happens HERE — the one
1407
- // seam through which an item becomes `scheduled` with a `publish_at`. The mint
1408
- // is compare-and-set and never rotates an existing token, so an item's
1409
- // `trigger_token_hash` is stable across reschedules and across every poll.
1410
- // The raw token stays in D1; only `sha256:<hex>` is ever exposed.
1411
- await armFrontsPublishTriggerToken(db, id)
1429
+ // Slot, terminal-ledger reset and token are one atomic transition. Moving an
1430
+ // already scheduled occurrence preserves its identity; scheduling a completed
1431
+ // publication creates a fresh occurrence without recapturing its media.
1412
1432
  }
1413
1433
 
1414
1434
  export async function evaluateContentBodyForPublish(
@@ -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,
@@ -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. */
@@ -369,6 +389,24 @@ const ContentMetadataSchema = z
369
389
  })
370
390
  .transform((metadata) => metadata as ContentMetadata)
371
391
 
392
+ // Known Nextcloud share pages are HTML, never ingestable media. The editor
393
+ // must save the direct-download URL so the existing provider validates bytes.
394
+ const MediaSourceUrlSchema = z
395
+ .string()
396
+ .url()
397
+ .refine(
398
+ (value) => {
399
+ let url: URL
400
+ try {
401
+ url = new URL(value)
402
+ } catch {
403
+ return false
404
+ }
405
+ return !(url.hostname === 'drive.fulcrum-portal.com' && /^\/s\/[^/]+\/?$/.test(url.pathname))
406
+ },
407
+ { message: 'Use the Nextcloud direct-download URL ending in /download, not its share page.' },
408
+ )
409
+
372
410
  function buildSchemas(resolved: ReturnType<typeof resolveConfig>) {
373
411
  const category = slugEnum(resolved.primaryCategorySlugs)
374
412
  const topic = slugEnum(resolved.primaryTopicSlugs)
@@ -410,7 +448,7 @@ function buildSchemas(resolved: ReturnType<typeof resolveConfig>) {
410
448
  videoId: z.string().min(1),
411
449
  durationSeconds: z.number().int().positive().optional().nullable(),
412
450
  thumbnailImageId: z.string().optional().nullable(),
413
- processingSourceUrl: z.string().url().optional().nullable(),
451
+ processingSourceUrl: MediaSourceUrlSchema.optional().nullable(),
414
452
  processingSourceKind: z.string().min(1).optional().nullable(),
415
453
  }),
416
454
  }),
@@ -430,7 +468,7 @@ function buildSchemas(resolved: ReturnType<typeof resolveConfig>) {
430
468
  // there because a video always has a slug-derived identity up front.
431
469
  audioR2Key: z.string().default(''),
432
470
  durationSeconds: z.number().int().positive().optional().nullable(),
433
- processingSourceUrl: z.string().url().optional().nullable(),
471
+ processingSourceUrl: MediaSourceUrlSchema.optional().nullable(),
434
472
  processingSourceKind: z.string().min(1).optional().nullable(),
435
473
  }),
436
474
  }),
@@ -495,7 +533,7 @@ const VideoContentUpdateSchema = z.object({
495
533
  videoId: z.string().optional(),
496
534
  durationSeconds: z.number().int().positive().optional().nullable(),
497
535
  thumbnailImageId: z.string().optional().nullable(),
498
- processingSourceUrl: z.string().url().optional().nullable(),
536
+ processingSourceUrl: MediaSourceUrlSchema.optional().nullable(),
499
537
  processingSourceKind: z.string().min(1).optional().nullable(),
500
538
  })
501
539
 
@@ -503,7 +541,7 @@ const PodcastContentUpdateSchema = z.object({
503
541
  transcript: z.string().optional(),
504
542
  audioR2Key: z.string().optional(),
505
543
  durationSeconds: z.number().int().positive().optional().nullable(),
506
- processingSourceUrl: z.string().url().optional().nullable(),
544
+ processingSourceUrl: MediaSourceUrlSchema.optional().nullable(),
507
545
  processingSourceKind: z.string().min(1).optional().nullable(),
508
546
  })
509
547
 
@@ -836,6 +874,17 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
836
874
  const dispatchWebhook = config.dispatchWebhook
837
875
  const publishReadiness = config.publishReadiness
838
876
  const activeWorkspaceId = config.activeWorkspaceId ?? 'default'
877
+ const targetDefaults =
878
+ config.frontsDefaultChannelTargets === undefined
879
+ ? null
880
+ : parseFrontsDefaultChannelTargets(config.frontsDefaultChannelTargets)
881
+ const defaultTargetsError = () =>
882
+ targetDefaults && 'error' in targetDefaults ? json({ error: targetDefaults.error }, 500) : null
883
+ async function armDefaultTargets(ctx: RouteContext, contentId: string): Promise<void> {
884
+ if (targetDefaults && 'targets' in targetDefaults) {
885
+ await setFrontsChannelTargets(ctx.db, contentId, targetDefaults.targets, { onlyIfNull: true })
886
+ }
887
+ }
839
888
 
840
889
  async function loadStoredMediaSource(
841
890
  db: D1Database,
@@ -1305,6 +1354,8 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1305
1354
  }
1306
1355
 
1307
1356
  if (op === 'schedule') {
1357
+ const configError = defaultTargetsError()
1358
+ if (configError) return configError
1308
1359
  const { publishAt } = parsed.data
1309
1360
  const publishAtMs = Date.parse(publishAt)
1310
1361
  if (!Number.isFinite(publishAtMs)) {
@@ -1313,6 +1364,7 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1313
1364
  const publishAtSeconds = Math.floor(publishAtMs / 1000)
1314
1365
  for (const id of ids) {
1315
1366
  await scheduleContent(ctx.db, id, publishAtSeconds)
1367
+ await armDefaultTargets(ctx, id)
1316
1368
  }
1317
1369
  return json({ scheduled: ids })
1318
1370
  }
@@ -1329,6 +1381,8 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1329
1381
  const ownAuthor = actor?.subject ?? ctx.userId ?? null
1330
1382
  const denied = await requireContentAction(ctx, 'write', ownAuthor)
1331
1383
  if (denied) return denied
1384
+ const configError = defaultTargetsError()
1385
+ if (configError) return configError
1332
1386
 
1333
1387
  const parsed = CreateSchema.safeParse(await readJson(ctx))
1334
1388
  if (!parsed.success) {
@@ -1360,6 +1414,7 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1360
1414
  if (conflict) return conflict
1361
1415
  throw error
1362
1416
  }
1417
+ await armDefaultTargets(ctx, created.id)
1363
1418
  const podcastAttempt: PodcastFoundryAttempt =
1364
1419
  parsed.data.type === 'podcast'
1365
1420
  ? await dispatchPodcastAfterSave(ctx, created.id)
@@ -1484,6 +1539,8 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1484
1539
  if (config.updateStatusConstraint && existing.status !== config.updateStatusConstraint) {
1485
1540
  return json({ error: 'update_status_conflict', state: existing.status }, 409)
1486
1541
  }
1542
+ const configError = defaultTargetsError()
1543
+ if (configError) return configError
1487
1544
 
1488
1545
  const parsed = UpdateSchema.safeParse(await readJson(ctx))
1489
1546
  if (!parsed.success) {
@@ -1607,6 +1664,7 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1607
1664
  if (updated.mediaSourceConflict) {
1608
1665
  return json({ error: 'media_source_conflict' }, 409)
1609
1666
  }
1667
+ await armDefaultTargets(ctx, id)
1610
1668
  const podcastAttempt: PodcastFoundryAttempt =
1611
1669
  existing.type === 'podcast'
1612
1670
  ? await dispatchPodcastAfterSave(ctx, id)
@@ -1649,6 +1707,8 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1649
1707
  if (denied) return denied
1650
1708
 
1651
1709
  if (action === 'schedule') {
1710
+ const configError = defaultTargetsError()
1711
+ if (configError) return configError
1652
1712
  const parsed = ScheduleSchema.safeParse(await readJson(ctx))
1653
1713
  if (!parsed.success) {
1654
1714
  return json({ error: 'Invalid request', details: parsed.error.flatten() }, 400)
@@ -1680,6 +1740,7 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1680
1740
  }
1681
1741
 
1682
1742
  await scheduleContent(ctx.db, id, publishAtSeconds as number, publishTimeZone)
1743
+ await armDefaultTargets(ctx, id)
1683
1744
 
1684
1745
  // Fire content.scheduled webhook.
1685
1746
  await fireWebhook(ctx, 'content.scheduled', {
@@ -1895,12 +1956,14 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1895
1956
  }
1896
1957
 
1897
1958
  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()
1959
+ await ctx.db.batch([
1960
+ ...invalidateMediaCaptureStatements(ctx.db, id),
1961
+ ctx.db
1962
+ .prepare(
1963
+ "UPDATE content_items SET status = 'archived', updated_at = unixepoch(), canonical_version = canonical_version + 1 WHERE id = ? AND status <> 'archived'",
1964
+ )
1965
+ .bind(id),
1966
+ ])
1904
1967
 
1905
1968
  // Fire content.deleted (archive = soft delete lifecycle) webhook.
1906
1969
  await fireWebhook(ctx, 'content.deleted', {
@@ -2005,36 +2068,16 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
2005
2068
  if (!resolved.hooks.foundryVideo) {
2006
2069
  return json({ error: 'Foundry video dispatch is not configured' }, 501)
2007
2070
  }
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
2071
  try {
2032
- const result = await dispatchToFoundry(ctx.db, id, resolved.hooks.foundryVideo)
2072
+ const result = await dispatchToFoundry(ctx.db, id, resolved.hooks.foundryVideo, {
2073
+ ifUncaptured: url.searchParams.get('capture') === '1',
2074
+ })
2033
2075
  return json(
2034
2076
  {
2035
2077
  status: 'queued',
2036
2078
  correlationId: result.correlationId,
2037
2079
  triggerToken: result.triggerToken,
2080
+ ...(result.skipped ? { skipped: true } : {}),
2038
2081
  },
2039
2082
  202,
2040
2083
  )
@@ -119,6 +119,12 @@ export interface Authz {
119
119
  export interface RouteContext {
120
120
  /** The inbound request (standard Fetch `Request`). */
121
121
  request: Request
122
+ /**
123
+ * Host-supplied execution-lifetime bridge. Maintenance-capable adapters
124
+ * provide this so abort cleanup can settle after the HTTP response; adapters
125
+ * must bind methods that depend on their original receiver.
126
+ */
127
+ waitUntil?: (promise: Promise<unknown>) => void
122
128
  /** Route params (e.g. `{ id }` for `/content/[id]`). */
123
129
  params: Record<string, string | undefined>
124
130
  /** The site's D1 binding. */