@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
@@ -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. */
@@ -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
  /**
@@ -44,7 +53,12 @@ import { json, type RouteContext } from './context.js'
44
53
  * (test injection / per-Worker secret) or a Secrets Store binding `{ get() }`.
45
54
  * Returns null → the route answers 500 "not configured".
46
55
  */
47
- async function resolveFoundryHmacSecret(
56
+ // Exported (W1-07 PR: content/insights-inputs) so a sibling M2M route can
57
+ // reuse the SAME timestamp+signature verification without duplicating it --
58
+ // content-insights.ts's insightsInputs handler is a GET poll exactly like
59
+ // `schedule` below, and belongs on the same FOUNDRY_HMAC_SECRET/verification
60
+ // family rather than inventing a second one.
61
+ export async function resolveFoundryHmacSecret(
48
62
  env: Record<string, unknown> | undefined,
49
63
  ): Promise<string | null> {
50
64
  const raw = env?.FOUNDRY_HMAC_SECRET
@@ -78,7 +92,7 @@ function parseTimestampSeconds(raw: string): number | null {
78
92
  * = HMAC over `${timestamp}\n${payload}`. Returns null when authentic, or a 401
79
93
  * Response (with a loud warn log) to short-circuit the handler.
80
94
  */
81
- async function verifyFoundrySignedRequest(
95
+ export async function verifyFoundrySignedRequest(
82
96
  request: Request,
83
97
  secret: string,
84
98
  payload: string,
@@ -125,9 +139,92 @@ const CallbackSchema = z
125
139
  })
126
140
  .strip()
127
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
+
128
223
  export interface FrontsPublishRouteHandlers {
129
224
  /** GET /fronts/schedule?window=<from>..<to> — items due for foundryd to publish. */
130
225
  schedule(ctx: RouteContext): Promise<Response>
226
+ /** POST /fronts/schedule — bounded target arming or host-owned maintenance. */
227
+ maintainSchedule(ctx: RouteContext): Promise<Response>
131
228
  /** POST /fronts/publish-callback — foundryd's outbound lifecycle callback. */
132
229
  publishCallback(ctx: RouteContext): Promise<Response>
133
230
  }
@@ -159,6 +256,90 @@ function parseWindow(
159
256
  export function createFrontsPublishRoutes(config: ContentRouteConfig): FrontsPublishRouteHandlers {
160
257
  const resolved = resolveConfig(config)
161
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
+ },
162
343
  async schedule(ctx) {
163
344
  const hmacSecret = await resolveFoundryHmacSecret(ctx.env)
164
345
  if (!hmacSecret) return json({ error: 'FOUNDRY_HMAC_SECRET is not configured' }, 500)
@@ -205,6 +386,10 @@ export function createFrontsPublishRoutes(config: ContentRouteConfig): FrontsPub
205
386
  // origin is deliberately deferred (packages-docs/cms.md).
206
387
  resolveContentUrl: (row) => publicUrlFor(row),
207
388
  })
389
+ const preparations = await hydrateFrontsMediaPreparations(ctx.db, {
390
+ podcastSourceProductionEnabled: config.podcastSourceProductionEnabled,
391
+ videoSourceProductionEnabled: config.videoSourceProductionEnabled,
392
+ })
208
393
  return json({
209
394
  ok: true,
210
395
  now,
@@ -213,6 +398,7 @@ export function createFrontsPublishRoutes(config: ContentRouteConfig): FrontsPub
213
398
  items,
214
399
  intents: hydrated.intents,
215
400
  incomplete: hydrated.incomplete,
401
+ preparations,
216
402
  })
217
403
  } catch (error) {
218
404
  if (error instanceof FrontsScheduleOverflowError) {
@@ -274,6 +460,7 @@ export function createFrontsPublishRoutes(config: ContentRouteConfig): FrontsPub
274
460
  occurredAt: parsed.data.occurred_at ?? null,
275
461
  receipt: parsed.data.receipt ?? null,
276
462
  error: parsed.data.error ?? null,
463
+ llm: resolved.hooks.llm,
277
464
  checkReadiness: config.publishReadiness
278
465
  ? (item) =>
279
466
  config.publishReadiness!({
@@ -318,6 +505,35 @@ export function createFrontsPublishRoutes(config: ContentRouteConfig): FrontsPub
318
505
  500,
319
506
  )
320
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
+ }
321
537
 
322
538
  return json({
323
539
  ok: true,
@@ -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,
@@ -0,0 +1,26 @@
1
+ // src/ui/api/content/insights-inputs.ts
2
+ // GET /admin/api/content/insights-inputs?site=<id> — the funnel's author
3
+ // denylist + existing-content coverage index. Machine-to-machine poll (same
4
+ // X-Foundry-Timestamp + X-Foundry-Signature family as fronts/schedule) for
5
+ // foundryd's sitebrain.insights.run schedule (W1-07 PR 6). NOT behind
6
+ // requirePublisher. Sibling of insights-ingest under /content/.
7
+ import type { APIContext } from 'astro'
8
+ import { createContentInsightsRoutes } from '../../../routes/content-insights.js'
9
+ import type { RouteContext } from '../../../routes/context.js'
10
+ import { buildSiteAuthz } from '../_authz.js'
11
+
12
+ function toCtx(c: APIContext): RouteContext {
13
+ const env = (c.locals as { cmsEnv?: Record<string, unknown> }).cmsEnv ?? {}
14
+ return { request: c.request, params: {}, db: env.SITE_DB as RouteContext['db'], env }
15
+ }
16
+
17
+ function siteId(c: APIContext): string {
18
+ const env = (c.locals as { cmsEnv?: Record<string, unknown> }).cmsEnv ?? {}
19
+ return (env.ACTIVE_WORKSPACE_ID as string | undefined) ?? 'default'
20
+ }
21
+
22
+ export const GET = (c: APIContext) =>
23
+ createContentInsightsRoutes({
24
+ authz: buildSiteAuthz(c),
25
+ activeWorkspaceId: siteId(c),
26
+ }).insightsInputs(toCtx(c))
@@ -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 (