@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.
Files changed (69) hide show
  1. package/dist/engine/foundry-dispatch.d.ts.map +1 -1
  2. package/dist/engine/foundry-dispatch.js +13 -4
  3. package/dist/engine/foundry-dispatch.js.map +1 -1
  4. package/dist/engine/fronts-publish-intent.d.ts +20 -3
  5. package/dist/engine/fronts-publish-intent.d.ts.map +1 -1
  6. package/dist/engine/fronts-publish-intent.js +25 -11
  7. package/dist/engine/fronts-publish-intent.js.map +1 -1
  8. package/dist/engine/fronts-publish.d.ts.map +1 -1
  9. package/dist/engine/fronts-publish.js +44 -8
  10. package/dist/engine/fronts-publish.js.map +1 -1
  11. package/dist/engine/google-drive-source.d.ts +27 -0
  12. package/dist/engine/google-drive-source.d.ts.map +1 -0
  13. package/dist/engine/google-drive-source.js +87 -0
  14. package/dist/engine/google-drive-source.js.map +1 -0
  15. package/dist/engine/index.d.ts +1 -0
  16. package/dist/engine/index.d.ts.map +1 -1
  17. package/dist/engine/index.js +1 -0
  18. package/dist/engine/index.js.map +1 -1
  19. package/dist/engine/publisher.d.ts +3 -1
  20. package/dist/engine/publisher.d.ts.map +1 -1
  21. package/dist/engine/publisher.js +21 -11
  22. package/dist/engine/publisher.js.map +1 -1
  23. package/dist/migration-vendor.d.ts.map +1 -1
  24. package/dist/migration-vendor.js +140 -12
  25. package/dist/migration-vendor.js.map +1 -1
  26. package/dist/providers/types.d.ts +6 -1
  27. package/dist/providers/types.d.ts.map +1 -1
  28. package/dist/providers/types.js.map +1 -1
  29. package/dist/routes/content.d.ts.map +1 -1
  30. package/dist/routes/content.js +191 -8
  31. package/dist/routes/content.js.map +1 -1
  32. package/dist/ui/editor/ContentForm.d.ts.map +1 -1
  33. package/dist/ui/editor/ContentForm.js +2 -2
  34. package/dist/ui/editor/ContentForm.js.map +1 -1
  35. package/dist/ui/editor/editor-media-upload.d.ts.map +1 -1
  36. package/dist/ui/editor/editor-media-upload.js +2 -1
  37. package/dist/ui/editor/editor-media-upload.js.map +1 -1
  38. package/dist/ui/inspector/PublishTab.d.ts.map +1 -1
  39. package/dist/ui/inspector/PublishTab.js +2 -1
  40. package/dist/ui/inspector/PublishTab.js.map +1 -1
  41. package/dist/ui/screens/LibraryScreen.d.ts.map +1 -1
  42. package/dist/ui/screens/LibraryScreen.js +4 -3
  43. package/dist/ui/screens/LibraryScreen.js.map +1 -1
  44. package/dist/ui/screens/SettingsScreen.d.ts.map +1 -1
  45. package/dist/ui/screens/SettingsScreen.js +14 -13
  46. package/dist/ui/screens/SettingsScreen.js.map +1 -1
  47. package/dist/ui/screens/TopicsScreen.d.ts.map +1 -1
  48. package/dist/ui/screens/TopicsScreen.js +3 -2
  49. package/dist/ui/screens/TopicsScreen.js.map +1 -1
  50. package/dist/ui/screens/author-recovery.d.ts.map +1 -1
  51. package/dist/ui/screens/author-recovery.js +2 -1
  52. package/dist/ui/screens/author-recovery.js.map +1 -1
  53. package/package.json +1 -1
  54. package/src/engine/foundry-dispatch.ts +13 -4
  55. package/src/engine/fronts-publish-intent.ts +50 -15
  56. package/src/engine/fronts-publish.ts +45 -6
  57. package/src/engine/google-drive-source.ts +99 -0
  58. package/src/engine/index.ts +1 -0
  59. package/src/engine/publisher.ts +24 -11
  60. package/src/migration-vendor.ts +156 -14
  61. package/src/providers/types.ts +6 -1
  62. package/src/routes/content.ts +209 -7
  63. package/src/ui/editor/ContentForm.tsx +5 -1
  64. package/src/ui/editor/editor-media-upload.ts +2 -1
  65. package/src/ui/inspector/PublishTab.tsx +2 -1
  66. package/src/ui/screens/LibraryScreen.tsx +4 -3
  67. package/src/ui/screens/SettingsScreen.tsx +14 -13
  68. package/src/ui/screens/TopicsScreen.tsx +3 -2
  69. package/src/ui/screens/author-recovery.ts +3 -1
@@ -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
- const MediaSourceUrlSchema = z.string().url().transform(normaliseNextcloudShareUrl)
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: MediaSourceUrlSchema.optional().nullable(),
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: MediaSourceUrlSchema.optional().nullable(),
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: MediaSourceUrlSchema.optional().nullable(),
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: MediaSourceUrlSchema.optional().nullable(),
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(await loadStoredMediaSource(ctx.db, id, item.type))
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.error ?? 'Save failed',
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.error ?? 'Image upload failed')
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.error ?? 'Could not add author')
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.error ?? res.statusText}`)
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.error ?? 'Parse failed')
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.error ?? 'Confirm failed')
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.error ?? `HTTP ${res.status}` })
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.error ?? `Failed to change role (HTTP ${res.status})`)
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.error ?? `HTTP ${res.status}`)
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.error ?? `HTTP ${res.status}` })
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.error ?? `HTTP ${res.status}`)
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.error ?? `Failed to revoke (HTTP ${res.status})`)
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.error ?? `HTTP ${res.status}` })
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.error ?? `HTTP ${res.status}`)
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.error ?? `Test failed (HTTP ${res.status})`)
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.error ?? `Failed to delete (HTTP ${res.status})`)
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.error ?? `HTTP ${res.status}`)
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.error ?? `HTTP ${res.status}`)
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.error ?? `Failed to save settings (HTTP ${res.status})`)
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.error || `${res.status} ${res.statusText}`)
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.error || `${res.status} ${res.statusText}`)
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.error || 'Author synchronization failed'
22
+ const message = apiErrorMessage(body, 'Author synchronization failed')
21
23
  const classifiedMessage = message.includes(body.errorClass)
22
24
  ? message
23
25
  : `${body.errorClass}: ${message}`