@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.
- package/dist/engine/ai-dek-notifications.d.ts +2 -0
- package/dist/engine/ai-dek-notifications.d.ts.map +1 -1
- package/dist/engine/ai-dek-notifications.js +11 -2
- package/dist/engine/ai-dek-notifications.js.map +1 -1
- package/dist/engine/ai-dek.d.ts +23 -0
- package/dist/engine/ai-dek.d.ts.map +1 -1
- package/dist/engine/ai-dek.js +94 -31
- package/dist/engine/ai-dek.js.map +1 -1
- package/dist/engine/foundry-dispatch.d.ts +13 -3
- package/dist/engine/foundry-dispatch.d.ts.map +1 -1
- package/dist/engine/foundry-dispatch.js +115 -39
- package/dist/engine/foundry-dispatch.js.map +1 -1
- package/dist/engine/fronts-publish-intent.d.ts +55 -0
- package/dist/engine/fronts-publish-intent.d.ts.map +1 -1
- package/dist/engine/fronts-publish-intent.js +187 -57
- package/dist/engine/fronts-publish-intent.js.map +1 -1
- package/dist/engine/fronts-publish.d.ts +11 -0
- package/dist/engine/fronts-publish.d.ts.map +1 -1
- package/dist/engine/fronts-publish.js +135 -10
- package/dist/engine/fronts-publish.js.map +1 -1
- package/dist/engine/index.d.ts +2 -2
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +2 -2
- package/dist/engine/index.js.map +1 -1
- package/dist/engine/podcast-source.d.ts +3 -2
- package/dist/engine/podcast-source.d.ts.map +1 -1
- package/dist/engine/podcast-source.js +6 -4
- package/dist/engine/podcast-source.js.map +1 -1
- package/dist/engine/publication-current-guard.d.ts +7 -0
- package/dist/engine/publication-current-guard.d.ts.map +1 -1
- package/dist/engine/publication-current-guard.js +66 -0
- package/dist/engine/publication-current-guard.js.map +1 -1
- package/dist/engine/publication.d.ts +30 -0
- package/dist/engine/publication.d.ts.map +1 -1
- package/dist/engine/publication.js +306 -34
- package/dist/engine/publication.js.map +1 -1
- package/dist/engine/publisher.d.ts.map +1 -1
- package/dist/engine/publisher.js +32 -19
- package/dist/engine/publisher.js.map +1 -1
- package/dist/engine/soft-delete.d.ts +3 -1
- package/dist/engine/soft-delete.d.ts.map +1 -1
- package/dist/engine/soft-delete.js +21 -5
- package/dist/engine/soft-delete.js.map +1 -1
- package/dist/engine/video-source.d.ts +4 -2
- package/dist/engine/video-source.d.ts.map +1 -1
- package/dist/engine/video-source.js +7 -4
- package/dist/engine/video-source.js.map +1 -1
- package/dist/routes/content.d.ts +14 -0
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +56 -28
- package/dist/routes/content.js.map +1 -1
- package/dist/routes/context.d.ts +6 -0
- package/dist/routes/context.d.ts.map +1 -1
- package/dist/routes/context.js.map +1 -1
- package/dist/routes/fronts-publish.d.ts +2 -0
- package/dist/routes/fronts-publish.d.ts.map +1 -1
- package/dist/routes/fronts-publish.js +178 -2
- package/dist/routes/fronts-publish.js.map +1 -1
- package/dist/schema/insights-ingest.d.ts +42 -42
- package/dist/schema/migrations.d.ts.map +1 -1
- package/dist/schema/migrations.js +8 -0
- package/dist/schema/migrations.js.map +1 -1
- package/dist/schema/types.d.ts +6 -0
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema/types.js.map +1 -1
- package/dist/surveys/schema.d.ts +48 -48
- package/dist/ui/api/_content-config.d.ts.map +1 -1
- package/dist/ui/api/_content-config.js +7 -0
- package/dist/ui/api/_content-config.js.map +1 -1
- package/dist/ui/api/fronts/schedule.d.ts +2 -0
- package/dist/ui/api/fronts/schedule.d.ts.map +1 -1
- package/dist/ui/api/fronts/schedule.js +2 -0
- package/dist/ui/api/fronts/schedule.js.map +1 -1
- package/dist/ui/editor/ContentForm.d.ts.map +1 -1
- package/dist/ui/editor/ContentForm.js +2 -2
- package/dist/ui/editor/ContentForm.js.map +1 -1
- package/migrations/0030_media_publication_guard.sql +5 -0
- package/package.json +1 -1
- package/src/engine/ai-dek-notifications.ts +19 -2
- package/src/engine/ai-dek.ts +131 -40
- package/src/engine/foundry-dispatch.ts +189 -49
- package/src/engine/fronts-publish-intent.ts +295 -67
- package/src/engine/fronts-publish.ts +167 -11
- package/src/engine/index.ts +4 -0
- package/src/engine/podcast-source.ts +6 -4
- package/src/engine/publication-current-guard.ts +86 -0
- package/src/engine/publication.ts +411 -59
- package/src/engine/publisher.ts +37 -17
- package/src/engine/soft-delete.ts +28 -8
- package/src/engine/video-source.ts +7 -4
- package/src/routes/content.ts +78 -35
- package/src/routes/context.ts +6 -0
- package/src/routes/fronts-publish.ts +213 -2
- package/src/schema/migrations.ts +8 -0
- package/src/schema/types.ts +6 -0
- package/src/ui/api/_content-config.ts +15 -0
- package/src/ui/api/fronts/schedule.ts +4 -0
- 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
|
-
//
|
|
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 {
|
|
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,
|
package/src/schema/migrations.ts
CHANGED
|
@@ -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
|
/**
|
package/src/schema/types.ts
CHANGED
|
@@ -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:
|
|
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
|
|
409
|
+
const draft = currentForm
|
|
411
410
|
|
|
412
411
|
if (id === null) {
|
|
413
412
|
if (
|