@growth-labs/cms 0.3.1 → 0.5.0

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 (84) hide show
  1. package/README.md +80 -6
  2. package/dist/engine/content-metadata.d.ts +13 -0
  3. package/dist/engine/content-metadata.d.ts.map +1 -0
  4. package/dist/engine/content-metadata.js +80 -0
  5. package/dist/engine/content-metadata.js.map +1 -0
  6. package/dist/engine/foundry-dispatch.d.ts +5 -1
  7. package/dist/engine/foundry-dispatch.d.ts.map +1 -1
  8. package/dist/engine/foundry-dispatch.js +19 -3
  9. package/dist/engine/foundry-dispatch.js.map +1 -1
  10. package/dist/engine/index.d.ts +4 -2
  11. package/dist/engine/index.d.ts.map +1 -1
  12. package/dist/engine/index.js +6 -2
  13. package/dist/engine/index.js.map +1 -1
  14. package/dist/engine/publication.d.ts +29 -1
  15. package/dist/engine/publication.d.ts.map +1 -1
  16. package/dist/engine/publication.js +197 -15
  17. package/dist/engine/publication.js.map +1 -1
  18. package/dist/engine/published-content.d.ts +40 -0
  19. package/dist/engine/published-content.d.ts.map +1 -0
  20. package/dist/engine/published-content.js +388 -0
  21. package/dist/engine/published-content.js.map +1 -0
  22. package/dist/engine/publisher.d.ts +20 -1
  23. package/dist/engine/publisher.d.ts.map +1 -1
  24. package/dist/engine/publisher.js +264 -65
  25. package/dist/engine/publisher.js.map +1 -1
  26. package/dist/engine/revisions.d.ts.map +1 -1
  27. package/dist/engine/revisions.js +2 -0
  28. package/dist/engine/revisions.js.map +1 -1
  29. package/dist/providers/types.d.ts +1 -0
  30. package/dist/providers/types.d.ts.map +1 -1
  31. package/dist/routes/content.d.ts.map +1 -1
  32. package/dist/routes/content.js +76 -16
  33. package/dist/routes/content.js.map +1 -1
  34. package/dist/routes/context.d.ts +1 -0
  35. package/dist/routes/context.d.ts.map +1 -1
  36. package/dist/routes/context.js.map +1 -1
  37. package/dist/routes/media.d.ts.map +1 -1
  38. package/dist/routes/media.js +53 -31
  39. package/dist/routes/media.js.map +1 -1
  40. package/dist/schema/migrations.d.ts.map +1 -1
  41. package/dist/schema/migrations.js +24 -0
  42. package/dist/schema/migrations.js.map +1 -1
  43. package/dist/schema/types.d.ts +6 -0
  44. package/dist/schema/types.d.ts.map +1 -1
  45. package/dist/schema/types.js.map +1 -1
  46. package/dist/ui/editor/ContentForm.d.ts.map +1 -1
  47. package/dist/ui/editor/ContentForm.js +65 -10
  48. package/dist/ui/editor/ContentForm.js.map +1 -1
  49. package/dist/ui/editor/content-payload.d.ts +2 -0
  50. package/dist/ui/editor/content-payload.d.ts.map +1 -1
  51. package/dist/ui/editor/content-payload.js +15 -4
  52. package/dist/ui/editor/content-payload.js.map +1 -1
  53. package/dist/ui/editor/editor-media-duration.d.ts +24 -0
  54. package/dist/ui/editor/editor-media-duration.d.ts.map +1 -0
  55. package/dist/ui/editor/editor-media-duration.js +51 -0
  56. package/dist/ui/editor/editor-media-duration.js.map +1 -0
  57. package/dist/ui/screens/MediaScreen.d.ts.map +1 -1
  58. package/dist/ui/screens/MediaScreen.js +8 -1
  59. package/dist/ui/screens/MediaScreen.js.map +1 -1
  60. package/dist/ui/screens/media-upload.d.ts +9 -2
  61. package/dist/ui/screens/media-upload.d.ts.map +1 -1
  62. package/dist/ui/screens/media-upload.js +24 -0
  63. package/dist/ui/screens/media-upload.js.map +1 -1
  64. package/migrations/0020_content_metadata_read_model.sql +19 -0
  65. package/migrations/0021_published_revision_slug_index.sql +5 -0
  66. package/package.json +1 -1
  67. package/src/engine/content-metadata.ts +122 -0
  68. package/src/engine/foundry-dispatch.ts +32 -4
  69. package/src/engine/index.ts +27 -0
  70. package/src/engine/publication.ts +329 -18
  71. package/src/engine/published-content.ts +581 -0
  72. package/src/engine/publisher.ts +403 -107
  73. package/src/engine/revisions.ts +4 -0
  74. package/src/providers/types.ts +1 -0
  75. package/src/routes/content.ts +91 -16
  76. package/src/routes/context.ts +1 -0
  77. package/src/routes/media.ts +63 -41
  78. package/src/schema/migrations.ts +24 -0
  79. package/src/schema/types.ts +6 -0
  80. package/src/ui/editor/ContentForm.tsx +121 -4
  81. package/src/ui/editor/content-payload.ts +20 -2
  82. package/src/ui/editor/editor-media-duration.ts +75 -0
  83. package/src/ui/screens/MediaScreen.tsx +13 -2
  84. package/src/ui/screens/media-upload.ts +41 -2
@@ -20,6 +20,7 @@
20
20
  // duplicate is NOT re-queued to Foundry. Any future `content_items` column
21
21
  // addition requires a matching edit here or the duplicate silently drops it.
22
22
  import type { ContentType } from '../schema/types.js'
23
+ import { type ContentMetadata, serializeContentMetadata } from './content-metadata.js'
23
24
  import type { D1Database } from './d1.js'
24
25
  import { evaluateArticleBody, type PublishGuardOutcome } from './publish-guard.js'
25
26
  import { slugify } from './slug.js'
@@ -95,6 +96,8 @@ export interface BaseContentInput {
95
96
  tags?: string[]
96
97
  /** The SEO focus keyword for this content item (migration 0004 column). */
97
98
  seoFocusKeyword?: string | null
99
+ /** Declared site-specific JSON values retained in every immutable revision. */
100
+ metadata?: ContentMetadata
98
101
  }
99
102
 
100
103
  export interface ArticleInput extends BaseContentInput {
@@ -154,12 +157,23 @@ export interface PageInput extends BaseContentInput {
154
157
  }
155
158
  }
156
159
 
157
- export type CreateContentInput =
160
+ export type CreateContentInput = (
158
161
  | ArticleInput
159
162
  | VideoInput
160
163
  | PodcastInput
161
164
  | NewsletterInput
162
165
  | PageInput
166
+ ) & {
167
+ /** Optional namespaced archive key. New copies deliberately do not inherit it. */
168
+ sourceId?: string | null
169
+ /** Initial historical values for archive ingestion; normal creates omit this. */
170
+ initialTimestamps?: {
171
+ publishedAt?: number | null
172
+ createdAt?: number | null
173
+ updatedAt?: number | null
174
+ publishTz?: string | null
175
+ }
176
+ }
163
177
 
164
178
  type BodyBackedContentInput = ArticleInput | NewsletterInput | PageInput
165
179
 
@@ -167,6 +181,118 @@ function isBodyBackedContentInput(input: CreateContentInput): input is BodyBacke
167
181
  return isBodyBackedContentType(input.type)
168
182
  }
169
183
 
184
+ function assertValidMediaDuration(value: unknown): asserts value is number | null | undefined {
185
+ if (value === null || value === undefined) return
186
+ if (
187
+ typeof value !== 'number' ||
188
+ !Number.isFinite(value) ||
189
+ !Number.isInteger(value) ||
190
+ value <= 0
191
+ ) {
192
+ throw new Error('Media duration must be a positive integer when supplied')
193
+ }
194
+ }
195
+
196
+ export class VerifiedMediaDurationConflictError extends Error {
197
+ readonly code = 'verified_duration_conflict'
198
+
199
+ constructor() {
200
+ super('Media duration conflicts with verified upload duration')
201
+ this.name = 'VerifiedMediaDurationConflictError'
202
+ }
203
+ }
204
+
205
+ type VerifiedMediaKind = 'video' | 'podcast'
206
+
207
+ function parseVerifiedMediaDuration(variantsJson: string | null): number | null {
208
+ if (!variantsJson) return null
209
+ try {
210
+ const parsed: unknown = JSON.parse(variantsJson)
211
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
212
+ const durationSeconds = (parsed as Record<string, unknown>).durationSeconds
213
+ return typeof durationSeconds === 'number' &&
214
+ Number.isFinite(durationSeconds) &&
215
+ Number.isInteger(durationSeconds) &&
216
+ durationSeconds > 0
217
+ ? durationSeconds
218
+ : null
219
+ } catch {
220
+ return null
221
+ }
222
+ }
223
+
224
+ async function loadVerifiedMediaDuration(
225
+ db: D1Database,
226
+ kind: VerifiedMediaKind,
227
+ source: string | null | undefined,
228
+ ): Promise<number | null> {
229
+ if (!source) return null
230
+
231
+ const isHttpUrl = /^https?:\/\//i.test(source)
232
+ const statement =
233
+ kind === 'video'
234
+ ? isHttpUrl
235
+ ? db
236
+ .prepare(
237
+ `SELECT variants_json
238
+ FROM media_assets
239
+ WHERE asset_type = 'video'
240
+ AND source_type = 'editor-video-source'
241
+ AND (url = ? OR public_url = ?)`,
242
+ )
243
+ .bind(source, source)
244
+ : db
245
+ .prepare(
246
+ `SELECT variants_json
247
+ FROM media_assets
248
+ WHERE asset_type = 'video'
249
+ AND source_type = 'editor-video-source'
250
+ AND (id = ? OR object_key = ?)`,
251
+ )
252
+ .bind(source, source)
253
+ : db
254
+ .prepare(
255
+ `SELECT variants_json
256
+ FROM media_assets
257
+ WHERE asset_type = 'audio'
258
+ AND source_type = 'editor-upload'
259
+ AND (id = ? OR object_key = ?)`,
260
+ )
261
+ .bind(source, source)
262
+
263
+ const rows = await statement.all<{ variants_json: string | null }>()
264
+ const durations = new Set<number>()
265
+ for (const row of rows.results ?? []) {
266
+ const duration = parseVerifiedMediaDuration(row.variants_json)
267
+ if (duration !== null) durations.add(duration)
268
+ }
269
+
270
+ if (durations.size > 1) {
271
+ // Multiple exact package-owned identities disagreeing is unsafe to guess.
272
+ throw new VerifiedMediaDurationConflictError()
273
+ }
274
+ return durations.values().next().value ?? null
275
+ }
276
+
277
+ async function resolveMediaDuration(
278
+ db: D1Database,
279
+ kind: VerifiedMediaKind,
280
+ source: string | null | undefined,
281
+ suppliedDuration: unknown,
282
+ ): Promise<number | null> {
283
+ assertValidMediaDuration(suppliedDuration)
284
+ const verifiedDuration = await loadVerifiedMediaDuration(db, kind, source)
285
+ if (verifiedDuration === null) return suppliedDuration ?? null
286
+ if (
287
+ suppliedDuration !== null &&
288
+ suppliedDuration !== undefined &&
289
+ suppliedDuration !== verifiedDuration
290
+ ) {
291
+ throw new VerifiedMediaDurationConflictError()
292
+ }
293
+ return verifiedDuration
294
+ }
295
+
170
296
  export interface UpdateContentInput extends Partial<BaseContentInput> {
171
297
  content?: Partial<
172
298
  | ArticleInput['content']
@@ -183,6 +309,23 @@ export interface UpdateContentInput extends Partial<BaseContentInput> {
183
309
  aiLockedFields?: string[]
184
310
  }
185
311
 
312
+ interface VideoUpdatePlan {
313
+ script: string
314
+ videoId: string
315
+ durationSeconds: number | null
316
+ thumbnailImageId: string | null
317
+ sourceUrl: string | null
318
+ sourceKind: string | null
319
+ sourceChanged: boolean
320
+ }
321
+
322
+ interface PodcastUpdatePlan {
323
+ transcript: string
324
+ audioR2Key: string
325
+ durationSeconds: number | null
326
+ processingTriggerToken: string | null
327
+ }
328
+
186
329
  /**
187
330
  * The shape `getContentItem` returns from `SELECT * FROM content_items`.
188
331
  * Note this is the engine's read-side row (nullable `featured`/timestamps as
@@ -215,10 +358,57 @@ export interface ContentItemRecord {
215
358
  ai_locked_fields: string | null
216
359
  published_revision_id: string | null
217
360
  seo_focus_keyword: string | null
361
+ metadata_json: string
362
+ source_id: string | null
218
363
  created_at: number | null
219
364
  updated_at: number | null
220
365
  }
221
366
 
367
+ function normalizeSourceId(value: unknown): string | null {
368
+ if (value === null || value === undefined) return null
369
+ if (typeof value !== 'string' || !value || value !== value.trim() || value.length > 256) {
370
+ throw new TypeError('sourceId must be a trimmed non-empty string of at most 256 characters')
371
+ }
372
+ return value
373
+ }
374
+
375
+ function normalizeInitialTimestamps(value: CreateContentInput['initialTimestamps']): {
376
+ publishedAt: number | null
377
+ createdAt: number | null
378
+ updatedAt: number | null
379
+ publishTz: string | null
380
+ } {
381
+ const timestamps = value ?? {}
382
+ for (const [field, candidate] of [
383
+ ['publishedAt', timestamps.publishedAt],
384
+ ['createdAt', timestamps.createdAt],
385
+ ['updatedAt', timestamps.updatedAt],
386
+ ] as const) {
387
+ if (candidate !== null && candidate !== undefined) {
388
+ if (!Number.isSafeInteger(candidate) || candidate < 0) {
389
+ throw new TypeError(`${field} must be a non-negative epoch second`)
390
+ }
391
+ }
392
+ }
393
+ const publishTz = timestamps.publishTz
394
+ if (
395
+ publishTz !== null &&
396
+ publishTz !== undefined &&
397
+ (typeof publishTz !== 'string' ||
398
+ !publishTz.trim() ||
399
+ publishTz !== publishTz.trim() ||
400
+ publishTz.length > 64)
401
+ ) {
402
+ throw new TypeError('publishTz must be a trimmed non-empty string of at most 64 characters')
403
+ }
404
+ return {
405
+ publishedAt: timestamps.publishedAt ?? null,
406
+ createdAt: timestamps.createdAt ?? null,
407
+ updatedAt: timestamps.updatedAt ?? null,
408
+ publishTz: publishTz ?? null,
409
+ }
410
+ }
411
+
222
412
  export async function ensureUniqueSlug(
223
413
  db: D1Database,
224
414
  slug: string,
@@ -229,12 +419,28 @@ export async function ensureUniqueSlug(
229
419
  let suffix = 2
230
420
 
231
421
  while (true) {
232
- const row = await db
422
+ const draftRow = await db
233
423
  .prepare('SELECT id FROM content_items WHERE slug = ? LIMIT 1')
234
424
  .bind(candidate)
235
425
  .first<{ id: string }>()
426
+ const publishedRow = await db
427
+ .prepare(
428
+ `SELECT ci.id
429
+ FROM content_items ci
430
+ JOIN content_revisions cr
431
+ ON cr.id = ci.published_revision_id AND cr.content_id = ci.id
432
+ WHERE ci.status = 'published'
433
+ AND ci.deleted_at IS NULL
434
+ AND json_extract(cr.payload_json, '$.item.slug') = ?
435
+ LIMIT 1`,
436
+ )
437
+ .bind(candidate)
438
+ .first<{ id: string }>()
236
439
 
237
- if (!row || (excludeId && row.id === excludeId)) {
440
+ const draftAvailable = !draftRow || (excludeId !== undefined && draftRow.id === excludeId)
441
+ const publishedAvailable =
442
+ !publishedRow || (excludeId !== undefined && publishedRow.id === excludeId)
443
+ if (draftAvailable && publishedAvailable) {
238
444
  return candidate
239
445
  }
240
446
 
@@ -339,10 +545,29 @@ export async function createContent(
339
545
  db: D1Database,
340
546
  input: CreateContentInput,
341
547
  ): Promise<{ id: string; slug: string }> {
548
+ const resolvedMediaDuration =
549
+ input.type === 'video'
550
+ ? await resolveMediaDuration(
551
+ db,
552
+ 'video',
553
+ input.content.processingSourceUrl,
554
+ input.content.durationSeconds,
555
+ )
556
+ : input.type === 'podcast'
557
+ ? await resolveMediaDuration(
558
+ db,
559
+ 'podcast',
560
+ input.content.audioR2Key,
561
+ input.content.durationSeconds,
562
+ )
563
+ : null
342
564
  const id = crypto.randomUUID()
343
565
  const slug = await ensureUniqueSlug(db, input.slug)
344
566
  const visibility = input.visibility || 'free'
345
567
  const primaryCategory = input.primaryCategory || 'analysis'
568
+ const metadataJson = serializeContentMetadata(input.metadata)
569
+ const sourceId = normalizeSourceId(input.sourceId)
570
+ const initialTimestamps = normalizeInitialTimestamps(input.initialTimestamps)
346
571
 
347
572
  await db
348
573
  .prepare(
@@ -350,8 +575,10 @@ export async function createContent(
350
575
  INSERT INTO content_items (
351
576
  id, type, status, visibility, slug, title, seo_title, description, excerpt,
352
577
  byline, channel, primary_topic, featured, author_id, hero_image_id,
353
- hero_image_alt, hero_image_caption, social_image_id, canonical_url
354
- ) VALUES (?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
578
+ hero_image_alt, hero_image_caption, social_image_id, canonical_url,
579
+ metadata_json, source_id, published_at, created_at, updated_at, publish_tz
580
+ ) VALUES (?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
581
+ COALESCE(?, unixepoch()), COALESCE(?, unixepoch()), COALESCE(?, 'Europe/Paris'))
355
582
  `,
356
583
  )
357
584
  .bind(
@@ -373,6 +600,12 @@ export async function createContent(
373
600
  input.heroImageCaption || null,
374
601
  input.socialImageId || null,
375
602
  input.canonicalUrl || null,
603
+ metadataJson,
604
+ sourceId,
605
+ initialTimestamps.publishedAt,
606
+ initialTimestamps.createdAt,
607
+ initialTimestamps.updatedAt,
608
+ initialTimestamps.publishTz,
376
609
  )
377
610
  .run()
378
611
 
@@ -414,7 +647,7 @@ export async function createContent(
414
647
  id,
415
648
  input.content.script,
416
649
  input.content.videoId,
417
- input.content.durationSeconds || null,
650
+ resolvedMediaDuration,
418
651
  input.content.thumbnailImageId || null,
419
652
  initialVideoProcessingState,
420
653
  input.content.processingSourceUrl || null,
@@ -429,12 +662,7 @@ export async function createContent(
429
662
  `INSERT INTO podcast_content (content_id, transcript, audio_r2_key, duration_seconds)
430
663
  VALUES (?, ?, ?, ?)`,
431
664
  )
432
- .bind(
433
- id,
434
- input.content.transcript,
435
- input.content.audioR2Key,
436
- input.content.durationSeconds || null,
437
- )
665
+ .bind(id, input.content.transcript, input.content.audioR2Key, resolvedMediaDuration)
438
666
  .run()
439
667
  }
440
668
 
@@ -464,6 +692,123 @@ export async function updateContentItem(
464
692
  if (!existing) return null
465
693
 
466
694
  const hasOwn = (source: object, key: string) => Object.hasOwn(source, key)
695
+ let videoUpdatePlan: VideoUpdatePlan | null | undefined
696
+ let podcastUpdatePlan: PodcastUpdatePlan | null | undefined
697
+
698
+ // Resolve all media identity/duration invariants before the base content row,
699
+ // tags, or relations can be mutated. D1 does not provide a transaction on the
700
+ // structural surface used here, so validation must be a strict preflight.
701
+ if (input.content && existing.type === 'video') {
702
+ const current = await db
703
+ .prepare(
704
+ `SELECT script, video_id, duration_seconds, thumbnail_image_id,
705
+ processing_source_url, processing_source_kind
706
+ FROM video_content
707
+ WHERE content_id = ?
708
+ LIMIT 1`,
709
+ )
710
+ .bind(id)
711
+ .first<{
712
+ script: string
713
+ video_id: string
714
+ duration_seconds: number | null
715
+ thumbnail_image_id: string | null
716
+ processing_source_url: string | null
717
+ processing_source_kind: string | null
718
+ }>()
719
+
720
+ if (current) {
721
+ const videoContent = input.content as Partial<VideoInput['content']>
722
+ const sourceUrl = hasOwn(videoContent, 'processingSourceUrl')
723
+ ? (videoContent.processingSourceUrl ?? null)
724
+ : current.processing_source_url
725
+ const sourceKind = hasOwn(videoContent, 'processingSourceKind')
726
+ ? (videoContent.processingSourceKind ?? null)
727
+ : current.processing_source_kind
728
+ const sourceChanged =
729
+ sourceUrl !== current.processing_source_url || sourceKind !== current.processing_source_kind
730
+ const durationProvided = hasOwn(videoContent, 'durationSeconds')
731
+ const resolvedDuration = await resolveMediaDuration(
732
+ db,
733
+ 'video',
734
+ sourceUrl,
735
+ durationProvided ? videoContent.durationSeconds : undefined,
736
+ )
737
+
738
+ videoUpdatePlan = {
739
+ script: hasOwn(videoContent, 'script')
740
+ ? (videoContent.script ?? current.script)
741
+ : current.script,
742
+ videoId: hasOwn(videoContent, 'videoId')
743
+ ? (videoContent.videoId ?? current.video_id)
744
+ : current.video_id,
745
+ durationSeconds:
746
+ durationProvided || sourceChanged
747
+ ? resolvedDuration
748
+ : (resolvedDuration ?? current.duration_seconds),
749
+ thumbnailImageId: hasOwn(videoContent, 'thumbnailImageId')
750
+ ? (videoContent.thumbnailImageId ?? null)
751
+ : current.thumbnail_image_id,
752
+ sourceUrl,
753
+ sourceKind,
754
+ sourceChanged,
755
+ }
756
+ } else {
757
+ videoUpdatePlan = null
758
+ }
759
+ }
760
+
761
+ if (input.content && existing.type === 'podcast') {
762
+ const current = await db
763
+ .prepare(
764
+ `SELECT transcript, audio_r2_key, duration_seconds, processing_trigger_token
765
+ FROM podcast_content
766
+ WHERE content_id = ?
767
+ LIMIT 1`,
768
+ )
769
+ .bind(id)
770
+ .first<{
771
+ transcript: string
772
+ audio_r2_key: string
773
+ duration_seconds: number | null
774
+ processing_trigger_token: string | null
775
+ }>()
776
+
777
+ if (current) {
778
+ const podcastContent = input.content as Partial<PodcastInput['content']>
779
+ const audioR2Key = hasOwn(podcastContent, 'audioR2Key')
780
+ ? (podcastContent.audioR2Key ?? current.audio_r2_key)
781
+ : current.audio_r2_key
782
+ const sourceChanged = audioR2Key !== current.audio_r2_key
783
+ const durationProvided = hasOwn(podcastContent, 'durationSeconds')
784
+ const resolvedDuration = await resolveMediaDuration(
785
+ db,
786
+ 'podcast',
787
+ audioR2Key,
788
+ durationProvided ? podcastContent.durationSeconds : undefined,
789
+ )
790
+ const replacementTranscriptProvided =
791
+ hasOwn(podcastContent, 'transcript') && typeof podcastContent.transcript === 'string'
792
+
793
+ podcastUpdatePlan = {
794
+ transcript: sourceChanged
795
+ ? replacementTranscriptProvided
796
+ ? (podcastContent.transcript as string)
797
+ : ''
798
+ : replacementTranscriptProvided
799
+ ? (podcastContent.transcript as string)
800
+ : current.transcript,
801
+ audioR2Key,
802
+ durationSeconds:
803
+ durationProvided || sourceChanged
804
+ ? resolvedDuration
805
+ : (resolvedDuration ?? current.duration_seconds),
806
+ processingTriggerToken: sourceChanged ? null : current.processing_trigger_token,
807
+ }
808
+ } else {
809
+ podcastUpdatePlan = null
810
+ }
811
+ }
467
812
 
468
813
  const slug = input.slug ? await ensureUniqueSlug(db, input.slug, id) : existing.slug
469
814
  const visibility = input.visibility || existing.visibility
@@ -505,6 +850,9 @@ export async function updateContentItem(
505
850
  const seoFocusKeyword = hasOwn(input, 'seoFocusKeyword')
506
851
  ? (input.seoFocusKeyword ?? null)
507
852
  : (existing.seo_focus_keyword ?? null)
853
+ const metadataJson = hasOwn(input, 'metadata')
854
+ ? serializeContentMetadata(input.metadata)
855
+ : existing.metadata_json
508
856
 
509
857
  await db
510
858
  .prepare(
@@ -528,6 +876,7 @@ export async function updateContentItem(
528
876
  canonical_url = ?,
529
877
  ai_locked_fields = ?,
530
878
  seo_focus_keyword = ?,
879
+ metadata_json = ?,
531
880
  updated_at = unixepoch()
532
881
  WHERE id = ?
533
882
  `,
@@ -551,6 +900,7 @@ export async function updateContentItem(
551
900
  canonicalUrl,
552
901
  aiLockedFields,
553
902
  seoFocusKeyword,
903
+ metadataJson,
554
904
  id,
555
905
  )
556
906
  .run()
@@ -642,37 +992,7 @@ export async function updateContentItem(
642
992
  }
643
993
 
644
994
  if (existing.type === 'video') {
645
- const current = await db
646
- .prepare(
647
- `
648
- SELECT script, video_id, duration_seconds, thumbnail_image_id,
649
- processing_source_url, processing_source_kind
650
- FROM video_content
651
- WHERE content_id = ?
652
- LIMIT 1
653
- `,
654
- )
655
- .bind(id)
656
- .first<{
657
- script: string
658
- video_id: string
659
- duration_seconds: number | null
660
- thumbnail_image_id: string | null
661
- processing_source_url: string | null
662
- processing_source_kind: string | null
663
- }>()
664
- if (!current) return { slug }
665
-
666
- const videoContent = input.content as Partial<VideoInput['content']>
667
- const nextSourceUrl = hasOwn(videoContent, 'processingSourceUrl')
668
- ? (videoContent.processingSourceUrl ?? null)
669
- : current.processing_source_url
670
- const nextSourceKind = hasOwn(videoContent, 'processingSourceKind')
671
- ? (videoContent.processingSourceKind ?? null)
672
- : current.processing_source_kind
673
- const sourceChanged =
674
- nextSourceUrl !== current.processing_source_url ||
675
- nextSourceKind !== current.processing_source_kind
995
+ if (!videoUpdatePlan) return { slug }
676
996
  await db
677
997
  .prepare(
678
998
  `
@@ -697,78 +1017,51 @@ export async function updateContentItem(
697
1017
  hls_manifest_url = CASE WHEN ? THEN NULL ELSE hls_manifest_url END,
698
1018
  hls_poster_url = CASE WHEN ? THEN NULL ELSE hls_poster_url END
699
1019
  WHERE content_id = ?
700
- `,
1020
+ `,
701
1021
  )
702
1022
  .bind(
703
- hasOwn(videoContent, 'script') ? (videoContent.script ?? current.script) : current.script,
704
- hasOwn(videoContent, 'videoId')
705
- ? (videoContent.videoId ?? current.video_id)
706
- : current.video_id,
707
- hasOwn(videoContent, 'durationSeconds')
708
- ? (videoContent.durationSeconds ?? null)
709
- : current.duration_seconds,
710
- hasOwn(videoContent, 'thumbnailImageId')
711
- ? (videoContent.thumbnailImageId ?? null)
712
- : current.thumbnail_image_id,
713
- nextSourceUrl,
714
- nextSourceKind,
715
- sourceChanged ? 1 : 0,
716
- sourceChanged ? 1 : 0,
717
- sourceChanged ? 1 : 0,
718
- sourceChanged ? 1 : 0,
719
- sourceChanged ? 1 : 0,
720
- sourceChanged ? 1 : 0,
721
- sourceChanged ? 1 : 0,
722
- sourceChanged ? 1 : 0,
723
- sourceChanged ? 1 : 0,
724
- sourceChanged ? 1 : 0,
725
- sourceChanged ? 1 : 0,
726
- sourceChanged ? 1 : 0,
727
- sourceChanged ? 1 : 0,
1023
+ videoUpdatePlan.script,
1024
+ videoUpdatePlan.videoId,
1025
+ videoUpdatePlan.durationSeconds,
1026
+ videoUpdatePlan.thumbnailImageId,
1027
+ videoUpdatePlan.sourceUrl,
1028
+ videoUpdatePlan.sourceKind,
1029
+ videoUpdatePlan.sourceChanged ? 1 : 0,
1030
+ videoUpdatePlan.sourceChanged ? 1 : 0,
1031
+ videoUpdatePlan.sourceChanged ? 1 : 0,
1032
+ videoUpdatePlan.sourceChanged ? 1 : 0,
1033
+ videoUpdatePlan.sourceChanged ? 1 : 0,
1034
+ videoUpdatePlan.sourceChanged ? 1 : 0,
1035
+ videoUpdatePlan.sourceChanged ? 1 : 0,
1036
+ videoUpdatePlan.sourceChanged ? 1 : 0,
1037
+ videoUpdatePlan.sourceChanged ? 1 : 0,
1038
+ videoUpdatePlan.sourceChanged ? 1 : 0,
1039
+ videoUpdatePlan.sourceChanged ? 1 : 0,
1040
+ videoUpdatePlan.sourceChanged ? 1 : 0,
1041
+ videoUpdatePlan.sourceChanged ? 1 : 0,
728
1042
  id,
729
1043
  )
730
1044
  .run()
731
1045
  }
732
1046
 
733
1047
  if (existing.type === 'podcast') {
734
- const current = await db
735
- .prepare(
736
- `
737
- SELECT transcript, audio_r2_key, duration_seconds
738
- FROM podcast_content
739
- WHERE content_id = ?
740
- LIMIT 1
741
- `,
742
- )
743
- .bind(id)
744
- .first<{
745
- transcript: string
746
- audio_r2_key: string
747
- duration_seconds: number | null
748
- }>()
749
- if (!current) return { slug }
750
-
751
- const podcastContent = input.content as Partial<PodcastInput['content']>
1048
+ if (!podcastUpdatePlan) return { slug }
752
1049
  await db
753
1050
  .prepare(
754
1051
  `
755
- UPDATE podcast_content SET
756
- transcript = ?,
757
- audio_r2_key = ?,
758
- duration_seconds = ?
759
- WHERE content_id = ?
760
- `,
1052
+ UPDATE podcast_content SET
1053
+ transcript = ?,
1054
+ audio_r2_key = ?,
1055
+ duration_seconds = ?,
1056
+ processing_trigger_token = ?
1057
+ WHERE content_id = ?
1058
+ `,
761
1059
  )
762
1060
  .bind(
763
- hasOwn(podcastContent, 'transcript')
764
- ? (podcastContent.transcript ?? current.transcript)
765
- : current.transcript,
766
- hasOwn(podcastContent, 'audioR2Key')
767
- ? (podcastContent.audioR2Key ?? current.audio_r2_key)
768
- : current.audio_r2_key,
769
- hasOwn(podcastContent, 'durationSeconds')
770
- ? (podcastContent.durationSeconds ?? null)
771
- : current.duration_seconds,
1061
+ podcastUpdatePlan.transcript,
1062
+ podcastUpdatePlan.audioR2Key,
1063
+ podcastUpdatePlan.durationSeconds,
1064
+ podcastUpdatePlan.processingTriggerToken,
772
1065
  id,
773
1066
  )
774
1067
  .run()
@@ -915,7 +1208,8 @@ export async function duplicateContentItem(
915
1208
  // DEFAULT (unixepoch()) timestamps; status forced to 'draft', featured to
916
1209
  // 0, publish_at/published_at/published_revision_id to NULL. (inventory §6)
917
1210
  //
918
- // SEO carries over (seo_title, seo_focus_keyword, seo_score, ai_og_image_id)
1211
+ // SEO and declared metadata carry over (seo_title, seo_focus_keyword,
1212
+ // seo_score, ai_og_image_id, metadata_json)
919
1213
  // — a duplicate should keep the SEO work already done on the source. But
920
1214
  // board_position is the source item's slot in the editorial board ordering;
921
1215
  // a fresh draft copy must NOT inherit it, so it is reset to NULL.
@@ -927,7 +1221,8 @@ export async function duplicateContentItem(
927
1221
  hero_image_id, hero_image_alt, hero_image_caption, social_image_id,
928
1222
  canonical_url, publish_at, published_at,
929
1223
  publish_tz, ai_locked_fields, published_revision_id,
930
- seo_focus_keyword, seo_score, ai_og_image_id, board_position
1224
+ seo_focus_keyword, seo_score, ai_og_image_id, board_position,
1225
+ metadata_json
931
1226
  )
932
1227
  SELECT
933
1228
  ?, type, 'draft', visibility, ?, title || ' (Copy)', seo_title, description, excerpt,
@@ -935,7 +1230,8 @@ export async function duplicateContentItem(
935
1230
  hero_image_id, hero_image_alt, hero_image_caption, social_image_id,
936
1231
  canonical_url, NULL, NULL,
937
1232
  publish_tz, ai_locked_fields, NULL,
938
- seo_focus_keyword, seo_score, ai_og_image_id, NULL
1233
+ seo_focus_keyword, seo_score, ai_og_image_id, NULL,
1234
+ metadata_json
939
1235
  FROM content_items
940
1236
  WHERE id = ?`,
941
1237
  )
@@ -1047,14 +1343,14 @@ export async function getContentSnapshot(
1047
1343
 
1048
1344
  const tags = await db
1049
1345
  .prepare(
1050
- 'SELECT t.slug FROM content_tag_links l JOIN content_tags t ON t.id = l.tag_id WHERE l.content_id = ?',
1346
+ 'SELECT t.slug FROM content_tag_links l JOIN content_tags t ON t.id = l.tag_id WHERE l.content_id = ? ORDER BY t.slug ASC',
1051
1347
  )
1052
1348
  .bind(contentId)
1053
1349
  .all<{ slug: string }>()
1054
1350
 
1055
1351
  const related = await db
1056
1352
  .prepare(
1057
- 'SELECT related_id, rank, reason FROM content_relations WHERE content_id = ? ORDER BY rank ASC',
1353
+ 'SELECT related_id, rank, reason FROM content_relations WHERE content_id = ? ORDER BY rank ASC, related_id ASC',
1058
1354
  )
1059
1355
  .bind(contentId)
1060
1356
  .all<{ related_id: string; rank: number; reason: string | null }>()