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