@growth-labs/cms 0.8.8 → 0.8.14
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/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/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 +129 -12
- package/dist/migration-vendor.js.map +1 -1
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +132 -1
- package/dist/routes/content.js.map +1 -1
- package/dist/ui/editor/ContentForm.js +1 -1
- 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/fronts-publish.ts +45 -6
- package/src/engine/publisher.ts +24 -11
- package/src/migration-vendor.ts +146 -14
- package/src/routes/content.ts +145 -1
- package/src/ui/editor/ContentForm.tsx +1 -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/migration-vendor.ts
CHANGED
|
@@ -119,26 +119,149 @@ function sequencePrefix(sequence: number): string {
|
|
|
119
119
|
return String(sequence).padStart(4, '0')
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
function targetSequence(target: string): number {
|
|
123
|
+
return Number.parseInt(target.slice(0, 4), 10)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function managedSuffix(source: string): string {
|
|
127
|
+
return `_growth_labs_cms_${source.replace(/^\d{4}_/, '')}`
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Sequences already spoken for in the consumer's migrations directory.
|
|
132
|
+
*
|
|
133
|
+
* Wrangler accepts one `migrations_dir`, so a consumer's own analytics, SEO or
|
|
134
|
+
* product migrations are interleaved with the vendored package ones. Those
|
|
135
|
+
* numbers are not ours to take.
|
|
136
|
+
*/
|
|
137
|
+
async function occupiedSequences(migrationsDir: string): Promise<Set<number>> {
|
|
138
|
+
const names = await readdir(migrationsDir)
|
|
139
|
+
const taken = new Set<number>()
|
|
140
|
+
for (const name of names) {
|
|
141
|
+
if (!name.endsWith('.sql')) continue
|
|
142
|
+
const sequence = targetSequence(name)
|
|
143
|
+
if (Number.isSafeInteger(sequence)) taken.add(sequence)
|
|
144
|
+
}
|
|
145
|
+
return taken
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
interface PlanContext {
|
|
149
|
+
/** Recorded source -> target, the authority for anything already vendored. */
|
|
150
|
+
recorded: Map<string, CmsMigrationManifestEntry>
|
|
151
|
+
/** Existing consumer files, for sequence avoidance and hash adoption. */
|
|
152
|
+
migrationsDir?: string
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Resolve each package migration to the consumer filename it must occupy.
|
|
157
|
+
*
|
|
158
|
+
* The hard invariant: a migration this consumer has ALREADY vendored keeps its
|
|
159
|
+
* exact filename forever. D1's `d1_migrations` ledger keys off that name, so
|
|
160
|
+
* renumbering an applied migration would re-run it. Every branch below either
|
|
161
|
+
* reuses a name or allocates an unused one; none ever rewrites an assigned one.
|
|
162
|
+
*
|
|
163
|
+
* Resolution order per source:
|
|
164
|
+
* 1. recorded in the receipt -> reuse that target verbatim;
|
|
165
|
+
* 2. an on-disk file with our managed suffix whose bytes hash identical
|
|
166
|
+
* -> adopt it (reconciles migrations vendored by hand before the receipt
|
|
167
|
+
* existed, without creating a duplicate that would apply the same SQL
|
|
168
|
+
* twice);
|
|
169
|
+
* 3. otherwise -> the next sequence above everything already assigned or
|
|
170
|
+
* occupied, skipping consumer-owned numbers.
|
|
171
|
+
*/
|
|
122
172
|
async function planMigrations(
|
|
123
173
|
sourceDir: string,
|
|
124
174
|
startSequence: number,
|
|
175
|
+
context: PlanContext = { recorded: new Map() },
|
|
125
176
|
): Promise<PlannedMigration[]> {
|
|
126
177
|
assertStartSequence(startSequence)
|
|
127
178
|
const sourceNames = (await readdir(sourceDir)).filter((name) => MIGRATION_FILE.test(name)).sort()
|
|
128
179
|
if (sourceNames.length === 0) throw new Error('installed CMS package has no migration SQL')
|
|
129
|
-
|
|
130
|
-
|
|
180
|
+
|
|
181
|
+
const occupied = context.migrationsDir
|
|
182
|
+
? await occupiedSequences(context.migrationsDir)
|
|
183
|
+
: new Set<number>()
|
|
184
|
+
const existingNames = context.migrationsDir ? await readdir(context.migrationsDir) : []
|
|
185
|
+
const assigned = new Set<number>()
|
|
186
|
+
let highWater = startSequence - 1
|
|
187
|
+
for (const sequence of occupied) {
|
|
188
|
+
if (sequence > highWater) highWater = sequence
|
|
131
189
|
}
|
|
132
190
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
191
|
+
const planned: PlannedMigration[] = []
|
|
192
|
+
for (const source of sourceNames) {
|
|
193
|
+
const bytes = new Uint8Array(await readFile(join(sourceDir, source)))
|
|
194
|
+
const hash = await sha256(bytes)
|
|
195
|
+
const suffix = managedSuffix(source)
|
|
196
|
+
|
|
197
|
+
const recorded = context.recorded.get(source)
|
|
198
|
+
if (recorded) {
|
|
199
|
+
const sequence = targetSequence(recorded.target)
|
|
200
|
+
assigned.add(sequence)
|
|
201
|
+
if (sequence > highWater) highWater = sequence
|
|
202
|
+
planned.push({ source, target: recorded.target, sequence, sha256: hash, bytes })
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const adopted = await findAdoptableTarget(
|
|
207
|
+
context.migrationsDir,
|
|
208
|
+
existingNames,
|
|
209
|
+
suffix,
|
|
210
|
+
hash,
|
|
211
|
+
assigned,
|
|
212
|
+
)
|
|
213
|
+
if (adopted) {
|
|
214
|
+
const sequence = targetSequence(adopted)
|
|
215
|
+
assigned.add(sequence)
|
|
216
|
+
if (sequence > highWater) highWater = sequence
|
|
217
|
+
planned.push({ source, target: adopted, sequence, sha256: hash, bytes })
|
|
218
|
+
continue
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let sequence = Math.max(startSequence, highWater + 1)
|
|
222
|
+
while (occupied.has(sequence) || assigned.has(sequence)) sequence += 1
|
|
223
|
+
if (sequence > 9999) throw new Error('reserved CMS migration sequence exceeds 9999')
|
|
224
|
+
assigned.add(sequence)
|
|
225
|
+
highWater = sequence
|
|
226
|
+
planned.push({
|
|
227
|
+
source,
|
|
228
|
+
target: `${sequencePrefix(sequence)}${suffix}`,
|
|
229
|
+
sequence,
|
|
230
|
+
sha256: hash,
|
|
231
|
+
bytes,
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return planned
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* An existing consumer file that IS this package migration, byte for byte.
|
|
240
|
+
*
|
|
241
|
+
* Content-addressed on purpose: adopting by filename alone would let an
|
|
242
|
+
* unrelated file with a colliding suffix be claimed as ours.
|
|
243
|
+
*/
|
|
244
|
+
async function findAdoptableTarget(
|
|
245
|
+
migrationsDir: string | undefined,
|
|
246
|
+
existingNames: string[],
|
|
247
|
+
suffix: string,
|
|
248
|
+
hash: string,
|
|
249
|
+
assigned: Set<number>,
|
|
250
|
+
): Promise<string | null> {
|
|
251
|
+
if (!migrationsDir) return null
|
|
252
|
+
for (const name of existingNames.slice().sort()) {
|
|
253
|
+
if (!name.endsWith('.sql') || !name.endsWith(`${suffix}`)) continue
|
|
254
|
+
const sequence = targetSequence(name)
|
|
255
|
+
if (!Number.isSafeInteger(sequence) || assigned.has(sequence)) continue
|
|
256
|
+
let existingHash: string
|
|
257
|
+
try {
|
|
258
|
+
existingHash = await sha256(new Uint8Array(await readFile(join(migrationsDir, name))))
|
|
259
|
+
} catch {
|
|
260
|
+
continue
|
|
261
|
+
}
|
|
262
|
+
if (existingHash === hash) return name
|
|
263
|
+
}
|
|
264
|
+
return null
|
|
142
265
|
}
|
|
143
266
|
|
|
144
267
|
function manifestFor(
|
|
@@ -365,9 +488,15 @@ export async function syncCmsMigrations(
|
|
|
365
488
|
const manifestPath = await resolveManifestPath(migrationsDir, options.manifestPath)
|
|
366
489
|
return withManifestLock(manifestPath, async () => {
|
|
367
490
|
const contract = await packageContract()
|
|
368
|
-
const plan = await planMigrations(contract.sourceDir, options.startSequence)
|
|
369
|
-
const nextManifest = manifestFor(contract.packageVersion, options.startSequence, plan)
|
|
370
491
|
const currentManifest = await readManifest(manifestPath)
|
|
492
|
+
// The receipt is the authority for anything already vendored: plan with it
|
|
493
|
+
// so recorded migrations keep their exact filenames and only genuinely new
|
|
494
|
+
// ones are allocated a sequence.
|
|
495
|
+
const plan = await planMigrations(contract.sourceDir, options.startSequence, {
|
|
496
|
+
recorded: new Map((currentManifest?.files ?? []).map((entry) => [entry.source, entry])),
|
|
497
|
+
migrationsDir,
|
|
498
|
+
})
|
|
499
|
+
const nextManifest = manifestFor(contract.packageVersion, options.startSequence, plan)
|
|
371
500
|
assertManifestCanAdvance(currentManifest, nextManifest)
|
|
372
501
|
await assertNoSequenceCollisions(migrationsDir, plan)
|
|
373
502
|
const preflight = await verifyTargets(migrationsDir, plan, true)
|
|
@@ -424,7 +553,10 @@ export async function verifyCmsMigrations(
|
|
|
424
553
|
`CMS migration manifest version ${manifest.packageVersion} does not match installed ${contract.packageVersion}`,
|
|
425
554
|
)
|
|
426
555
|
}
|
|
427
|
-
const plan = await planMigrations(contract.sourceDir, manifest.startSequence
|
|
556
|
+
const plan = await planMigrations(contract.sourceDir, manifest.startSequence, {
|
|
557
|
+
recorded: new Map(manifest.files.map((entry) => [entry.source, entry])),
|
|
558
|
+
migrationsDir,
|
|
559
|
+
})
|
|
428
560
|
const expected = manifestFor(contract.packageVersion, manifest.startSequence, plan)
|
|
429
561
|
if (
|
|
430
562
|
expected.files.length !== manifest.files.length ||
|
package/src/routes/content.ts
CHANGED
|
@@ -1140,6 +1140,72 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1140
1140
|
}
|
|
1141
1141
|
}
|
|
1142
1142
|
|
|
1143
|
+
/**
|
|
1144
|
+
* The video sibling of maybeDispatchPodcastToFoundry: capture a sourced video
|
|
1145
|
+
* that holds no capture. A row with no trigger token is invisible to the
|
|
1146
|
+
* producer whatever its state says (trash and restore clears the token and
|
|
1147
|
+
* leaves `queued`), so only `ready` media and `failed` — the editor's Re-run
|
|
1148
|
+
* decision — are left alone. An archived item is inactive to the dispatcher;
|
|
1149
|
+
* it is captured once scheduling has made it active again.
|
|
1150
|
+
* The editor form captures after a save too, but it de-dupes on local state; a
|
|
1151
|
+
* later save that reset the capture left the video scheduled and never
|
|
1152
|
+
* prepared, with nothing to notice (Fronts, 2026-09-15..17).
|
|
1153
|
+
*/
|
|
1154
|
+
async function maybeCaptureVideoSource(
|
|
1155
|
+
ctx: RouteContext,
|
|
1156
|
+
contentId: string,
|
|
1157
|
+
): Promise<DispatchResult | null> {
|
|
1158
|
+
const hook = resolved.hooks.foundryVideo
|
|
1159
|
+
if (!hook) return null
|
|
1160
|
+
const readCapture = () =>
|
|
1161
|
+
ctx.db
|
|
1162
|
+
.prepare(
|
|
1163
|
+
`SELECT vc.processing_source_url, vc.processing_trigger_token, vc.processing_state,
|
|
1164
|
+
vc.hls_ready, ci.status
|
|
1165
|
+
FROM video_content vc JOIN content_items ci ON ci.id = vc.content_id
|
|
1166
|
+
WHERE vc.content_id = ?
|
|
1167
|
+
LIMIT 1`,
|
|
1168
|
+
)
|
|
1169
|
+
.bind(contentId)
|
|
1170
|
+
.first<{
|
|
1171
|
+
processing_source_url: string | null
|
|
1172
|
+
processing_trigger_token: string | null
|
|
1173
|
+
processing_state: string | null
|
|
1174
|
+
hls_ready: number | null
|
|
1175
|
+
status: string
|
|
1176
|
+
}>()
|
|
1177
|
+
const row = await readCapture()
|
|
1178
|
+
if (!row?.processing_source_url?.trim()) return null
|
|
1179
|
+
if (row.processing_trigger_token?.trim()) return null
|
|
1180
|
+
if (row.status === 'archived') return null
|
|
1181
|
+
if (row.hls_ready === 1) return null
|
|
1182
|
+
if (row.processing_state === 'ready' || row.processing_state === 'failed') return null
|
|
1183
|
+
try {
|
|
1184
|
+
return await dispatchToFoundry(ctx.db, contentId, hook, { ifUncaptured: true })
|
|
1185
|
+
} catch (error) {
|
|
1186
|
+
// The editor form fires its own capture after every save. Losing that
|
|
1187
|
+
// race fails this call's compare-and-set, and the capture exists.
|
|
1188
|
+
const raced = await readCapture()
|
|
1189
|
+
if (raced?.processing_trigger_token?.trim()) return null
|
|
1190
|
+
throw error
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
async function captureScheduledVideoAfterSave(
|
|
1195
|
+
ctx: RouteContext,
|
|
1196
|
+
contentId: string,
|
|
1197
|
+
): Promise<PodcastFoundryAttempt> {
|
|
1198
|
+
try {
|
|
1199
|
+
return { result: await maybeCaptureVideoSource(ctx, contentId), reason: null }
|
|
1200
|
+
} catch (error) {
|
|
1201
|
+
if (error instanceof FoundryDispatchPrerequisiteError) {
|
|
1202
|
+
return { result: null, reason: 'missing_duration' }
|
|
1203
|
+
}
|
|
1204
|
+
console.error('Scheduled video source capture failed after content save', error)
|
|
1205
|
+
return { result: null, reason: 'dispatch_failed' }
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1143
1209
|
function missingDurationResponse(): Response {
|
|
1144
1210
|
return json(
|
|
1145
1211
|
{
|
|
@@ -1422,6 +1488,33 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1422
1488
|
const publishAtSeconds = Math.floor(publishAtMs / 1000)
|
|
1423
1489
|
const expectedTargets = new Map<string, ScheduleFrontsChannelTargets | undefined>()
|
|
1424
1490
|
for (const id of ids) {
|
|
1491
|
+
// Same gate as the single-item path: no item in a bulk schedule
|
|
1492
|
+
// may take a slot with a body publish will refuse.
|
|
1493
|
+
const bulkGuard = await evaluateContentBodyForPublish(ctx.db, id, 'cms.schedule', {
|
|
1494
|
+
requireBody: false,
|
|
1495
|
+
})
|
|
1496
|
+
if (bulkGuard && !bulkGuard.ok) {
|
|
1497
|
+
return json(
|
|
1498
|
+
{
|
|
1499
|
+
error: 'Article body failed pre-publish validation',
|
|
1500
|
+
source: 'cms.schedule',
|
|
1501
|
+
contentId: id,
|
|
1502
|
+
errors: bulkGuard.errors,
|
|
1503
|
+
},
|
|
1504
|
+
422,
|
|
1505
|
+
)
|
|
1506
|
+
}
|
|
1507
|
+
// And the same capture guarantee: no sourced video in a bulk
|
|
1508
|
+
// schedule may take a slot the producer cannot see.
|
|
1509
|
+
try {
|
|
1510
|
+
await maybeCaptureVideoSource(ctx, id)
|
|
1511
|
+
} catch (error) {
|
|
1512
|
+
if (error instanceof FoundryDispatchPrerequisiteError) {
|
|
1513
|
+
return missingDurationResponse()
|
|
1514
|
+
}
|
|
1515
|
+
console.error('Video source capture failed before bulk schedule', error)
|
|
1516
|
+
return json({ error: 'Foundry dispatch failed', contentId: id }, 502)
|
|
1517
|
+
}
|
|
1425
1518
|
const prepared = await prepareScheduleTargets(ctx, id)
|
|
1426
1519
|
if ('error' in prepared) return prepared.error
|
|
1427
1520
|
expectedTargets.set(id, prepared.targets)
|
|
@@ -1433,6 +1526,8 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1433
1526
|
if (targets !== undefined && !changed) {
|
|
1434
1527
|
return json({ error: 'schedule_targets_changed', contentId: id, scheduled }, 409)
|
|
1435
1528
|
}
|
|
1529
|
+
// Covers an item that was archived above, now active again.
|
|
1530
|
+
await captureScheduledVideoAfterSave(ctx, id)
|
|
1436
1531
|
scheduled.push(id)
|
|
1437
1532
|
}
|
|
1438
1533
|
return json({ scheduled })
|
|
@@ -1738,6 +1833,12 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1738
1833
|
existing.type === 'podcast'
|
|
1739
1834
|
? await dispatchPodcastAfterSave(ctx, id)
|
|
1740
1835
|
: { result: null, reason: null }
|
|
1836
|
+
// A scheduled video is committed to a slot: a save that reset its capture
|
|
1837
|
+
// must not leave it unprepared until someone notices the missed publish.
|
|
1838
|
+
const videoAttempt: PodcastFoundryAttempt =
|
|
1839
|
+
existing.type === 'video' && existing.status === 'scheduled'
|
|
1840
|
+
? await captureScheduledVideoAfterSave(ctx, id)
|
|
1841
|
+
: { result: null, reason: null }
|
|
1741
1842
|
|
|
1742
1843
|
// If the slug changed, record the old slug as a redirect (spec §8)
|
|
1743
1844
|
// and drop any stale redirect row for the now-live slug — atomically,
|
|
@@ -1752,6 +1853,8 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1752
1853
|
podcastFoundryQueued: Boolean(podcastAttempt.result),
|
|
1753
1854
|
podcastFoundryCorrelationId: podcastAttempt.result?.correlationId ?? null,
|
|
1754
1855
|
podcastFoundryReason: podcastAttempt.reason,
|
|
1856
|
+
videoFoundryQueued: Boolean(videoAttempt.result && !videoAttempt.result.skipped),
|
|
1857
|
+
videoFoundryReason: videoAttempt.reason,
|
|
1755
1858
|
...(mediaSourceReadiness ? { mediaSourceReadiness } : {}),
|
|
1756
1859
|
// Present and empty when the body linted clean, so a client can
|
|
1757
1860
|
// distinguish "no warnings" from "this build does not report them".
|
|
@@ -1808,6 +1911,41 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1808
1911
|
return json({ error: 'Missing publish time' }, 400)
|
|
1809
1912
|
}
|
|
1810
1913
|
|
|
1914
|
+
// A body the publish guard will refuse must not be allowed to take a
|
|
1915
|
+
// slot. Scheduling was the one write in this lane that never ran
|
|
1916
|
+
// the guard, so a defect an editor could not see at save time
|
|
1917
|
+
// surfaced only when foundryd tried to publish — at 16:30 on a
|
|
1918
|
+
// Monday, as a missed premium article (2026-09-15).
|
|
1919
|
+
const scheduleGuard = await evaluateContentBodyForPublish(ctx.db, id, 'cms.schedule', {
|
|
1920
|
+
// Claiming a slot before the piece is written stays allowed;
|
|
1921
|
+
// claiming one with an already-broken body does not.
|
|
1922
|
+
requireBody: false,
|
|
1923
|
+
})
|
|
1924
|
+
if (scheduleGuard && !scheduleGuard.ok) {
|
|
1925
|
+
return json(
|
|
1926
|
+
{
|
|
1927
|
+
error: 'Article body failed pre-publish validation',
|
|
1928
|
+
source: 'cms.schedule',
|
|
1929
|
+
errors: scheduleGuard.errors,
|
|
1930
|
+
},
|
|
1931
|
+
422,
|
|
1932
|
+
)
|
|
1933
|
+
}
|
|
1934
|
+
// A sourced video that holds no capture is invisible to the producer:
|
|
1935
|
+
// it would take the slot and never publish. Capture it here, and refuse
|
|
1936
|
+
// the slot if that cannot be done. A video with no source yet may still
|
|
1937
|
+
// claim a slot early.
|
|
1938
|
+
if (existing.type === 'video') {
|
|
1939
|
+
try {
|
|
1940
|
+
await maybeCaptureVideoSource(ctx, id)
|
|
1941
|
+
} catch (error) {
|
|
1942
|
+
if (error instanceof FoundryDispatchPrerequisiteError) {
|
|
1943
|
+
return missingDurationResponse()
|
|
1944
|
+
}
|
|
1945
|
+
console.error('Video source capture failed before schedule', error)
|
|
1946
|
+
return json({ error: 'Foundry dispatch failed' }, 502)
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1811
1949
|
const prepared = await prepareScheduleTargets(ctx, id)
|
|
1812
1950
|
if ('error' in prepared) return prepared.error
|
|
1813
1951
|
const changed = await scheduleContent(
|
|
@@ -1820,6 +1958,8 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1820
1958
|
if (prepared.targets !== undefined && !changed) {
|
|
1821
1959
|
return json({ error: 'schedule_targets_changed', contentId: id }, 409)
|
|
1822
1960
|
}
|
|
1961
|
+
// Covers an item that was archived above, now active again.
|
|
1962
|
+
if (existing.type === 'video') await captureScheduledVideoAfterSave(ctx, id)
|
|
1823
1963
|
|
|
1824
1964
|
// Fire content.scheduled webhook.
|
|
1825
1965
|
await fireWebhook(ctx, 'content.scheduled', {
|
|
@@ -1835,6 +1975,10 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1835
1975
|
if (action === 'publish') {
|
|
1836
1976
|
const now = Math.floor(Date.now() / 1000)
|
|
1837
1977
|
const wasDraft = existing.status === 'draft'
|
|
1978
|
+
const publishedAt =
|
|
1979
|
+
existing.status === 'published' && existing.published_at !== null
|
|
1980
|
+
? existing.published_at
|
|
1981
|
+
: now
|
|
1838
1982
|
let podcastDispatch: DispatchResult | null = null
|
|
1839
1983
|
if (existing.type === 'podcast') {
|
|
1840
1984
|
try {
|
|
@@ -1929,7 +2073,7 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1929
2073
|
throw error
|
|
1930
2074
|
}
|
|
1931
2075
|
try {
|
|
1932
|
-
await publishContent(ctx.db, id, now)
|
|
2076
|
+
await publishContent(ctx.db, id, publishedAt, now)
|
|
1933
2077
|
await createRevision(ctx.db, id, ctx.userId || null)
|
|
1934
2078
|
} catch (error) {
|
|
1935
2079
|
if (error instanceof TagInputError) return tagInputErrorResponse(error)
|
|
@@ -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}`
|