@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
@@ -2,6 +2,7 @@
2
2
  // the out-of-process Fronts publish worker (foundryd) drives:
3
3
  //
4
4
  // GET /admin/api/fronts/schedule?window=<from>..<to> — the poll surface.
5
+ // POST /admin/api/fronts/schedule — bounded schedule maintenance.
5
6
  // POST /admin/api/fronts/publish-callback — the callback receiver.
6
7
  //
7
8
  // AUTH reuses the existing M2M primitive (`verifyHmac` + `X-Foundry-Signature`
@@ -18,11 +19,12 @@
18
19
  // CONSTANT — so one captured URL+header pair would be a permanent bearer
19
20
  // credential for the pre-publication embargo schedule.
20
21
  //
21
- // Both endpoints ship ENABLED behind FRONTS_PUBLISH_ENABLED (a rollback lever,
22
+ // The endpoints ship ENABLED behind FRONTS_PUBLISH_ENABLED (a rollback lever,
22
23
  // default ON). Failures are loud: bad/stale HMAC → 401 + warn log; a `live`
23
24
  // callback that cannot commit through the ledger → 500 + error log; disabled → 503.
24
25
 
25
26
  import { z } from 'zod'
27
+ import { createDekFailureNotifications } from '../engine/ai-dek-notifications.js'
26
28
  import { verifyHmac } from '../engine/foundry-dispatch.js'
27
29
  import {
28
30
  applyFrontsPublishCallback,
@@ -30,12 +32,19 @@ import {
30
32
  listFrontsPublishSchedule,
31
33
  } from '../engine/fronts-publish.js'
32
34
  import {
35
+ backfillFrontsChannelTargets,
33
36
  FrontsScheduleOverflowError,
37
+ hydrateFrontsMediaPreparations,
34
38
  hydrateFrontsPublishIntents,
39
+ parseFrontsDefaultChannelTargets,
35
40
  } from '../engine/fronts-publish-intent.js'
36
41
  import { publicUrlFor } from '../ui/screens/public-url.js'
37
42
  import { resolveConfig } from './config.js'
38
- import type { ContentRouteConfig } from './content.js'
43
+ import type {
44
+ ContentRouteConfig,
45
+ FrontsScheduleMaintenanceHook,
46
+ FrontsScheduleMaintenanceOperation,
47
+ } from './content.js'
39
48
  import { json, type RouteContext } from './context.js'
40
49
 
41
50
  /**
@@ -130,9 +139,92 @@ const CallbackSchema = z
130
139
  })
131
140
  .strip()
132
141
 
142
+ const ArmChannelTargetsSchema = z
143
+ .object({
144
+ operation: z.literal('arm-channel-targets'),
145
+ limit: z.number().int().min(1).max(50).default(50),
146
+ })
147
+ .strict()
148
+
149
+ const FrontsScheduleMaintenanceSchema = z.union([
150
+ z
151
+ .object({
152
+ operation: z.literal('recover-video-sources'),
153
+ limit: z.literal(3).default(3),
154
+ })
155
+ .strict(),
156
+ z
157
+ .object({
158
+ operation: z.literal('reconcile-article-enrichment'),
159
+ limit: z.literal(5).default(5),
160
+ })
161
+ .strict(),
162
+ ])
163
+
164
+ const FRONTS_SCHEDULE_MAINTENANCE_TIMEOUT_MS = 25_000
165
+
166
+ class FrontsScheduleMaintenanceTimeoutError extends Error {}
167
+
168
+ function isMaintenanceOperation(value: unknown): value is FrontsScheduleMaintenanceOperation {
169
+ return value === 'recover-video-sources' || value === 'reconcile-article-enrichment'
170
+ }
171
+
172
+ function validateMaintenanceResult(result: unknown, limit: 3 | 5): { contentIds: string[] } | null {
173
+ const parsed = z
174
+ .object({
175
+ contentIds: z.array(z.string().refine((id) => id.trim().length > 0)).max(limit),
176
+ })
177
+ .strict()
178
+ .safeParse(result)
179
+ if (!parsed.success || new Set(parsed.data.contentIds).size !== parsed.data.contentIds.length)
180
+ return null
181
+ return parsed.data
182
+ }
183
+
184
+ async function runScheduleMaintenance(
185
+ ctx: RouteContext,
186
+ hook: FrontsScheduleMaintenanceHook,
187
+ operation: FrontsScheduleMaintenanceOperation,
188
+ limit: 3 | 5,
189
+ ): Promise<{ contentIds: string[] }> {
190
+ const controller = new AbortController()
191
+ let rejectDeadline: (error: FrontsScheduleMaintenanceTimeoutError) => void = () => undefined
192
+ let deadlineReached = false
193
+ const deadline = new Promise<never>((_resolve, reject) => {
194
+ rejectDeadline = reject
195
+ })
196
+ const abort = () => {
197
+ if (deadlineReached) return
198
+ deadlineReached = true
199
+ rejectDeadline(new FrontsScheduleMaintenanceTimeoutError())
200
+ controller.abort()
201
+ }
202
+ if (ctx.request.signal.aborted) abort()
203
+ else ctx.request.signal.addEventListener('abort', abort, { once: true })
204
+ const timer = setTimeout(abort, FRONTS_SCHEDULE_MAINTENANCE_TIMEOUT_MS)
205
+ const work = Promise.resolve().then(() =>
206
+ hook({ ctx, operation, limit, signal: controller.signal }),
207
+ )
208
+ // Keep the already-running hook alive through abort cleanup while observing
209
+ // either settlement so a late private rejection cannot escape the runtime.
210
+ const observedWork = work.then(
211
+ () => undefined,
212
+ () => undefined,
213
+ )
214
+ ctx.waitUntil?.(observedWork)
215
+ try {
216
+ return await Promise.race([work, deadline])
217
+ } finally {
218
+ clearTimeout(timer)
219
+ ctx.request.signal.removeEventListener('abort', abort)
220
+ }
221
+ }
222
+
133
223
  export interface FrontsPublishRouteHandlers {
134
224
  /** GET /fronts/schedule?window=<from>..<to> — items due for foundryd to publish. */
135
225
  schedule(ctx: RouteContext): Promise<Response>
226
+ /** POST /fronts/schedule — bounded target arming or host-owned maintenance. */
227
+ maintainSchedule(ctx: RouteContext): Promise<Response>
136
228
  /** POST /fronts/publish-callback — foundryd's outbound lifecycle callback. */
137
229
  publishCallback(ctx: RouteContext): Promise<Response>
138
230
  }
@@ -164,6 +256,90 @@ function parseWindow(
164
256
  export function createFrontsPublishRoutes(config: ContentRouteConfig): FrontsPublishRouteHandlers {
165
257
  const resolved = resolveConfig(config)
166
258
  return {
259
+ async maintainSchedule(ctx) {
260
+ let responseOperation: 'arm-channel-targets' | FrontsScheduleMaintenanceOperation =
261
+ 'arm-channel-targets'
262
+ const respond = (body: Record<string, unknown>, status = 200) =>
263
+ json({ version: 1, operation: responseOperation, ...body }, status)
264
+ const hmacSecret = await resolveFoundryHmacSecret(ctx.env)
265
+ if (!hmacSecret)
266
+ return respond({ ok: false, error: 'foundry_hmac_secret_not_configured' }, 500)
267
+ let rawBody: string
268
+ try {
269
+ rawBody = await ctx.request.text()
270
+ } catch {
271
+ return respond({ ok: false, error: 'invalid_channel_targets_request' }, 400)
272
+ }
273
+ const denied = await verifyFoundrySignedRequest(
274
+ ctx.request,
275
+ hmacSecret,
276
+ rawBody,
277
+ 'fronts/schedule.arm-channel-targets',
278
+ )
279
+ if (denied) return respond({ ok: false, error: 'invalid_foundry_signature' }, denied.status)
280
+ if (!isFrontsPublishEnabled(ctx.env))
281
+ return respond({ ok: false, error: 'fronts_publish_disabled' }, 503)
282
+ let raw: unknown
283
+ try {
284
+ raw = JSON.parse(rawBody)
285
+ } catch {
286
+ return respond({ ok: false, error: 'invalid_channel_targets_request' }, 400)
287
+ }
288
+ const rawOperation =
289
+ typeof raw === 'object' && raw !== null && 'operation' in raw
290
+ ? (raw as { operation?: unknown }).operation
291
+ : undefined
292
+ if (isMaintenanceOperation(rawOperation)) {
293
+ responseOperation = rawOperation
294
+ const parsed = FrontsScheduleMaintenanceSchema.safeParse(raw)
295
+ if (!parsed.success)
296
+ return respond({ ok: false, error: 'invalid_fronts_schedule_maintenance_request' }, 400)
297
+ if (!config.frontsScheduleMaintenance)
298
+ return respond({ ok: false, error: 'fronts_schedule_maintenance_unconfigured' }, 503)
299
+ try {
300
+ const rawResult = await runScheduleMaintenance(
301
+ ctx,
302
+ config.frontsScheduleMaintenance,
303
+ parsed.data.operation,
304
+ parsed.data.limit,
305
+ )
306
+ const result = validateMaintenanceResult(rawResult, parsed.data.limit)
307
+ if (!result)
308
+ return respond({ ok: false, error: 'fronts_schedule_maintenance_invalid_result' }, 500)
309
+ return respond({
310
+ ok: true,
311
+ limit: parsed.data.limit,
312
+ completed: result.contentIds.length,
313
+ contentIds: result.contentIds,
314
+ })
315
+ } catch (error) {
316
+ if (error instanceof FrontsScheduleMaintenanceTimeoutError)
317
+ return respond({ ok: false, error: 'fronts_schedule_maintenance_timeout' }, 503)
318
+ console.error('[cms] fronts/schedule host maintenance failed')
319
+ return respond({ ok: false, error: 'fronts_schedule_maintenance_failed' }, 500)
320
+ }
321
+ }
322
+ const parsed = ArmChannelTargetsSchema.safeParse(raw)
323
+ if (!parsed.success)
324
+ return respond({ ok: false, error: 'invalid_channel_targets_request' }, 400)
325
+ const defaults = parseFrontsDefaultChannelTargets(config.frontsDefaultChannelTargets)
326
+ if ('error' in defaults)
327
+ return respond(
328
+ { ok: false, error: defaults.error },
329
+ defaults.error === 'missing_fronts_default_channel_targets' ? 503 : 500,
330
+ )
331
+ try {
332
+ const result = await backfillFrontsChannelTargets(
333
+ ctx.db,
334
+ defaults.targets,
335
+ parsed.data.limit,
336
+ )
337
+ return respond({ ok: true, limit: parsed.data.limit, ...result })
338
+ } catch (error) {
339
+ console.error('[cms] fronts/schedule target arming failed', error)
340
+ return respond({ ok: false, error: 'fronts_channel_targets_backfill_failed' }, 500)
341
+ }
342
+ },
167
343
  async schedule(ctx) {
168
344
  const hmacSecret = await resolveFoundryHmacSecret(ctx.env)
169
345
  if (!hmacSecret) return json({ error: 'FOUNDRY_HMAC_SECRET is not configured' }, 500)
@@ -210,6 +386,10 @@ export function createFrontsPublishRoutes(config: ContentRouteConfig): FrontsPub
210
386
  // origin is deliberately deferred (packages-docs/cms.md).
211
387
  resolveContentUrl: (row) => publicUrlFor(row),
212
388
  })
389
+ const preparations = await hydrateFrontsMediaPreparations(ctx.db, {
390
+ podcastSourceProductionEnabled: config.podcastSourceProductionEnabled,
391
+ videoSourceProductionEnabled: config.videoSourceProductionEnabled,
392
+ })
213
393
  return json({
214
394
  ok: true,
215
395
  now,
@@ -218,6 +398,7 @@ export function createFrontsPublishRoutes(config: ContentRouteConfig): FrontsPub
218
398
  items,
219
399
  intents: hydrated.intents,
220
400
  incomplete: hydrated.incomplete,
401
+ preparations,
221
402
  })
222
403
  } catch (error) {
223
404
  if (error instanceof FrontsScheduleOverflowError) {
@@ -279,6 +460,7 @@ export function createFrontsPublishRoutes(config: ContentRouteConfig): FrontsPub
279
460
  occurredAt: parsed.data.occurred_at ?? null,
280
461
  receipt: parsed.data.receipt ?? null,
281
462
  error: parsed.data.error ?? null,
463
+ llm: resolved.hooks.llm,
282
464
  checkReadiness: config.publishReadiness
283
465
  ? (item) =>
284
466
  config.publishReadiness!({
@@ -323,6 +505,35 @@ export function createFrontsPublishRoutes(config: ContentRouteConfig): FrontsPub
323
505
  500,
324
506
  )
325
507
  }
508
+ const newlyPublished =
509
+ result.ok && result.published && !result.deduped && result.attemptId !== null
510
+ if (newlyPublished && result.publishedItem) {
511
+ if (result.dekFailureReason) {
512
+ try {
513
+ await createDekFailureNotifications({
514
+ db: ctx.db,
515
+ workspaceId: config.activeWorkspaceId ?? 'default',
516
+ contentId: result.publishedItem.id,
517
+ title: result.publishedItem.title,
518
+ reason: result.dekFailureReason,
519
+ occurrenceId: `${eventId}:${result.attemptId}`,
520
+ })
521
+ } catch {
522
+ console.error('[cms] failed to create post-publication dek notification')
523
+ }
524
+ }
525
+ if (config.dispatchWebhook) {
526
+ try {
527
+ await config.dispatchWebhook(ctx, {
528
+ eventType: 'content.published',
529
+ workspaceId: config.activeWorkspaceId ?? 'default',
530
+ data: result.publishedItem,
531
+ })
532
+ } catch {
533
+ console.error('[cms] content.published webhook dispatch failed')
534
+ }
535
+ }
536
+ }
326
537
 
327
538
  return json({
328
539
  ok: true,
@@ -902,6 +902,14 @@ CREATE INDEX idx_cms_foundry_articles_updated
902
902
  ON cms_foundry_articles(updated_at DESC);
903
903
  `,
904
904
  },
905
+ {
906
+ id: '0030_media_publication_guard',
907
+ sql: `
908
+ ALTER TABLE video_content ADD COLUMN processing_publication_guard TEXT;
909
+ ALTER TABLE podcast_content ADD COLUMN processing_publication_guard TEXT;
910
+ ALTER TABLE podcast_content ADD COLUMN processing_state TEXT CHECK (processing_state IS NULL OR processing_state IN ('pending','queued','fetching','transcribing','transcoding','ready','failed'));
911
+ `,
912
+ },
905
913
  ]
906
914
 
907
915
  /**
@@ -187,6 +187,8 @@ export interface ArticleContentRow {
187
187
  */
188
188
  export interface VideoContentRow {
189
189
  content_id: string
190
+ /** Capture-time published revision fence; null for historical/unpublished captures. */
191
+ processing_publication_guard?: string | null
190
192
  script: string
191
193
  video_id: string
192
194
  duration_seconds: number | null
@@ -211,6 +213,10 @@ export interface VideoContentRow {
211
213
 
212
214
  export interface PodcastContentRow {
213
215
  content_id: string
216
+ /** Explicit preparation lifecycle; historical captures remain unqueued. */
217
+ processing_state?: VideoProcessingState | null
218
+ /** Capture-time published revision fence; null for historical/unpublished captures. */
219
+ processing_publication_guard?: string | null
214
220
  transcript: string
215
221
  audio_r2_key: string
216
222
  duration_seconds: number | null
@@ -8,6 +8,7 @@ import type {
8
8
  DispatchFaqHook,
9
9
  DispatchTakeawaysHook,
10
10
  DispatchWebhookHook,
11
+ FrontsScheduleMaintenanceHook,
11
12
  PublishReadinessHook,
12
13
  } from '../../routes/content.js'
13
14
  import type { R2BucketLike, RouteContext } from '../../routes/context.js'
@@ -20,6 +21,8 @@ import { buildSiteAuthz } from './_authz.js'
20
21
  * ContentRouteConfig so adapters can surface them without a separate locals key.
21
22
  */
22
23
  type CmsHooksLocals = ContentRouteConfig['hooks'] & {
24
+ frontsDefaultChannelTargets?: ContentRouteConfig['frontsDefaultChannelTargets']
25
+ frontsScheduleMaintenance?: FrontsScheduleMaintenanceHook
23
26
  activeWorkspaceId?: string
24
27
  dispatchTakeaways?: DispatchTakeawaysHook
25
28
  dispatchFaq?: DispatchFaqHook
@@ -40,12 +43,22 @@ type CmsHooksLocals = ContentRouteConfig['hooks'] & {
40
43
  */
41
44
  export function toContentCtx(c: APIContext, params?: Record<string, string>): RouteContext {
42
45
  const env = (c.locals as { cmsEnv?: Record<string, unknown> }).cmsEnv ?? {}
46
+ const cfContext = (
47
+ c.locals as {
48
+ cfContext?: { waitUntil?: (promise: Promise<unknown>) => void }
49
+ }
50
+ ).cfContext
51
+ const waitUntil =
52
+ cfContext && typeof cfContext.waitUntil === 'function'
53
+ ? cfContext.waitUntil.bind(cfContext)
54
+ : undefined
43
55
  const userId =
44
56
  (c.locals as { user?: { subject?: string } }).user?.subject ??
45
57
  (env.__identitySubject as string | undefined) ??
46
58
  null
47
59
  return {
48
60
  request: c.request,
61
+ ...(waitUntil ? { waitUntil } : {}),
49
62
  params: params ?? (c.params as Record<string, string>),
50
63
  db: env.SITE_DB as RouteContext['db'],
51
64
  bucket: (env.PUBLIC_MEDIA ?? env.MEDIA) as R2BucketLike | undefined,
@@ -75,6 +88,8 @@ export function toContentConfig(c: APIContext): ContentRouteConfig {
75
88
  : undefined,
76
89
  requiredPrimaryTopicContentTypes: config.primaryTopic?.contentTypes,
77
90
  activeWorkspaceId: hooks.activeWorkspaceId,
91
+ frontsDefaultChannelTargets: hooks.frontsDefaultChannelTargets,
92
+ frontsScheduleMaintenance: hooks.frontsScheduleMaintenance,
78
93
  hooks: {
79
94
  llm: hooks.llm,
80
95
  foundryVideo: hooks.foundryVideo,
@@ -10,3 +10,7 @@ import { toContentConfig, toContentCtx } from '../_content-config.js'
10
10
 
11
11
  export const GET = (c: APIContext) =>
12
12
  createFrontsPublishRoutes(toContentConfig(c)).schedule(toContentCtx(c, {}))
13
+
14
+ /** Go requests one bounded maintenance operation; the GET projection never mutates. */
15
+ export const POST = (c: APIContext) =>
16
+ createFrontsPublishRoutes(toContentConfig(c)).maintainSchedule(toContentCtx(c, {}))
@@ -32,7 +32,6 @@ import {
32
32
  applyMediaSourceReadiness,
33
33
  buildContentCreatePayload,
34
34
  buildContentUpdatePayload,
35
- type ContentDraftFields,
36
35
  canCreateContentDraft,
37
36
  foundryDispatchSourceForDraft,
38
37
  initialContentMetadata,
@@ -356,7 +355,7 @@ export function ContentForm({
356
355
  // -- Autosave logic -------------------------------------------------------
357
356
 
358
357
  const queueFoundryProcessing = useCallback(
359
- async (id: string, draft: ContentDraftFields) => {
358
+ async (id: string, draft: FormState) => {
360
359
  const source = foundryDispatchSourceForDraft(contentType, draft)
361
360
  if (!source) return
362
361
  const durationSeconds = normalizeMediaDurationSeconds(draft.durationSeconds)
@@ -369,7 +368,7 @@ export function ContentForm({
369
368
  }))
370
369
  return
371
370
  }
372
- const queueKey = `${contentType}:${id}:${source}:${durationSeconds}`
371
+ const queueKey = `${contentType}:${id}:${source}:${durationSeconds}:${draft.slug}:${draft.premium}`
373
372
  if (queuedVideoSources.current.has(queueKey)) return
374
373
 
375
374
  setForm((prev) => ({
@@ -378,7 +377,7 @@ export function ContentForm({
378
377
  videoPipelineError: null,
379
378
  }))
380
379
  try {
381
- const res = await fetch(`/admin/api/content/${id}?action=dispatch-to-foundry`, {
380
+ const res = await fetch(`/admin/api/content/${id}?action=dispatch-to-foundry&capture=1`, {
382
381
  method: 'POST',
383
382
  })
384
383
  if (!res.ok) {
@@ -407,7 +406,7 @@ export function ContentForm({
407
406
  const persist = useCallback(
408
407
  async (currentForm: FormState) => {
409
408
  const id = currentForm.savedId
410
- const draft: ContentDraftFields = currentForm
409
+ const draft = currentForm
411
410
 
412
411
  if (id === null) {
413
412
  if (