@growth-labs/cms 0.8.8 → 0.8.15
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/foundry-dispatch.d.ts.map +1 -1
- package/dist/engine/foundry-dispatch.js +13 -4
- package/dist/engine/foundry-dispatch.js.map +1 -1
- package/dist/engine/fronts-publish-intent.d.ts +20 -3
- package/dist/engine/fronts-publish-intent.d.ts.map +1 -1
- package/dist/engine/fronts-publish-intent.js +25 -11
- package/dist/engine/fronts-publish-intent.js.map +1 -1
- package/dist/engine/fronts-publish.d.ts.map +1 -1
- package/dist/engine/fronts-publish.js +44 -8
- package/dist/engine/fronts-publish.js.map +1 -1
- package/dist/engine/google-drive-source.d.ts +27 -0
- package/dist/engine/google-drive-source.d.ts.map +1 -0
- package/dist/engine/google-drive-source.js +87 -0
- package/dist/engine/google-drive-source.js.map +1 -0
- package/dist/engine/index.d.ts +1 -0
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +1 -0
- package/dist/engine/index.js.map +1 -1
- package/dist/engine/publisher.d.ts +3 -1
- package/dist/engine/publisher.d.ts.map +1 -1
- package/dist/engine/publisher.js +21 -11
- package/dist/engine/publisher.js.map +1 -1
- package/dist/migration-vendor.d.ts.map +1 -1
- package/dist/migration-vendor.js +140 -12
- package/dist/migration-vendor.js.map +1 -1
- package/dist/providers/types.d.ts +6 -1
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/providers/types.js.map +1 -1
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +191 -8
- package/dist/routes/content.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/dist/ui/editor/editor-media-upload.d.ts.map +1 -1
- package/dist/ui/editor/editor-media-upload.js +2 -1
- package/dist/ui/editor/editor-media-upload.js.map +1 -1
- package/dist/ui/inspector/PublishTab.d.ts.map +1 -1
- package/dist/ui/inspector/PublishTab.js +2 -1
- package/dist/ui/inspector/PublishTab.js.map +1 -1
- package/dist/ui/screens/LibraryScreen.d.ts.map +1 -1
- package/dist/ui/screens/LibraryScreen.js +4 -3
- package/dist/ui/screens/LibraryScreen.js.map +1 -1
- package/dist/ui/screens/SettingsScreen.d.ts.map +1 -1
- package/dist/ui/screens/SettingsScreen.js +14 -13
- package/dist/ui/screens/SettingsScreen.js.map +1 -1
- package/dist/ui/screens/TopicsScreen.d.ts.map +1 -1
- package/dist/ui/screens/TopicsScreen.js +3 -2
- package/dist/ui/screens/TopicsScreen.js.map +1 -1
- package/dist/ui/screens/author-recovery.d.ts.map +1 -1
- package/dist/ui/screens/author-recovery.js +2 -1
- package/dist/ui/screens/author-recovery.js.map +1 -1
- package/package.json +1 -1
- package/src/engine/foundry-dispatch.ts +13 -4
- package/src/engine/fronts-publish-intent.ts +50 -15
- package/src/engine/fronts-publish.ts +45 -6
- package/src/engine/google-drive-source.ts +99 -0
- package/src/engine/index.ts +1 -0
- package/src/engine/publisher.ts +24 -11
- package/src/migration-vendor.ts +156 -14
- package/src/providers/types.ts +6 -1
- package/src/routes/content.ts +209 -7
- package/src/ui/editor/ContentForm.tsx +5 -1
- package/src/ui/editor/editor-media-upload.ts +2 -1
- package/src/ui/inspector/PublishTab.tsx +2 -1
- package/src/ui/screens/LibraryScreen.tsx +4 -3
- package/src/ui/screens/SettingsScreen.tsx +14 -13
- package/src/ui/screens/TopicsScreen.tsx +3 -2
- package/src/ui/screens/author-recovery.ts +3 -1
package/src/routes/content.ts
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
parseFrontsDefaultChannelTargets,
|
|
39
39
|
setFrontsChannelTargets,
|
|
40
40
|
} from '../engine/fronts-publish-intent.js'
|
|
41
|
+
import { classifyGoogleDriveUrl } from '../engine/google-drive-source.js'
|
|
41
42
|
import { evaluateArticleBody } from '../engine/publish-guard.js'
|
|
42
43
|
import {
|
|
43
44
|
createContent,
|
|
@@ -433,7 +434,34 @@ export function normaliseNextcloudShareUrl(value: string): string {
|
|
|
433
434
|
return url.toString()
|
|
434
435
|
}
|
|
435
436
|
|
|
436
|
-
|
|
437
|
+
// A Google Drive link is not run through the Nextcloud rewrite; it is kept as
|
|
438
|
+
// the editor pasted it. The podcast schema accepts a valid Drive file link
|
|
439
|
+
// (the Go plane fetches and measures it itself) and turns any other
|
|
440
|
+
// drive.google.com / docs.google.com URL -- a Doc, a folder, a link with no
|
|
441
|
+
// file id -- into a field-level issue, because stored as a plain https URL it
|
|
442
|
+
// is an HTML page. The video schema refuses every Drive link: the video path
|
|
443
|
+
// ingests over plain https and would fetch the viewer page.
|
|
444
|
+
const PodcastSourceUrlSchema = z
|
|
445
|
+
.string()
|
|
446
|
+
.url()
|
|
447
|
+
.superRefine((value, ctx) => {
|
|
448
|
+
const link = classifyGoogleDriveUrl(value)
|
|
449
|
+
if (link.kind === 'drive_invalid') ctx.addIssue({ code: 'custom', message: link.reason })
|
|
450
|
+
})
|
|
451
|
+
.transform(normaliseNextcloudShareUrl)
|
|
452
|
+
|
|
453
|
+
const VIDEO_DRIVE_SOURCE_UNSUPPORTED =
|
|
454
|
+
'Google Drive links are not yet supported for video; share a direct download link'
|
|
455
|
+
|
|
456
|
+
const VideoSourceUrlSchema = z
|
|
457
|
+
.string()
|
|
458
|
+
.url()
|
|
459
|
+
.superRefine((value, ctx) => {
|
|
460
|
+
if (classifyGoogleDriveUrl(value).kind !== 'not_drive') {
|
|
461
|
+
ctx.addIssue({ code: 'custom', message: VIDEO_DRIVE_SOURCE_UNSUPPORTED })
|
|
462
|
+
}
|
|
463
|
+
})
|
|
464
|
+
.transform(normaliseNextcloudShareUrl)
|
|
437
465
|
|
|
438
466
|
function buildSchemas(resolved: ReturnType<typeof resolveConfig>) {
|
|
439
467
|
const category = slugEnum(resolved.primaryCategorySlugs)
|
|
@@ -476,7 +504,7 @@ function buildSchemas(resolved: ReturnType<typeof resolveConfig>) {
|
|
|
476
504
|
videoId: z.string().min(1),
|
|
477
505
|
durationSeconds: z.number().int().positive().optional().nullable(),
|
|
478
506
|
thumbnailImageId: z.string().optional().nullable(),
|
|
479
|
-
processingSourceUrl:
|
|
507
|
+
processingSourceUrl: VideoSourceUrlSchema.optional().nullable(),
|
|
480
508
|
processingSourceKind: z.string().min(1).optional().nullable(),
|
|
481
509
|
}),
|
|
482
510
|
}),
|
|
@@ -496,7 +524,7 @@ function buildSchemas(resolved: ReturnType<typeof resolveConfig>) {
|
|
|
496
524
|
// there because a video always has a slug-derived identity up front.
|
|
497
525
|
audioR2Key: z.string().default(''),
|
|
498
526
|
durationSeconds: z.number().int().positive().optional().nullable(),
|
|
499
|
-
processingSourceUrl:
|
|
527
|
+
processingSourceUrl: PodcastSourceUrlSchema.optional().nullable(),
|
|
500
528
|
processingSourceKind: z.string().min(1).optional().nullable(),
|
|
501
529
|
}),
|
|
502
530
|
}),
|
|
@@ -561,7 +589,7 @@ const VideoContentUpdateSchema = z.object({
|
|
|
561
589
|
videoId: z.string().optional(),
|
|
562
590
|
durationSeconds: z.number().int().positive().optional().nullable(),
|
|
563
591
|
thumbnailImageId: z.string().optional().nullable(),
|
|
564
|
-
processingSourceUrl:
|
|
592
|
+
processingSourceUrl: VideoSourceUrlSchema.optional().nullable(),
|
|
565
593
|
processingSourceKind: z.string().min(1).optional().nullable(),
|
|
566
594
|
})
|
|
567
595
|
|
|
@@ -569,7 +597,7 @@ const PodcastContentUpdateSchema = z.object({
|
|
|
569
597
|
transcript: z.string().optional(),
|
|
570
598
|
audioR2Key: z.string().optional(),
|
|
571
599
|
durationSeconds: z.number().int().positive().optional().nullable(),
|
|
572
|
-
processingSourceUrl:
|
|
600
|
+
processingSourceUrl: PodcastSourceUrlSchema.optional().nullable(),
|
|
573
601
|
processingSourceKind: z.string().min(1).optional().nullable(),
|
|
574
602
|
})
|
|
575
603
|
|
|
@@ -990,6 +1018,22 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
990
1018
|
content.durationSeconds = null
|
|
991
1019
|
return null
|
|
992
1020
|
}
|
|
1021
|
+
// A Drive podcast source is fetched and measured by the Go plane, not
|
|
1022
|
+
// probed here: keep the editor's URL as pasted, store no duration and let
|
|
1023
|
+
// the host's probe stay out of it (it would fetch the HTML viewer page).
|
|
1024
|
+
if (type === 'podcast' && classifyGoogleDriveUrl(requestedUrl).kind === 'drive') {
|
|
1025
|
+
content.processingSourceUrl = requestedUrl
|
|
1026
|
+
content.processingSourceKind = 'https'
|
|
1027
|
+
content.durationSeconds = null
|
|
1028
|
+
return {
|
|
1029
|
+
requestedUrl,
|
|
1030
|
+
resolvedUrl: requestedUrl,
|
|
1031
|
+
sourceKind: 'https',
|
|
1032
|
+
durationSeconds: null,
|
|
1033
|
+
ready: true,
|
|
1034
|
+
blockers: [],
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
993
1037
|
const storedDuration = positiveIntegerDuration(stored?.durationSeconds)
|
|
994
1038
|
if (stored?.sourceUrl === requestedUrl && storedDuration !== null) {
|
|
995
1039
|
content.processingSourceUrl = stored.sourceUrl
|
|
@@ -1081,11 +1125,22 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1081
1125
|
}
|
|
1082
1126
|
|
|
1083
1127
|
function mediaSourceReadinessFromStored(
|
|
1128
|
+
type: ContentType,
|
|
1084
1129
|
stored: StoredMediaSource | null,
|
|
1085
1130
|
): MediaSourceReadiness | null {
|
|
1086
1131
|
if (!stored) return null
|
|
1087
1132
|
const sourceUrl = stored.sourceUrl?.trim()
|
|
1088
1133
|
if (!sourceUrl) return null
|
|
1134
|
+
if (type === 'podcast' && classifyGoogleDriveUrl(sourceUrl).kind === 'drive') {
|
|
1135
|
+
return {
|
|
1136
|
+
requestedUrl: sourceUrl,
|
|
1137
|
+
resolvedUrl: sourceUrl,
|
|
1138
|
+
sourceKind: 'https',
|
|
1139
|
+
durationSeconds: null,
|
|
1140
|
+
ready: true,
|
|
1141
|
+
blockers: [],
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1089
1144
|
const durationSeconds = positiveIntegerDuration(stored.durationSeconds)
|
|
1090
1145
|
return {
|
|
1091
1146
|
requestedUrl: sourceUrl,
|
|
@@ -1140,6 +1195,72 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1140
1195
|
}
|
|
1141
1196
|
}
|
|
1142
1197
|
|
|
1198
|
+
/**
|
|
1199
|
+
* The video sibling of maybeDispatchPodcastToFoundry: capture a sourced video
|
|
1200
|
+
* that holds no capture. A row with no trigger token is invisible to the
|
|
1201
|
+
* producer whatever its state says (trash and restore clears the token and
|
|
1202
|
+
* leaves `queued`), so only `ready` media and `failed` — the editor's Re-run
|
|
1203
|
+
* decision — are left alone. An archived item is inactive to the dispatcher;
|
|
1204
|
+
* it is captured once scheduling has made it active again.
|
|
1205
|
+
* The editor form captures after a save too, but it de-dupes on local state; a
|
|
1206
|
+
* later save that reset the capture left the video scheduled and never
|
|
1207
|
+
* prepared, with nothing to notice (Fronts, 2026-09-15..17).
|
|
1208
|
+
*/
|
|
1209
|
+
async function maybeCaptureVideoSource(
|
|
1210
|
+
ctx: RouteContext,
|
|
1211
|
+
contentId: string,
|
|
1212
|
+
): Promise<DispatchResult | null> {
|
|
1213
|
+
const hook = resolved.hooks.foundryVideo
|
|
1214
|
+
if (!hook) return null
|
|
1215
|
+
const readCapture = () =>
|
|
1216
|
+
ctx.db
|
|
1217
|
+
.prepare(
|
|
1218
|
+
`SELECT vc.processing_source_url, vc.processing_trigger_token, vc.processing_state,
|
|
1219
|
+
vc.hls_ready, ci.status
|
|
1220
|
+
FROM video_content vc JOIN content_items ci ON ci.id = vc.content_id
|
|
1221
|
+
WHERE vc.content_id = ?
|
|
1222
|
+
LIMIT 1`,
|
|
1223
|
+
)
|
|
1224
|
+
.bind(contentId)
|
|
1225
|
+
.first<{
|
|
1226
|
+
processing_source_url: string | null
|
|
1227
|
+
processing_trigger_token: string | null
|
|
1228
|
+
processing_state: string | null
|
|
1229
|
+
hls_ready: number | null
|
|
1230
|
+
status: string
|
|
1231
|
+
}>()
|
|
1232
|
+
const row = await readCapture()
|
|
1233
|
+
if (!row?.processing_source_url?.trim()) return null
|
|
1234
|
+
if (row.processing_trigger_token?.trim()) return null
|
|
1235
|
+
if (row.status === 'archived') return null
|
|
1236
|
+
if (row.hls_ready === 1) return null
|
|
1237
|
+
if (row.processing_state === 'ready' || row.processing_state === 'failed') return null
|
|
1238
|
+
try {
|
|
1239
|
+
return await dispatchToFoundry(ctx.db, contentId, hook, { ifUncaptured: true })
|
|
1240
|
+
} catch (error) {
|
|
1241
|
+
// The editor form fires its own capture after every save. Losing that
|
|
1242
|
+
// race fails this call's compare-and-set, and the capture exists.
|
|
1243
|
+
const raced = await readCapture()
|
|
1244
|
+
if (raced?.processing_trigger_token?.trim()) return null
|
|
1245
|
+
throw error
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
async function captureScheduledVideoAfterSave(
|
|
1250
|
+
ctx: RouteContext,
|
|
1251
|
+
contentId: string,
|
|
1252
|
+
): Promise<PodcastFoundryAttempt> {
|
|
1253
|
+
try {
|
|
1254
|
+
return { result: await maybeCaptureVideoSource(ctx, contentId), reason: null }
|
|
1255
|
+
} catch (error) {
|
|
1256
|
+
if (error instanceof FoundryDispatchPrerequisiteError) {
|
|
1257
|
+
return { result: null, reason: 'missing_duration' }
|
|
1258
|
+
}
|
|
1259
|
+
console.error('Scheduled video source capture failed after content save', error)
|
|
1260
|
+
return { result: null, reason: 'dispatch_failed' }
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1143
1264
|
function missingDurationResponse(): Response {
|
|
1144
1265
|
return json(
|
|
1145
1266
|
{
|
|
@@ -1422,6 +1543,33 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1422
1543
|
const publishAtSeconds = Math.floor(publishAtMs / 1000)
|
|
1423
1544
|
const expectedTargets = new Map<string, ScheduleFrontsChannelTargets | undefined>()
|
|
1424
1545
|
for (const id of ids) {
|
|
1546
|
+
// Same gate as the single-item path: no item in a bulk schedule
|
|
1547
|
+
// may take a slot with a body publish will refuse.
|
|
1548
|
+
const bulkGuard = await evaluateContentBodyForPublish(ctx.db, id, 'cms.schedule', {
|
|
1549
|
+
requireBody: false,
|
|
1550
|
+
})
|
|
1551
|
+
if (bulkGuard && !bulkGuard.ok) {
|
|
1552
|
+
return json(
|
|
1553
|
+
{
|
|
1554
|
+
error: 'Article body failed pre-publish validation',
|
|
1555
|
+
source: 'cms.schedule',
|
|
1556
|
+
contentId: id,
|
|
1557
|
+
errors: bulkGuard.errors,
|
|
1558
|
+
},
|
|
1559
|
+
422,
|
|
1560
|
+
)
|
|
1561
|
+
}
|
|
1562
|
+
// And the same capture guarantee: no sourced video in a bulk
|
|
1563
|
+
// schedule may take a slot the producer cannot see.
|
|
1564
|
+
try {
|
|
1565
|
+
await maybeCaptureVideoSource(ctx, id)
|
|
1566
|
+
} catch (error) {
|
|
1567
|
+
if (error instanceof FoundryDispatchPrerequisiteError) {
|
|
1568
|
+
return missingDurationResponse()
|
|
1569
|
+
}
|
|
1570
|
+
console.error('Video source capture failed before bulk schedule', error)
|
|
1571
|
+
return json({ error: 'Foundry dispatch failed', contentId: id }, 502)
|
|
1572
|
+
}
|
|
1425
1573
|
const prepared = await prepareScheduleTargets(ctx, id)
|
|
1426
1574
|
if ('error' in prepared) return prepared.error
|
|
1427
1575
|
expectedTargets.set(id, prepared.targets)
|
|
@@ -1433,6 +1581,8 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1433
1581
|
if (targets !== undefined && !changed) {
|
|
1434
1582
|
return json({ error: 'schedule_targets_changed', contentId: id, scheduled }, 409)
|
|
1435
1583
|
}
|
|
1584
|
+
// Covers an item that was archived above, now active again.
|
|
1585
|
+
await captureScheduledVideoAfterSave(ctx, id)
|
|
1436
1586
|
scheduled.push(id)
|
|
1437
1587
|
}
|
|
1438
1588
|
return json({ scheduled })
|
|
@@ -1569,7 +1719,10 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1569
1719
|
: content
|
|
1570
1720
|
const mediaSourceReadiness =
|
|
1571
1721
|
item.type === 'video' || item.type === 'podcast'
|
|
1572
|
-
? mediaSourceReadinessFromStored(
|
|
1722
|
+
? mediaSourceReadinessFromStored(
|
|
1723
|
+
item.type,
|
|
1724
|
+
await loadStoredMediaSource(ctx.db, id, item.type),
|
|
1725
|
+
)
|
|
1573
1726
|
: null
|
|
1574
1727
|
|
|
1575
1728
|
const aiLockedFields: string[] = (() => {
|
|
@@ -1738,6 +1891,12 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1738
1891
|
existing.type === 'podcast'
|
|
1739
1892
|
? await dispatchPodcastAfterSave(ctx, id)
|
|
1740
1893
|
: { result: null, reason: null }
|
|
1894
|
+
// A scheduled video is committed to a slot: a save that reset its capture
|
|
1895
|
+
// must not leave it unprepared until someone notices the missed publish.
|
|
1896
|
+
const videoAttempt: PodcastFoundryAttempt =
|
|
1897
|
+
existing.type === 'video' && existing.status === 'scheduled'
|
|
1898
|
+
? await captureScheduledVideoAfterSave(ctx, id)
|
|
1899
|
+
: { result: null, reason: null }
|
|
1741
1900
|
|
|
1742
1901
|
// If the slug changed, record the old slug as a redirect (spec §8)
|
|
1743
1902
|
// and drop any stale redirect row for the now-live slug — atomically,
|
|
@@ -1752,6 +1911,8 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1752
1911
|
podcastFoundryQueued: Boolean(podcastAttempt.result),
|
|
1753
1912
|
podcastFoundryCorrelationId: podcastAttempt.result?.correlationId ?? null,
|
|
1754
1913
|
podcastFoundryReason: podcastAttempt.reason,
|
|
1914
|
+
videoFoundryQueued: Boolean(videoAttempt.result && !videoAttempt.result.skipped),
|
|
1915
|
+
videoFoundryReason: videoAttempt.reason,
|
|
1755
1916
|
...(mediaSourceReadiness ? { mediaSourceReadiness } : {}),
|
|
1756
1917
|
// Present and empty when the body linted clean, so a client can
|
|
1757
1918
|
// distinguish "no warnings" from "this build does not report them".
|
|
@@ -1808,6 +1969,41 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1808
1969
|
return json({ error: 'Missing publish time' }, 400)
|
|
1809
1970
|
}
|
|
1810
1971
|
|
|
1972
|
+
// A body the publish guard will refuse must not be allowed to take a
|
|
1973
|
+
// slot. Scheduling was the one write in this lane that never ran
|
|
1974
|
+
// the guard, so a defect an editor could not see at save time
|
|
1975
|
+
// surfaced only when foundryd tried to publish — at 16:30 on a
|
|
1976
|
+
// Monday, as a missed premium article (2026-09-15).
|
|
1977
|
+
const scheduleGuard = await evaluateContentBodyForPublish(ctx.db, id, 'cms.schedule', {
|
|
1978
|
+
// Claiming a slot before the piece is written stays allowed;
|
|
1979
|
+
// claiming one with an already-broken body does not.
|
|
1980
|
+
requireBody: false,
|
|
1981
|
+
})
|
|
1982
|
+
if (scheduleGuard && !scheduleGuard.ok) {
|
|
1983
|
+
return json(
|
|
1984
|
+
{
|
|
1985
|
+
error: 'Article body failed pre-publish validation',
|
|
1986
|
+
source: 'cms.schedule',
|
|
1987
|
+
errors: scheduleGuard.errors,
|
|
1988
|
+
},
|
|
1989
|
+
422,
|
|
1990
|
+
)
|
|
1991
|
+
}
|
|
1992
|
+
// A sourced video that holds no capture is invisible to the producer:
|
|
1993
|
+
// it would take the slot and never publish. Capture it here, and refuse
|
|
1994
|
+
// the slot if that cannot be done. A video with no source yet may still
|
|
1995
|
+
// claim a slot early.
|
|
1996
|
+
if (existing.type === 'video') {
|
|
1997
|
+
try {
|
|
1998
|
+
await maybeCaptureVideoSource(ctx, id)
|
|
1999
|
+
} catch (error) {
|
|
2000
|
+
if (error instanceof FoundryDispatchPrerequisiteError) {
|
|
2001
|
+
return missingDurationResponse()
|
|
2002
|
+
}
|
|
2003
|
+
console.error('Video source capture failed before schedule', error)
|
|
2004
|
+
return json({ error: 'Foundry dispatch failed' }, 502)
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
1811
2007
|
const prepared = await prepareScheduleTargets(ctx, id)
|
|
1812
2008
|
if ('error' in prepared) return prepared.error
|
|
1813
2009
|
const changed = await scheduleContent(
|
|
@@ -1820,6 +2016,8 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1820
2016
|
if (prepared.targets !== undefined && !changed) {
|
|
1821
2017
|
return json({ error: 'schedule_targets_changed', contentId: id }, 409)
|
|
1822
2018
|
}
|
|
2019
|
+
// Covers an item that was archived above, now active again.
|
|
2020
|
+
if (existing.type === 'video') await captureScheduledVideoAfterSave(ctx, id)
|
|
1823
2021
|
|
|
1824
2022
|
// Fire content.scheduled webhook.
|
|
1825
2023
|
await fireWebhook(ctx, 'content.scheduled', {
|
|
@@ -1835,6 +2033,10 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1835
2033
|
if (action === 'publish') {
|
|
1836
2034
|
const now = Math.floor(Date.now() / 1000)
|
|
1837
2035
|
const wasDraft = existing.status === 'draft'
|
|
2036
|
+
const publishedAt =
|
|
2037
|
+
existing.status === 'published' && existing.published_at !== null
|
|
2038
|
+
? existing.published_at
|
|
2039
|
+
: now
|
|
1838
2040
|
let podcastDispatch: DispatchResult | null = null
|
|
1839
2041
|
if (existing.type === 'podcast') {
|
|
1840
2042
|
try {
|
|
@@ -1929,7 +2131,7 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1929
2131
|
throw error
|
|
1930
2132
|
}
|
|
1931
2133
|
try {
|
|
1932
|
-
await publishContent(ctx.db, id, now)
|
|
2134
|
+
await publishContent(ctx.db, id, publishedAt, now)
|
|
1933
2135
|
await createRevision(ctx.db, id, ctx.userId || null)
|
|
1934
2136
|
} catch (error) {
|
|
1935
2137
|
if (error instanceof TagInputError) return tagInputErrorResponse(error)
|
|
@@ -485,7 +485,7 @@ export function ContentForm({
|
|
|
485
485
|
setForm((prev) => ({
|
|
486
486
|
...prev,
|
|
487
487
|
saving: false,
|
|
488
|
-
saveError: err
|
|
488
|
+
saveError: apiErrorMessage(err, 'Save failed'),
|
|
489
489
|
}))
|
|
490
490
|
return
|
|
491
491
|
}
|
|
@@ -1540,6 +1540,10 @@ function AudioBlock({
|
|
|
1540
1540
|
transcodes it, and hydrates the Audio R2 Key and duration automatically. Skip this once the
|
|
1541
1541
|
audio file is uploaded below.
|
|
1542
1542
|
</div>
|
|
1543
|
+
<div style={assetHelp}>
|
|
1544
|
+
A Google Drive share link to the audio file works too, if the file is shared with the Fronts
|
|
1545
|
+
Google account or with anyone who has the link. Google Docs and folders are not audio files.
|
|
1546
|
+
</div>
|
|
1543
1547
|
|
|
1544
1548
|
<div
|
|
1545
1549
|
style={{
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { apiErrorMessage } from '../api-error.js'
|
|
1
2
|
import { slugifyTitle } from './content-payload.js'
|
|
2
3
|
|
|
3
4
|
type FetchLike = (url: string, init?: RequestInit) => Promise<Response>
|
|
@@ -17,7 +18,7 @@ export async function uploadEditorImage(
|
|
|
17
18
|
})
|
|
18
19
|
if (!res.ok) {
|
|
19
20
|
const err = (await res.json().catch(() => ({ error: res.statusText }))) as { error?: string }
|
|
20
|
-
throw new Error(err
|
|
21
|
+
throw new Error(apiErrorMessage(err, 'Image upload failed'))
|
|
21
22
|
}
|
|
22
23
|
const body = (await res.json()) as { url?: string; publicUrl?: string }
|
|
23
24
|
const url = body.url || body.publicUrl
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { useCallback, useEffect, useState } from 'react'
|
|
14
14
|
import type { ContentStatus } from '../../schema/types.js'
|
|
15
|
+
import { apiErrorMessage } from '../api-error.js'
|
|
15
16
|
import { Icon } from '../icons.js'
|
|
16
17
|
import { Field } from './Field.js'
|
|
17
18
|
import {
|
|
@@ -246,7 +247,7 @@ export function PublishTab({
|
|
|
246
247
|
const err = (await res.json().catch(() => ({ error: res.statusText }))) as {
|
|
247
248
|
error?: string
|
|
248
249
|
}
|
|
249
|
-
throw new Error(err
|
|
250
|
+
throw new Error(apiErrorMessage(err, 'Could not add author'))
|
|
250
251
|
}
|
|
251
252
|
const created = (await res.json()) as CreatedAuthorResponse
|
|
252
253
|
const nextAuthor = { id: created.id, name }
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { useCallback, useEffect, useReducer, useRef, useState } from 'react'
|
|
14
14
|
import type { ContentStatus, ContentType, FrontsPublishStatus } from '../../schema/types.js'
|
|
15
|
+
import { apiErrorMessage } from '../api-error.js'
|
|
15
16
|
import { buildSharePrefill, ShareModal } from '../components/ShareModal.js'
|
|
16
17
|
import { Icon } from '../icons.js'
|
|
17
18
|
import { useWorkspace } from '../workspace-context.js'
|
|
@@ -1139,7 +1140,7 @@ function BulkBar({
|
|
|
1139
1140
|
const err = (await res.json().catch(() => ({ error: res.statusText }))) as {
|
|
1140
1141
|
error?: string
|
|
1141
1142
|
}
|
|
1142
|
-
alert(`Bulk ${op} failed: ${err
|
|
1143
|
+
alert(`Bulk ${op} failed: ${apiErrorMessage(err, res.statusText)}`)
|
|
1143
1144
|
setBusy(false)
|
|
1144
1145
|
return
|
|
1145
1146
|
}
|
|
@@ -1241,7 +1242,7 @@ function ImportModal({
|
|
|
1241
1242
|
const err = (await res.json().catch(() => ({ error: res.statusText }))) as {
|
|
1242
1243
|
error?: string
|
|
1243
1244
|
}
|
|
1244
|
-
setError(err
|
|
1245
|
+
setError(apiErrorMessage(err, 'Parse failed'))
|
|
1245
1246
|
return
|
|
1246
1247
|
}
|
|
1247
1248
|
const data = (await res.json()) as ParseResult
|
|
@@ -1266,7 +1267,7 @@ function ImportModal({
|
|
|
1266
1267
|
const err = (await res.json().catch(() => ({ error: res.statusText }))) as {
|
|
1267
1268
|
error?: string
|
|
1268
1269
|
}
|
|
1269
|
-
setError(err
|
|
1270
|
+
setError(apiErrorMessage(err, 'Confirm failed'))
|
|
1270
1271
|
setStep('preview')
|
|
1271
1272
|
return
|
|
1272
1273
|
}
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
import { useCallback, useEffect, useReducer, useState } from 'react'
|
|
25
25
|
import type { ApiKeyListEntry } from '../../engine/api-keys.js'
|
|
26
26
|
import { WEBHOOK_EVENTS, type WebhookRecord } from '../../engine/webhooks.js'
|
|
27
|
+
import { apiErrorMessage } from '../api-error.js'
|
|
27
28
|
import { Icon } from '../icons.js'
|
|
28
29
|
import { useWorkspace } from '../workspace-context.js'
|
|
29
30
|
import {
|
|
@@ -552,7 +553,7 @@ function TeamSettings({ currentSubject }: { currentSubject: string | null }) {
|
|
|
552
553
|
const res = await fetch('/admin/api/settings/members')
|
|
553
554
|
if (!res.ok) {
|
|
554
555
|
const body = (await res.json().catch(() => ({}))) as { error?: string }
|
|
555
|
-
dispatch({ type: 'fetch-error', error: body
|
|
556
|
+
dispatch({ type: 'fetch-error', error: apiErrorMessage(body, `HTTP ${res.status}`) })
|
|
556
557
|
return
|
|
557
558
|
}
|
|
558
559
|
const body = (await res.json()) as {
|
|
@@ -600,7 +601,7 @@ function TeamSettings({ currentSubject }: { currentSubject: string | null }) {
|
|
|
600
601
|
})
|
|
601
602
|
if (!res.ok) {
|
|
602
603
|
const body = (await res.json().catch(() => ({}))) as { error?: string; reason?: string }
|
|
603
|
-
alert(body.reason ?? body
|
|
604
|
+
alert(body.reason ?? apiErrorMessage(body, `Failed to change role (HTTP ${res.status})`))
|
|
604
605
|
return
|
|
605
606
|
}
|
|
606
607
|
dispatch({ type: 'update-role', userId, role: newRole })
|
|
@@ -629,7 +630,7 @@ function TeamSettings({ currentSubject }: { currentSubject: string | null }) {
|
|
|
629
630
|
error?: string
|
|
630
631
|
}
|
|
631
632
|
if (!res.ok) {
|
|
632
|
-
setInviteError(body
|
|
633
|
+
setInviteError(apiErrorMessage(body, `HTTP ${res.status}`))
|
|
633
634
|
return
|
|
634
635
|
}
|
|
635
636
|
setInviteSuccess(
|
|
@@ -905,7 +906,7 @@ function ApiSettings() {
|
|
|
905
906
|
const res = await fetch('/admin/api/settings/api-keys')
|
|
906
907
|
if (!res.ok) {
|
|
907
908
|
const body = (await res.json().catch(() => ({}))) as { error?: string }
|
|
908
|
-
dispatch({ type: 'fetch-error', error: body
|
|
909
|
+
dispatch({ type: 'fetch-error', error: apiErrorMessage(body, `HTTP ${res.status}`) })
|
|
909
910
|
return
|
|
910
911
|
}
|
|
911
912
|
const body = (await res.json()) as { apiKeys: ApiKeyListEntry[] }
|
|
@@ -937,7 +938,7 @@ function ApiSettings() {
|
|
|
937
938
|
error?: string
|
|
938
939
|
}
|
|
939
940
|
if (!res.ok) {
|
|
940
|
-
setCreateError(body
|
|
941
|
+
setCreateError(apiErrorMessage(body, `HTTP ${res.status}`))
|
|
941
942
|
return
|
|
942
943
|
}
|
|
943
944
|
// Show the one-time reveal modal
|
|
@@ -965,7 +966,7 @@ function ApiSettings() {
|
|
|
965
966
|
dispatch({ type: 'revoked', id })
|
|
966
967
|
} else {
|
|
967
968
|
const body = (await res.json().catch(() => ({}))) as { error?: string }
|
|
968
|
-
alert(body
|
|
969
|
+
alert(apiErrorMessage(body, `Failed to revoke (HTTP ${res.status})`))
|
|
969
970
|
}
|
|
970
971
|
} catch (err) {
|
|
971
972
|
alert(String(err))
|
|
@@ -1158,7 +1159,7 @@ function WebhooksSettings() {
|
|
|
1158
1159
|
const res = await fetch('/admin/api/settings/webhooks')
|
|
1159
1160
|
if (!res.ok) {
|
|
1160
1161
|
const body = (await res.json().catch(() => ({}))) as { error?: string }
|
|
1161
|
-
dispatch({ type: 'fetch-error', error: body
|
|
1162
|
+
dispatch({ type: 'fetch-error', error: apiErrorMessage(body, `HTTP ${res.status}`) })
|
|
1162
1163
|
return
|
|
1163
1164
|
}
|
|
1164
1165
|
const body = (await res.json()) as { webhooks: WebhookRecord[] }
|
|
@@ -1185,7 +1186,7 @@ function WebhooksSettings() {
|
|
|
1185
1186
|
})
|
|
1186
1187
|
const body = (await res.json().catch(() => ({}))) as { error?: string }
|
|
1187
1188
|
if (!res.ok) {
|
|
1188
|
-
setCreateError(body
|
|
1189
|
+
setCreateError(apiErrorMessage(body, `HTTP ${res.status}`))
|
|
1189
1190
|
return
|
|
1190
1191
|
}
|
|
1191
1192
|
setNewUrl('')
|
|
@@ -1206,7 +1207,7 @@ function WebhooksSettings() {
|
|
|
1206
1207
|
})
|
|
1207
1208
|
const body = (await res.json().catch(() => ({}))) as { error?: string }
|
|
1208
1209
|
if (!res.ok) {
|
|
1209
|
-
alert(body
|
|
1210
|
+
alert(apiErrorMessage(body, `Test failed (HTTP ${res.status})`))
|
|
1210
1211
|
return
|
|
1211
1212
|
}
|
|
1212
1213
|
dispatch({ type: 'update-status', id, status: 'test' })
|
|
@@ -1228,7 +1229,7 @@ function WebhooksSettings() {
|
|
|
1228
1229
|
dispatch({ type: 'deleted', id })
|
|
1229
1230
|
} else {
|
|
1230
1231
|
const body = (await res.json().catch(() => ({}))) as { error?: string }
|
|
1231
|
-
alert(body
|
|
1232
|
+
alert(apiErrorMessage(body, `Failed to delete (HTTP ${res.status})`))
|
|
1232
1233
|
}
|
|
1233
1234
|
} catch (err) {
|
|
1234
1235
|
alert(String(err))
|
|
@@ -1481,7 +1482,7 @@ function IntegrationsSettings() {
|
|
|
1481
1482
|
.then(async (res) => {
|
|
1482
1483
|
if (!res.ok) {
|
|
1483
1484
|
const body = (await res.json().catch(() => ({}))) as { error?: string }
|
|
1484
|
-
setError(body
|
|
1485
|
+
setError(apiErrorMessage(body, `HTTP ${res.status}`))
|
|
1485
1486
|
return
|
|
1486
1487
|
}
|
|
1487
1488
|
const body = (await res.json()) as IntegrationsData
|
|
@@ -1600,7 +1601,7 @@ function DomainsSettings() {
|
|
|
1600
1601
|
.then(async (res) => {
|
|
1601
1602
|
if (!res.ok) {
|
|
1602
1603
|
const body = (await res.json().catch(() => ({}))) as { error?: string }
|
|
1603
|
-
setError(body
|
|
1604
|
+
setError(apiErrorMessage(body, `HTTP ${res.status}`))
|
|
1604
1605
|
return
|
|
1605
1606
|
}
|
|
1606
1607
|
const body = (await res.json()) as DomainsData
|
|
@@ -1728,7 +1729,7 @@ export function SettingsScreen() {
|
|
|
1728
1729
|
})
|
|
1729
1730
|
if (!res.ok) {
|
|
1730
1731
|
const body = (await res.json().catch(() => ({}))) as { error?: string }
|
|
1731
|
-
alert(body
|
|
1732
|
+
alert(apiErrorMessage(body, `Failed to save settings (HTTP ${res.status})`))
|
|
1732
1733
|
return
|
|
1733
1734
|
}
|
|
1734
1735
|
// Re-fetch to ensure the UI is in sync with server state
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
// confirm dialog. NO classes outside the broadsheet vocabulary.
|
|
31
31
|
|
|
32
32
|
import { useCallback, useEffect, useReducer, useState } from 'react'
|
|
33
|
+
import { apiErrorMessage } from '../api-error.js'
|
|
33
34
|
import { Icon } from '../icons.js'
|
|
34
35
|
import {
|
|
35
36
|
buildTopicCreateBody,
|
|
@@ -191,7 +192,7 @@ export function TopicsScreen() {
|
|
|
191
192
|
error?: string
|
|
192
193
|
}
|
|
193
194
|
if (!res.ok || !body.topic) {
|
|
194
|
-
setCreateError(body
|
|
195
|
+
setCreateError(apiErrorMessage(body, `${res.status} ${res.statusText}`))
|
|
195
196
|
return
|
|
196
197
|
}
|
|
197
198
|
dispatchTopics({ type: 'create-ok', topic: body.topic })
|
|
@@ -219,7 +220,7 @@ export function TopicsScreen() {
|
|
|
219
220
|
error?: string
|
|
220
221
|
}
|
|
221
222
|
if (!res.ok || !body.topic) {
|
|
222
|
-
setRenameError(body
|
|
223
|
+
setRenameError(apiErrorMessage(body, `${res.status} ${res.statusText}`))
|
|
223
224
|
return
|
|
224
225
|
}
|
|
225
226
|
dispatchTopics({ type: 'rename-ok', fromSlug: pendingRename.slug, topic: body.topic })
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { apiErrorMessage } from '../api-error.js'
|
|
2
|
+
|
|
1
3
|
interface AuthorWriteFailureBody {
|
|
2
4
|
error?: string
|
|
3
5
|
errorClass?: string
|
|
@@ -17,7 +19,7 @@ export function formatAuthorWriteFailure(
|
|
|
17
19
|
body.author?.id ? `author id: ${body.author.id}` : '',
|
|
18
20
|
body.author?.slug ? `slug: ${body.author.slug}` : '',
|
|
19
21
|
].filter(Boolean)
|
|
20
|
-
const message = body
|
|
22
|
+
const message = apiErrorMessage(body, 'Author synchronization failed')
|
|
21
23
|
const classifiedMessage = message.includes(body.errorClass)
|
|
22
24
|
? message
|
|
23
25
|
: `${body.errorClass}: ${message}`
|