@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
@@ -21,6 +21,7 @@ import { ContentMetadataError, serializeContentMetadata, } from '../engine/conte
21
21
  import { listContributors, setContributors } from '../engine/contributors.js';
22
22
  import { applyFoundryCallback, dispatchToFoundry, FoundryDispatchPrerequisiteError, verifyHmac, } from '../engine/foundry-dispatch.js';
23
23
  import { parseFrontsChannelTargets, parseFrontsDefaultChannelTargets, setFrontsChannelTargets, } from '../engine/fronts-publish-intent.js';
24
+ import { classifyGoogleDriveUrl } from '../engine/google-drive-source.js';
24
25
  import { evaluateArticleBody } from '../engine/publish-guard.js';
25
26
  import { createContent, createRevision, duplicateContentItem, evaluateContentBodyForPublish, getContentItem, getContentRelations, getContentTagPairs, publishContent, scheduleContent, unpublishContent, updateContentItem, VerifiedMediaDurationConflictError, } from '../engine/publisher.js';
26
27
  import { createRevisionWithDelta, getRevision, listRevisions, restoreRevision, } from '../engine/revisions.js';
@@ -250,7 +251,32 @@ export function normaliseNextcloudShareUrl(value) {
250
251
  }
251
252
  return url.toString();
252
253
  }
253
- const MediaSourceUrlSchema = z.string().url().transform(normaliseNextcloudShareUrl);
254
+ // A Google Drive link is not run through the Nextcloud rewrite; it is kept as
255
+ // the editor pasted it. The podcast schema accepts a valid Drive file link
256
+ // (the Go plane fetches and measures it itself) and turns any other
257
+ // drive.google.com / docs.google.com URL -- a Doc, a folder, a link with no
258
+ // file id -- into a field-level issue, because stored as a plain https URL it
259
+ // is an HTML page. The video schema refuses every Drive link: the video path
260
+ // ingests over plain https and would fetch the viewer page.
261
+ const PodcastSourceUrlSchema = z
262
+ .string()
263
+ .url()
264
+ .superRefine((value, ctx) => {
265
+ const link = classifyGoogleDriveUrl(value);
266
+ if (link.kind === 'drive_invalid')
267
+ ctx.addIssue({ code: 'custom', message: link.reason });
268
+ })
269
+ .transform(normaliseNextcloudShareUrl);
270
+ const VIDEO_DRIVE_SOURCE_UNSUPPORTED = 'Google Drive links are not yet supported for video; share a direct download link';
271
+ const VideoSourceUrlSchema = z
272
+ .string()
273
+ .url()
274
+ .superRefine((value, ctx) => {
275
+ if (classifyGoogleDriveUrl(value).kind !== 'not_drive') {
276
+ ctx.addIssue({ code: 'custom', message: VIDEO_DRIVE_SOURCE_UNSUPPORTED });
277
+ }
278
+ })
279
+ .transform(normaliseNextcloudShareUrl);
254
280
  function buildSchemas(resolved) {
255
281
  const category = slugEnum(resolved.primaryCategorySlugs);
256
282
  const topic = slugEnum(resolved.primaryTopicSlugs);
@@ -289,7 +315,7 @@ function buildSchemas(resolved) {
289
315
  videoId: z.string().min(1),
290
316
  durationSeconds: z.number().int().positive().optional().nullable(),
291
317
  thumbnailImageId: z.string().optional().nullable(),
292
- processingSourceUrl: MediaSourceUrlSchema.optional().nullable(),
318
+ processingSourceUrl: VideoSourceUrlSchema.optional().nullable(),
293
319
  processingSourceKind: z.string().min(1).optional().nullable(),
294
320
  }),
295
321
  }),
@@ -309,7 +335,7 @@ function buildSchemas(resolved) {
309
335
  // there because a video always has a slug-derived identity up front.
310
336
  audioR2Key: z.string().default(''),
311
337
  durationSeconds: z.number().int().positive().optional().nullable(),
312
- processingSourceUrl: MediaSourceUrlSchema.optional().nullable(),
338
+ processingSourceUrl: PodcastSourceUrlSchema.optional().nullable(),
313
339
  processingSourceKind: z.string().min(1).optional().nullable(),
314
340
  }),
315
341
  }),
@@ -369,14 +395,14 @@ const VideoContentUpdateSchema = z.object({
369
395
  videoId: z.string().optional(),
370
396
  durationSeconds: z.number().int().positive().optional().nullable(),
371
397
  thumbnailImageId: z.string().optional().nullable(),
372
- processingSourceUrl: MediaSourceUrlSchema.optional().nullable(),
398
+ processingSourceUrl: VideoSourceUrlSchema.optional().nullable(),
373
399
  processingSourceKind: z.string().min(1).optional().nullable(),
374
400
  });
375
401
  const PodcastContentUpdateSchema = z.object({
376
402
  transcript: z.string().optional(),
377
403
  audioR2Key: z.string().optional(),
378
404
  durationSeconds: z.number().int().positive().optional().nullable(),
379
- processingSourceUrl: MediaSourceUrlSchema.optional().nullable(),
405
+ processingSourceUrl: PodcastSourceUrlSchema.optional().nullable(),
380
406
  processingSourceKind: z.string().min(1).optional().nullable(),
381
407
  });
382
408
  function getContentUpdateSchema(type) {
@@ -672,6 +698,22 @@ export function createContentRoutes(config) {
672
698
  content.durationSeconds = null;
673
699
  return null;
674
700
  }
701
+ // A Drive podcast source is fetched and measured by the Go plane, not
702
+ // probed here: keep the editor's URL as pasted, store no duration and let
703
+ // the host's probe stay out of it (it would fetch the HTML viewer page).
704
+ if (type === 'podcast' && classifyGoogleDriveUrl(requestedUrl).kind === 'drive') {
705
+ content.processingSourceUrl = requestedUrl;
706
+ content.processingSourceKind = 'https';
707
+ content.durationSeconds = null;
708
+ return {
709
+ requestedUrl,
710
+ resolvedUrl: requestedUrl,
711
+ sourceKind: 'https',
712
+ durationSeconds: null,
713
+ ready: true,
714
+ blockers: [],
715
+ };
716
+ }
675
717
  const storedDuration = positiveIntegerDuration(stored?.durationSeconds);
676
718
  if (stored?.sourceUrl === requestedUrl && storedDuration !== null) {
677
719
  content.processingSourceUrl = stored.sourceUrl;
@@ -754,12 +796,22 @@ export function createContentRoutes(config) {
754
796
  blockers: durationSeconds === null && blockers.length === 0 ? ['duration_probe_failed'] : blockers,
755
797
  };
756
798
  }
757
- function mediaSourceReadinessFromStored(stored) {
799
+ function mediaSourceReadinessFromStored(type, stored) {
758
800
  if (!stored)
759
801
  return null;
760
802
  const sourceUrl = stored.sourceUrl?.trim();
761
803
  if (!sourceUrl)
762
804
  return null;
805
+ if (type === 'podcast' && classifyGoogleDriveUrl(sourceUrl).kind === 'drive') {
806
+ return {
807
+ requestedUrl: sourceUrl,
808
+ resolvedUrl: sourceUrl,
809
+ sourceKind: 'https',
810
+ durationSeconds: null,
811
+ ready: true,
812
+ blockers: [],
813
+ };
814
+ }
763
815
  const durationSeconds = positiveIntegerDuration(stored.durationSeconds);
764
816
  return {
765
817
  requestedUrl: sourceUrl,
@@ -804,6 +856,64 @@ export function createContentRoutes(config) {
804
856
  return { result: null, reason: 'dispatch_failed' };
805
857
  }
806
858
  }
859
+ /**
860
+ * The video sibling of maybeDispatchPodcastToFoundry: capture a sourced video
861
+ * that holds no capture. A row with no trigger token is invisible to the
862
+ * producer whatever its state says (trash and restore clears the token and
863
+ * leaves `queued`), so only `ready` media and `failed` — the editor's Re-run
864
+ * decision — are left alone. An archived item is inactive to the dispatcher;
865
+ * it is captured once scheduling has made it active again.
866
+ * The editor form captures after a save too, but it de-dupes on local state; a
867
+ * later save that reset the capture left the video scheduled and never
868
+ * prepared, with nothing to notice (Fronts, 2026-09-15..17).
869
+ */
870
+ async function maybeCaptureVideoSource(ctx, contentId) {
871
+ const hook = resolved.hooks.foundryVideo;
872
+ if (!hook)
873
+ return null;
874
+ const readCapture = () => ctx.db
875
+ .prepare(`SELECT vc.processing_source_url, vc.processing_trigger_token, vc.processing_state,
876
+ vc.hls_ready, ci.status
877
+ FROM video_content vc JOIN content_items ci ON ci.id = vc.content_id
878
+ WHERE vc.content_id = ?
879
+ LIMIT 1`)
880
+ .bind(contentId)
881
+ .first();
882
+ const row = await readCapture();
883
+ if (!row?.processing_source_url?.trim())
884
+ return null;
885
+ if (row.processing_trigger_token?.trim())
886
+ return null;
887
+ if (row.status === 'archived')
888
+ return null;
889
+ if (row.hls_ready === 1)
890
+ return null;
891
+ if (row.processing_state === 'ready' || row.processing_state === 'failed')
892
+ return null;
893
+ try {
894
+ return await dispatchToFoundry(ctx.db, contentId, hook, { ifUncaptured: true });
895
+ }
896
+ catch (error) {
897
+ // The editor form fires its own capture after every save. Losing that
898
+ // race fails this call's compare-and-set, and the capture exists.
899
+ const raced = await readCapture();
900
+ if (raced?.processing_trigger_token?.trim())
901
+ return null;
902
+ throw error;
903
+ }
904
+ }
905
+ async function captureScheduledVideoAfterSave(ctx, contentId) {
906
+ try {
907
+ return { result: await maybeCaptureVideoSource(ctx, contentId), reason: null };
908
+ }
909
+ catch (error) {
910
+ if (error instanceof FoundryDispatchPrerequisiteError) {
911
+ return { result: null, reason: 'missing_duration' };
912
+ }
913
+ console.error('Scheduled video source capture failed after content save', error);
914
+ return { result: null, reason: 'dispatch_failed' };
915
+ }
916
+ }
807
917
  function missingDurationResponse() {
808
918
  return json({
809
919
  error: 'Content requires a positive finite duration before dispatch',
@@ -1027,6 +1137,31 @@ export function createContentRoutes(config) {
1027
1137
  const publishAtSeconds = Math.floor(publishAtMs / 1000);
1028
1138
  const expectedTargets = new Map();
1029
1139
  for (const id of ids) {
1140
+ // Same gate as the single-item path: no item in a bulk schedule
1141
+ // may take a slot with a body publish will refuse.
1142
+ const bulkGuard = await evaluateContentBodyForPublish(ctx.db, id, 'cms.schedule', {
1143
+ requireBody: false,
1144
+ });
1145
+ if (bulkGuard && !bulkGuard.ok) {
1146
+ return json({
1147
+ error: 'Article body failed pre-publish validation',
1148
+ source: 'cms.schedule',
1149
+ contentId: id,
1150
+ errors: bulkGuard.errors,
1151
+ }, 422);
1152
+ }
1153
+ // And the same capture guarantee: no sourced video in a bulk
1154
+ // schedule may take a slot the producer cannot see.
1155
+ try {
1156
+ await maybeCaptureVideoSource(ctx, id);
1157
+ }
1158
+ catch (error) {
1159
+ if (error instanceof FoundryDispatchPrerequisiteError) {
1160
+ return missingDurationResponse();
1161
+ }
1162
+ console.error('Video source capture failed before bulk schedule', error);
1163
+ return json({ error: 'Foundry dispatch failed', contentId: id }, 502);
1164
+ }
1030
1165
  const prepared = await prepareScheduleTargets(ctx, id);
1031
1166
  if ('error' in prepared)
1032
1167
  return prepared.error;
@@ -1039,6 +1174,8 @@ export function createContentRoutes(config) {
1039
1174
  if (targets !== undefined && !changed) {
1040
1175
  return json({ error: 'schedule_targets_changed', contentId: id, scheduled }, 409);
1041
1176
  }
1177
+ // Covers an item that was archived above, now active again.
1178
+ await captureScheduledVideoAfterSave(ctx, id);
1042
1179
  scheduled.push(id);
1043
1180
  }
1044
1181
  return json({ scheduled });
@@ -1162,7 +1299,7 @@ export function createContentRoutes(config) {
1162
1299
  }
1163
1300
  : content;
1164
1301
  const mediaSourceReadiness = item.type === 'video' || item.type === 'podcast'
1165
- ? mediaSourceReadinessFromStored(await loadStoredMediaSource(ctx.db, id, item.type))
1302
+ ? mediaSourceReadinessFromStored(item.type, await loadStoredMediaSource(ctx.db, id, item.type))
1166
1303
  : null;
1167
1304
  const aiLockedFields = (() => {
1168
1305
  try {
@@ -1307,6 +1444,11 @@ export function createContentRoutes(config) {
1307
1444
  const podcastAttempt = existing.type === 'podcast'
1308
1445
  ? await dispatchPodcastAfterSave(ctx, id)
1309
1446
  : { result: null, reason: null };
1447
+ // A scheduled video is committed to a slot: a save that reset its capture
1448
+ // must not leave it unprepared until someone notices the missed publish.
1449
+ const videoAttempt = existing.type === 'video' && existing.status === 'scheduled'
1450
+ ? await captureScheduledVideoAfterSave(ctx, id)
1451
+ : { result: null, reason: null };
1310
1452
  // If the slug changed, record the old slug as a redirect (spec §8)
1311
1453
  // and drop any stale redirect row for the now-live slug — atomically,
1312
1454
  // so a live slug is never also a redirect source (rename-back safety).
@@ -1319,6 +1461,8 @@ export function createContentRoutes(config) {
1319
1461
  podcastFoundryQueued: Boolean(podcastAttempt.result),
1320
1462
  podcastFoundryCorrelationId: podcastAttempt.result?.correlationId ?? null,
1321
1463
  podcastFoundryReason: podcastAttempt.reason,
1464
+ videoFoundryQueued: Boolean(videoAttempt.result && !videoAttempt.result.skipped),
1465
+ videoFoundryReason: videoAttempt.reason,
1322
1466
  ...(mediaSourceReadiness ? { mediaSourceReadiness } : {}),
1323
1467
  // Present and empty when the body linted clean, so a client can
1324
1468
  // distinguish "no warnings" from "this build does not report them".
@@ -1369,6 +1513,39 @@ export function createContentRoutes(config) {
1369
1513
  if (!Number.isFinite(publishAtSeconds)) {
1370
1514
  return json({ error: 'Missing publish time' }, 400);
1371
1515
  }
1516
+ // A body the publish guard will refuse must not be allowed to take a
1517
+ // slot. Scheduling was the one write in this lane that never ran
1518
+ // the guard, so a defect an editor could not see at save time
1519
+ // surfaced only when foundryd tried to publish — at 16:30 on a
1520
+ // Monday, as a missed premium article (2026-09-15).
1521
+ const scheduleGuard = await evaluateContentBodyForPublish(ctx.db, id, 'cms.schedule', {
1522
+ // Claiming a slot before the piece is written stays allowed;
1523
+ // claiming one with an already-broken body does not.
1524
+ requireBody: false,
1525
+ });
1526
+ if (scheduleGuard && !scheduleGuard.ok) {
1527
+ return json({
1528
+ error: 'Article body failed pre-publish validation',
1529
+ source: 'cms.schedule',
1530
+ errors: scheduleGuard.errors,
1531
+ }, 422);
1532
+ }
1533
+ // A sourced video that holds no capture is invisible to the producer:
1534
+ // it would take the slot and never publish. Capture it here, and refuse
1535
+ // the slot if that cannot be done. A video with no source yet may still
1536
+ // claim a slot early.
1537
+ if (existing.type === 'video') {
1538
+ try {
1539
+ await maybeCaptureVideoSource(ctx, id);
1540
+ }
1541
+ catch (error) {
1542
+ if (error instanceof FoundryDispatchPrerequisiteError) {
1543
+ return missingDurationResponse();
1544
+ }
1545
+ console.error('Video source capture failed before schedule', error);
1546
+ return json({ error: 'Foundry dispatch failed' }, 502);
1547
+ }
1548
+ }
1372
1549
  const prepared = await prepareScheduleTargets(ctx, id);
1373
1550
  if ('error' in prepared)
1374
1551
  return prepared.error;
@@ -1376,6 +1553,9 @@ export function createContentRoutes(config) {
1376
1553
  if (prepared.targets !== undefined && !changed) {
1377
1554
  return json({ error: 'schedule_targets_changed', contentId: id }, 409);
1378
1555
  }
1556
+ // Covers an item that was archived above, now active again.
1557
+ if (existing.type === 'video')
1558
+ await captureScheduledVideoAfterSave(ctx, id);
1379
1559
  // Fire content.scheduled webhook.
1380
1560
  await fireWebhook(ctx, 'content.scheduled', {
1381
1561
  id,
@@ -1388,6 +1568,9 @@ export function createContentRoutes(config) {
1388
1568
  if (action === 'publish') {
1389
1569
  const now = Math.floor(Date.now() / 1000);
1390
1570
  const wasDraft = existing.status === 'draft';
1571
+ const publishedAt = existing.status === 'published' && existing.published_at !== null
1572
+ ? existing.published_at
1573
+ : now;
1391
1574
  let podcastDispatch = null;
1392
1575
  if (existing.type === 'podcast') {
1393
1576
  try {
@@ -1483,7 +1666,7 @@ export function createContentRoutes(config) {
1483
1666
  throw error;
1484
1667
  }
1485
1668
  try {
1486
- await publishContent(ctx.db, id, now);
1669
+ await publishContent(ctx.db, id, publishedAt, now);
1487
1670
  await createRevision(ctx.db, id, ctx.userId || null);
1488
1671
  }
1489
1672
  catch (error) {