@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
package/src/engine/publisher.ts
CHANGED
|
@@ -22,7 +22,6 @@
|
|
|
22
22
|
import type { ContentStatus, ContentType, FrontsPublishStatus } from '../schema/types.js'
|
|
23
23
|
import { type ContentMetadata, serializeContentMetadata } from './content-metadata.js'
|
|
24
24
|
import type { D1Database } from './d1.js'
|
|
25
|
-
import { armFrontsPublishTriggerToken } from './fronts-publish-intent.js'
|
|
26
25
|
import { evaluateArticleBody, type PublishGuardOutcome } from './publish-guard.js'
|
|
27
26
|
import { slugify } from './slug.js'
|
|
28
27
|
import {
|
|
@@ -793,7 +792,10 @@ export async function updateContentItem(
|
|
|
793
792
|
// Resolve all media identity/duration invariants before the base content row,
|
|
794
793
|
// tags, or relations can be mutated. D1 does not provide a transaction on the
|
|
795
794
|
// structural surface used here, so validation must be a strict preflight.
|
|
796
|
-
if (
|
|
795
|
+
if (
|
|
796
|
+
(input.content || input.slug !== undefined || input.visibility !== undefined) &&
|
|
797
|
+
existing.type === 'video'
|
|
798
|
+
) {
|
|
797
799
|
const current = await db
|
|
798
800
|
.prepare(
|
|
799
801
|
`SELECT script, video_id, duration_seconds, thumbnail_image_id,
|
|
@@ -813,7 +815,7 @@ export async function updateContentItem(
|
|
|
813
815
|
}>()
|
|
814
816
|
|
|
815
817
|
if (current) {
|
|
816
|
-
const videoContent = input.content as Partial<VideoInput['content']>
|
|
818
|
+
const videoContent = (input.content ?? {}) as Partial<VideoInput['content']>
|
|
817
819
|
const sourceUrl = hasOwn(videoContent, 'processingSourceUrl')
|
|
818
820
|
? (videoContent.processingSourceUrl ?? null)
|
|
819
821
|
: current.processing_source_url
|
|
@@ -851,14 +853,19 @@ export async function updateContentItem(
|
|
|
851
853
|
: current.thumbnail_image_id,
|
|
852
854
|
sourceUrl,
|
|
853
855
|
sourceKind,
|
|
854
|
-
sourceChanged
|
|
856
|
+
sourceChanged:
|
|
857
|
+
sourceChanged ||
|
|
858
|
+
resolvedDuration !== current.duration_seconds ||
|
|
859
|
+
(input.slug !== undefined && input.slug !== existing.slug) ||
|
|
860
|
+
(input.visibility !== undefined && input.visibility !== existing.visibility) ||
|
|
861
|
+
(hasOwn(videoContent, 'videoId') && videoContent.videoId !== current.video_id),
|
|
855
862
|
}
|
|
856
863
|
} else {
|
|
857
864
|
videoUpdatePlan = null
|
|
858
865
|
}
|
|
859
866
|
}
|
|
860
867
|
|
|
861
|
-
if (input.content && existing.type === 'podcast') {
|
|
868
|
+
if ((input.content || input.slug !== undefined) && existing.type === 'podcast') {
|
|
862
869
|
const current = await db
|
|
863
870
|
.prepare(
|
|
864
871
|
`SELECT transcript, audio_r2_key, duration_seconds, processing_trigger_token,
|
|
@@ -878,7 +885,7 @@ export async function updateContentItem(
|
|
|
878
885
|
}>()
|
|
879
886
|
|
|
880
887
|
if (current) {
|
|
881
|
-
const podcastContent = input.content as Partial<PodcastInput['content']>
|
|
888
|
+
const podcastContent = (input.content ?? {}) as Partial<PodcastInput['content']>
|
|
882
889
|
const audioR2Key = hasOwn(podcastContent, 'audioR2Key')
|
|
883
890
|
? (podcastContent.audioR2Key ?? current.audio_r2_key)
|
|
884
891
|
: current.audio_r2_key
|
|
@@ -910,9 +917,13 @@ export async function updateContentItem(
|
|
|
910
917
|
)
|
|
911
918
|
const replacementTranscriptProvided =
|
|
912
919
|
hasOwn(podcastContent, 'transcript') && typeof podcastContent.transcript === 'string'
|
|
920
|
+
const preparationChanged =
|
|
921
|
+
sourceChanged ||
|
|
922
|
+
resolvedDuration !== current.duration_seconds ||
|
|
923
|
+
(input.slug !== undefined && input.slug !== existing.slug)
|
|
913
924
|
|
|
914
925
|
podcastUpdatePlan = {
|
|
915
|
-
transcript:
|
|
926
|
+
transcript: preparationChanged
|
|
916
927
|
? replacementTranscriptProvided
|
|
917
928
|
? (podcastContent.transcript as string)
|
|
918
929
|
: ''
|
|
@@ -924,7 +935,7 @@ export async function updateContentItem(
|
|
|
924
935
|
durationProvided || sourceChanged
|
|
925
936
|
? resolvedDuration
|
|
926
937
|
: (resolvedDuration ?? current.duration_seconds),
|
|
927
|
-
processingTriggerToken:
|
|
938
|
+
processingTriggerToken: preparationChanged ? null : current.processing_trigger_token,
|
|
928
939
|
sourceUrl,
|
|
929
940
|
sourceKind,
|
|
930
941
|
}
|
|
@@ -1196,7 +1207,7 @@ export async function updateContentItem(
|
|
|
1196
1207
|
}
|
|
1197
1208
|
if (!options.expectedMediaSource) await updateBaseContent()
|
|
1198
1209
|
|
|
1199
|
-
if (input.content) {
|
|
1210
|
+
if (input.content || videoUpdatePlan || podcastUpdatePlan) {
|
|
1200
1211
|
if (isBodyBackedContentType(existing.type)) {
|
|
1201
1212
|
const current = await db
|
|
1202
1213
|
.prepare(
|
|
@@ -1307,6 +1318,7 @@ export async function updateContentItem(
|
|
|
1307
1318
|
processing_state = CASE WHEN ? THEN 'pending' ELSE processing_state END,
|
|
1308
1319
|
processing_correlation_id = CASE WHEN ? THEN NULL ELSE processing_correlation_id END,
|
|
1309
1320
|
processing_trigger_token = CASE WHEN ? THEN NULL ELSE processing_trigger_token END,
|
|
1321
|
+
processing_publication_guard = CASE WHEN ? THEN NULL ELSE processing_publication_guard END,
|
|
1310
1322
|
processing_started_at = CASE WHEN ? THEN NULL ELSE processing_started_at END,
|
|
1311
1323
|
processing_completed_at = CASE WHEN ? THEN NULL ELSE processing_completed_at END,
|
|
1312
1324
|
processing_last_event_at = CASE WHEN ? THEN NULL ELSE processing_last_event_at END,
|
|
@@ -1341,6 +1353,7 @@ export async function updateContentItem(
|
|
|
1341
1353
|
videoUpdatePlan.sourceChanged ? 1 : 0,
|
|
1342
1354
|
videoUpdatePlan.sourceChanged ? 1 : 0,
|
|
1343
1355
|
videoUpdatePlan.sourceChanged ? 1 : 0,
|
|
1356
|
+
videoUpdatePlan.sourceChanged ? 1 : 0,
|
|
1344
1357
|
id,
|
|
1345
1358
|
options.expectedMediaSource ? 1 : 0,
|
|
1346
1359
|
options.expectedMediaSource?.sourceUrl ?? null,
|
|
@@ -1362,6 +1375,8 @@ export async function updateContentItem(
|
|
|
1362
1375
|
audio_r2_key = ?,
|
|
1363
1376
|
duration_seconds = ?,
|
|
1364
1377
|
processing_trigger_token = ?,
|
|
1378
|
+
processing_publication_guard = CASE WHEN ? IS processing_trigger_token THEN processing_publication_guard ELSE NULL END,
|
|
1379
|
+
processing_state = CASE WHEN ? IS processing_trigger_token THEN processing_state ELSE NULL END,
|
|
1365
1380
|
processing_source_url = ?,
|
|
1366
1381
|
processing_source_kind = ?
|
|
1367
1382
|
WHERE content_id = ?
|
|
@@ -1373,6 +1388,8 @@ export async function updateContentItem(
|
|
|
1373
1388
|
podcastUpdatePlan.audioR2Key,
|
|
1374
1389
|
podcastUpdatePlan.durationSeconds,
|
|
1375
1390
|
podcastUpdatePlan.processingTriggerToken,
|
|
1391
|
+
podcastUpdatePlan.processingTriggerToken,
|
|
1392
|
+
podcastUpdatePlan.processingTriggerToken,
|
|
1376
1393
|
podcastUpdatePlan.sourceUrl,
|
|
1377
1394
|
podcastUpdatePlan.sourceKind,
|
|
1378
1395
|
id,
|
|
@@ -1399,16 +1416,19 @@ export async function scheduleContent(
|
|
|
1399
1416
|
): Promise<void> {
|
|
1400
1417
|
await db
|
|
1401
1418
|
.prepare(
|
|
1402
|
-
`UPDATE content_items SET status = 'scheduled', publish_at = ?, publish_tz = ?,
|
|
1419
|
+
`UPDATE content_items SET status = 'scheduled', publish_at = ?, publish_tz = ?,
|
|
1420
|
+
canonical_version = canonical_version + CASE WHEN status = 'published' OR fronts_publish_status = 'live' THEN 1 ELSE 0 END,
|
|
1421
|
+
fronts_publish_status = CASE WHEN status = 'published' OR fronts_publish_status = 'live'
|
|
1422
|
+
THEN 'queued' ELSE fronts_publish_status END,
|
|
1423
|
+
fronts_publish_trigger_token = CASE WHEN status = 'published' OR fronts_publish_status = 'live'
|
|
1424
|
+
OR fronts_publish_trigger_token IS NULL THEN ? ELSE fronts_publish_trigger_token END,
|
|
1425
|
+
updated_at = unixepoch() WHERE id = ?`,
|
|
1403
1426
|
)
|
|
1404
|
-
.bind(publishAt, publishTz, id)
|
|
1427
|
+
.bind(publishAt, publishTz, crypto.randomUUID(), id)
|
|
1405
1428
|
.run()
|
|
1406
|
-
//
|
|
1407
|
-
//
|
|
1408
|
-
//
|
|
1409
|
-
// `trigger_token_hash` is stable across reschedules and across every poll.
|
|
1410
|
-
// The raw token stays in D1; only `sha256:<hex>` is ever exposed.
|
|
1411
|
-
await armFrontsPublishTriggerToken(db, id)
|
|
1429
|
+
// Slot, terminal-ledger reset and token are one atomic transition. Moving an
|
|
1430
|
+
// already scheduled occurrence preserves its identity; scheduling a completed
|
|
1431
|
+
// publication creates a fresh occurrence without recapturing its media.
|
|
1412
1432
|
}
|
|
1413
1433
|
|
|
1414
1434
|
export async function evaluateContentBodyForPublish(
|
|
@@ -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/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. */
|
|
@@ -369,6 +389,24 @@ const ContentMetadataSchema = z
|
|
|
369
389
|
})
|
|
370
390
|
.transform((metadata) => metadata as ContentMetadata)
|
|
371
391
|
|
|
392
|
+
// Known Nextcloud share pages are HTML, never ingestable media. The editor
|
|
393
|
+
// must save the direct-download URL so the existing provider validates bytes.
|
|
394
|
+
const MediaSourceUrlSchema = z
|
|
395
|
+
.string()
|
|
396
|
+
.url()
|
|
397
|
+
.refine(
|
|
398
|
+
(value) => {
|
|
399
|
+
let url: URL
|
|
400
|
+
try {
|
|
401
|
+
url = new URL(value)
|
|
402
|
+
} catch {
|
|
403
|
+
return false
|
|
404
|
+
}
|
|
405
|
+
return !(url.hostname === 'drive.fulcrum-portal.com' && /^\/s\/[^/]+\/?$/.test(url.pathname))
|
|
406
|
+
},
|
|
407
|
+
{ message: 'Use the Nextcloud direct-download URL ending in /download, not its share page.' },
|
|
408
|
+
)
|
|
409
|
+
|
|
372
410
|
function buildSchemas(resolved: ReturnType<typeof resolveConfig>) {
|
|
373
411
|
const category = slugEnum(resolved.primaryCategorySlugs)
|
|
374
412
|
const topic = slugEnum(resolved.primaryTopicSlugs)
|
|
@@ -410,7 +448,7 @@ function buildSchemas(resolved: ReturnType<typeof resolveConfig>) {
|
|
|
410
448
|
videoId: z.string().min(1),
|
|
411
449
|
durationSeconds: z.number().int().positive().optional().nullable(),
|
|
412
450
|
thumbnailImageId: z.string().optional().nullable(),
|
|
413
|
-
processingSourceUrl:
|
|
451
|
+
processingSourceUrl: MediaSourceUrlSchema.optional().nullable(),
|
|
414
452
|
processingSourceKind: z.string().min(1).optional().nullable(),
|
|
415
453
|
}),
|
|
416
454
|
}),
|
|
@@ -430,7 +468,7 @@ function buildSchemas(resolved: ReturnType<typeof resolveConfig>) {
|
|
|
430
468
|
// there because a video always has a slug-derived identity up front.
|
|
431
469
|
audioR2Key: z.string().default(''),
|
|
432
470
|
durationSeconds: z.number().int().positive().optional().nullable(),
|
|
433
|
-
processingSourceUrl:
|
|
471
|
+
processingSourceUrl: MediaSourceUrlSchema.optional().nullable(),
|
|
434
472
|
processingSourceKind: z.string().min(1).optional().nullable(),
|
|
435
473
|
}),
|
|
436
474
|
}),
|
|
@@ -495,7 +533,7 @@ const VideoContentUpdateSchema = z.object({
|
|
|
495
533
|
videoId: z.string().optional(),
|
|
496
534
|
durationSeconds: z.number().int().positive().optional().nullable(),
|
|
497
535
|
thumbnailImageId: z.string().optional().nullable(),
|
|
498
|
-
processingSourceUrl:
|
|
536
|
+
processingSourceUrl: MediaSourceUrlSchema.optional().nullable(),
|
|
499
537
|
processingSourceKind: z.string().min(1).optional().nullable(),
|
|
500
538
|
})
|
|
501
539
|
|
|
@@ -503,7 +541,7 @@ const PodcastContentUpdateSchema = z.object({
|
|
|
503
541
|
transcript: z.string().optional(),
|
|
504
542
|
audioR2Key: z.string().optional(),
|
|
505
543
|
durationSeconds: z.number().int().positive().optional().nullable(),
|
|
506
|
-
processingSourceUrl:
|
|
544
|
+
processingSourceUrl: MediaSourceUrlSchema.optional().nullable(),
|
|
507
545
|
processingSourceKind: z.string().min(1).optional().nullable(),
|
|
508
546
|
})
|
|
509
547
|
|
|
@@ -836,6 +874,17 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
836
874
|
const dispatchWebhook = config.dispatchWebhook
|
|
837
875
|
const publishReadiness = config.publishReadiness
|
|
838
876
|
const activeWorkspaceId = config.activeWorkspaceId ?? 'default'
|
|
877
|
+
const targetDefaults =
|
|
878
|
+
config.frontsDefaultChannelTargets === undefined
|
|
879
|
+
? null
|
|
880
|
+
: parseFrontsDefaultChannelTargets(config.frontsDefaultChannelTargets)
|
|
881
|
+
const defaultTargetsError = () =>
|
|
882
|
+
targetDefaults && 'error' in targetDefaults ? json({ error: targetDefaults.error }, 500) : null
|
|
883
|
+
async function armDefaultTargets(ctx: RouteContext, contentId: string): Promise<void> {
|
|
884
|
+
if (targetDefaults && 'targets' in targetDefaults) {
|
|
885
|
+
await setFrontsChannelTargets(ctx.db, contentId, targetDefaults.targets, { onlyIfNull: true })
|
|
886
|
+
}
|
|
887
|
+
}
|
|
839
888
|
|
|
840
889
|
async function loadStoredMediaSource(
|
|
841
890
|
db: D1Database,
|
|
@@ -1305,6 +1354,8 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1305
1354
|
}
|
|
1306
1355
|
|
|
1307
1356
|
if (op === 'schedule') {
|
|
1357
|
+
const configError = defaultTargetsError()
|
|
1358
|
+
if (configError) return configError
|
|
1308
1359
|
const { publishAt } = parsed.data
|
|
1309
1360
|
const publishAtMs = Date.parse(publishAt)
|
|
1310
1361
|
if (!Number.isFinite(publishAtMs)) {
|
|
@@ -1313,6 +1364,7 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1313
1364
|
const publishAtSeconds = Math.floor(publishAtMs / 1000)
|
|
1314
1365
|
for (const id of ids) {
|
|
1315
1366
|
await scheduleContent(ctx.db, id, publishAtSeconds)
|
|
1367
|
+
await armDefaultTargets(ctx, id)
|
|
1316
1368
|
}
|
|
1317
1369
|
return json({ scheduled: ids })
|
|
1318
1370
|
}
|
|
@@ -1329,6 +1381,8 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1329
1381
|
const ownAuthor = actor?.subject ?? ctx.userId ?? null
|
|
1330
1382
|
const denied = await requireContentAction(ctx, 'write', ownAuthor)
|
|
1331
1383
|
if (denied) return denied
|
|
1384
|
+
const configError = defaultTargetsError()
|
|
1385
|
+
if (configError) return configError
|
|
1332
1386
|
|
|
1333
1387
|
const parsed = CreateSchema.safeParse(await readJson(ctx))
|
|
1334
1388
|
if (!parsed.success) {
|
|
@@ -1360,6 +1414,7 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1360
1414
|
if (conflict) return conflict
|
|
1361
1415
|
throw error
|
|
1362
1416
|
}
|
|
1417
|
+
await armDefaultTargets(ctx, created.id)
|
|
1363
1418
|
const podcastAttempt: PodcastFoundryAttempt =
|
|
1364
1419
|
parsed.data.type === 'podcast'
|
|
1365
1420
|
? await dispatchPodcastAfterSave(ctx, created.id)
|
|
@@ -1484,6 +1539,8 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1484
1539
|
if (config.updateStatusConstraint && existing.status !== config.updateStatusConstraint) {
|
|
1485
1540
|
return json({ error: 'update_status_conflict', state: existing.status }, 409)
|
|
1486
1541
|
}
|
|
1542
|
+
const configError = defaultTargetsError()
|
|
1543
|
+
if (configError) return configError
|
|
1487
1544
|
|
|
1488
1545
|
const parsed = UpdateSchema.safeParse(await readJson(ctx))
|
|
1489
1546
|
if (!parsed.success) {
|
|
@@ -1607,6 +1664,7 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1607
1664
|
if (updated.mediaSourceConflict) {
|
|
1608
1665
|
return json({ error: 'media_source_conflict' }, 409)
|
|
1609
1666
|
}
|
|
1667
|
+
await armDefaultTargets(ctx, id)
|
|
1610
1668
|
const podcastAttempt: PodcastFoundryAttempt =
|
|
1611
1669
|
existing.type === 'podcast'
|
|
1612
1670
|
? await dispatchPodcastAfterSave(ctx, id)
|
|
@@ -1649,6 +1707,8 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1649
1707
|
if (denied) return denied
|
|
1650
1708
|
|
|
1651
1709
|
if (action === 'schedule') {
|
|
1710
|
+
const configError = defaultTargetsError()
|
|
1711
|
+
if (configError) return configError
|
|
1652
1712
|
const parsed = ScheduleSchema.safeParse(await readJson(ctx))
|
|
1653
1713
|
if (!parsed.success) {
|
|
1654
1714
|
return json({ error: 'Invalid request', details: parsed.error.flatten() }, 400)
|
|
@@ -1680,6 +1740,7 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1680
1740
|
}
|
|
1681
1741
|
|
|
1682
1742
|
await scheduleContent(ctx.db, id, publishAtSeconds as number, publishTimeZone)
|
|
1743
|
+
await armDefaultTargets(ctx, id)
|
|
1683
1744
|
|
|
1684
1745
|
// Fire content.scheduled webhook.
|
|
1685
1746
|
await fireWebhook(ctx, 'content.scheduled', {
|
|
@@ -1895,12 +1956,14 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1895
1956
|
}
|
|
1896
1957
|
|
|
1897
1958
|
if (action === 'archive') {
|
|
1898
|
-
await ctx.db
|
|
1899
|
-
.
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1959
|
+
await ctx.db.batch([
|
|
1960
|
+
...invalidateMediaCaptureStatements(ctx.db, id),
|
|
1961
|
+
ctx.db
|
|
1962
|
+
.prepare(
|
|
1963
|
+
"UPDATE content_items SET status = 'archived', updated_at = unixepoch(), canonical_version = canonical_version + 1 WHERE id = ? AND status <> 'archived'",
|
|
1964
|
+
)
|
|
1965
|
+
.bind(id),
|
|
1966
|
+
])
|
|
1904
1967
|
|
|
1905
1968
|
// Fire content.deleted (archive = soft delete lifecycle) webhook.
|
|
1906
1969
|
await fireWebhook(ctx, 'content.deleted', {
|
|
@@ -2005,36 +2068,16 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
2005
2068
|
if (!resolved.hooks.foundryVideo) {
|
|
2006
2069
|
return json({ error: 'Foundry video dispatch is not configured' }, 501)
|
|
2007
2070
|
}
|
|
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
2071
|
try {
|
|
2032
|
-
const result = await dispatchToFoundry(ctx.db, id, resolved.hooks.foundryVideo
|
|
2072
|
+
const result = await dispatchToFoundry(ctx.db, id, resolved.hooks.foundryVideo, {
|
|
2073
|
+
ifUncaptured: url.searchParams.get('capture') === '1',
|
|
2074
|
+
})
|
|
2033
2075
|
return json(
|
|
2034
2076
|
{
|
|
2035
2077
|
status: 'queued',
|
|
2036
2078
|
correlationId: result.correlationId,
|
|
2037
2079
|
triggerToken: result.triggerToken,
|
|
2080
|
+
...(result.skipped ? { skipped: true } : {}),
|
|
2038
2081
|
},
|
|
2039
2082
|
202,
|
|
2040
2083
|
)
|
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. */
|