@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
package/src/routes/context.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
/**
|
|
@@ -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
|
-
|
|
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:
|
|
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 (
|