@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/dist/routes/content.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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) {
|