@growth-labs/cms 0.6.0 → 0.6.2

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.
@@ -19,7 +19,7 @@
19
19
  // timestamps; the video copy hard-codes `processing_state = 'ready'` so a
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
- import type { ContentType, FrontsPublishStatus } from '../schema/types.js'
22
+ import type { ContentStatus, ContentType, FrontsPublishStatus } from '../schema/types.js'
23
23
  import { type ContentMetadata, serializeContentMetadata } from './content-metadata.js'
24
24
  import type { D1Database } from './d1.js'
25
25
  import { armFrontsPublishTriggerToken } from './fronts-publish-intent.js'
@@ -340,6 +340,17 @@ export interface UpdateContentInput extends Partial<BaseContentInput> {
340
340
  aiLockedFields?: string[]
341
341
  }
342
342
 
343
+ export interface UpdateContentOptions {
344
+ /** Atomically apply the update only while the content row has this status. */
345
+ requiredStatus?: ContentStatus
346
+ }
347
+
348
+ export interface UpdateContentResult {
349
+ slug: string
350
+ /** Present when requiredStatus lost a race before the mutation transaction. */
351
+ statusConflict?: string
352
+ }
353
+
343
354
  interface VideoUpdatePlan {
344
355
  script: string
345
356
  videoId: string
@@ -762,7 +773,8 @@ export async function updateContentItem(
762
773
  db: D1Database,
763
774
  id: string,
764
775
  input: UpdateContentInput,
765
- ): Promise<{ slug: string } | null> {
776
+ options: UpdateContentOptions = {},
777
+ ): Promise<UpdateContentResult | null> {
766
778
  const tagPairs = input.tags === undefined ? null : validateDeclaredTagList(input.tags)
767
779
  const existing = await getContentItem(db, id)
768
780
  if (!existing) return null
@@ -949,6 +961,158 @@ export async function updateContentItem(
949
961
  ? serializeContentMetadata(input.metadata)
950
962
  : existing.metadata_json
951
963
 
964
+ if (options.requiredStatus) {
965
+ if (input.tags !== undefined || input.relations !== undefined) {
966
+ throw new TypeError('Status-constrained updates do not support tags or relations')
967
+ }
968
+ if (!isBodyBackedContentType(existing.type)) {
969
+ throw new TypeError('Status-constrained updates require body-backed content')
970
+ }
971
+
972
+ // D1 batch is a transaction: the status predicate and every content-table
973
+ // write linearize together, so a native publish/schedule cannot interleave
974
+ // between the base row and its body row.
975
+ const statements = [
976
+ db
977
+ .prepare(
978
+ `UPDATE content_items SET
979
+ slug = ?, title = ?, seo_title = ?, description = ?, excerpt = ?, byline = ?,
980
+ channel = ?, primary_topic = ?, featured = ?, visibility = ?, author_id = ?,
981
+ hero_image_id = ?, hero_image_alt = ?, hero_image_caption = ?, social_image_id = ?,
982
+ canonical_url = ?, ai_locked_fields = ?, seo_focus_keyword = ?, metadata_json = ?,
983
+ updated_at = unixepoch(), canonical_version = canonical_version + 1
984
+ WHERE id = ? AND status = ? AND deleted_at IS NULL
985
+ RETURNING slug`,
986
+ )
987
+ .bind(
988
+ slug,
989
+ title,
990
+ seoTitle,
991
+ description,
992
+ excerpt,
993
+ byline,
994
+ primaryCategory,
995
+ primaryTopic,
996
+ featured,
997
+ visibility,
998
+ authorId,
999
+ heroImageId,
1000
+ heroImageAlt,
1001
+ heroImageCaption,
1002
+ socialImageId,
1003
+ canonicalUrl,
1004
+ aiLockedFields,
1005
+ seoFocusKeyword,
1006
+ metadataJson,
1007
+ id,
1008
+ options.requiredStatus,
1009
+ ),
1010
+ ]
1011
+
1012
+ if (input.content) {
1013
+ const current = await db
1014
+ .prepare(
1015
+ `SELECT body_markdown, body_html, body_portable_text, layout_json,
1016
+ word_count, read_time_minutes, subtitle, editor_takeaways
1017
+ FROM article_content WHERE content_id = ? LIMIT 1`,
1018
+ )
1019
+ .bind(id)
1020
+ .first<{
1021
+ body_markdown: string
1022
+ body_html: string | null
1023
+ body_portable_text: string | null
1024
+ layout_json: string | null
1025
+ word_count: number | null
1026
+ read_time_minutes: number | null
1027
+ subtitle: string | null
1028
+ editor_takeaways: string | null
1029
+ }>()
1030
+ if (!current) throw new Error('Body-backed content row is missing')
1031
+
1032
+ const articleContent = input.content as Partial<
1033
+ ArticleInput['content'] | NewsletterInput['content'] | PageInput['content']
1034
+ >
1035
+ const bodyMarkdownProvided = hasOwn(articleContent, 'bodyMarkdown')
1036
+ const wordCountProvided = hasOwn(articleContent, 'wordCount')
1037
+ const readTimeProvided = hasOwn(articleContent, 'readTimeMinutes')
1038
+ const nextBodyMarkdown = bodyMarkdownProvided
1039
+ ? (articleContent.bodyMarkdown ?? current.body_markdown)
1040
+ : current.body_markdown
1041
+ const nextWordCount = wordCountProvided
1042
+ ? (articleContent.wordCount ?? null)
1043
+ : bodyMarkdownProvided
1044
+ ? nextBodyMarkdown
1045
+ ? countWords(nextBodyMarkdown)
1046
+ : null
1047
+ : current.word_count
1048
+ const nextReadTime = readTimeProvided
1049
+ ? (articleContent.readTimeMinutes ?? null)
1050
+ : bodyMarkdownProvided
1051
+ ? nextWordCount
1052
+ ? estimateReadTime(nextWordCount)
1053
+ : null
1054
+ : current.read_time_minutes
1055
+
1056
+ statements.push(
1057
+ db
1058
+ .prepare(
1059
+ `UPDATE article_content SET
1060
+ body_markdown = ?, body_html = ?, body_portable_text = ?, layout_json = ?,
1061
+ word_count = ?, read_time_minutes = ?, subtitle = ?, editor_takeaways = ?
1062
+ WHERE content_id = ?
1063
+ AND EXISTS (
1064
+ SELECT 1 FROM content_items
1065
+ WHERE id = ? AND status = ? AND deleted_at IS NULL
1066
+ )
1067
+ RETURNING content_id`,
1068
+ )
1069
+ .bind(
1070
+ nextBodyMarkdown,
1071
+ hasOwn(articleContent, 'bodyHtml')
1072
+ ? (articleContent.bodyHtml ?? null)
1073
+ : bodyMarkdownProvided
1074
+ ? null
1075
+ : current.body_html,
1076
+ hasOwn(articleContent, 'bodyPortableText')
1077
+ ? (articleContent.bodyPortableText ?? null)
1078
+ : bodyMarkdownProvided
1079
+ ? null
1080
+ : current.body_portable_text,
1081
+ hasOwn(articleContent, 'layoutJson')
1082
+ ? (articleContent.layoutJson ?? null)
1083
+ : current.layout_json,
1084
+ nextWordCount,
1085
+ nextReadTime,
1086
+ hasOwn(articleContent, 'subtitle')
1087
+ ? (articleContent.subtitle ?? null)
1088
+ : current.subtitle,
1089
+ hasOwn(articleContent, 'editorTakeaways')
1090
+ ? serializeTakeaways(articleContent.editorTakeaways)
1091
+ : current.editor_takeaways,
1092
+ id,
1093
+ id,
1094
+ options.requiredStatus,
1095
+ ),
1096
+ )
1097
+ }
1098
+
1099
+ const statusResultIndex = statements.length
1100
+ statements.push(
1101
+ db
1102
+ .prepare('SELECT status FROM content_items WHERE id = ? AND deleted_at IS NULL LIMIT 1')
1103
+ .bind(id),
1104
+ )
1105
+ const results = await db.batch(statements)
1106
+ const baseChanged =
1107
+ (results[0]?.results?.length ?? 0) > 0 ||
1108
+ (results[0]?.meta as { changes?: number } | undefined)?.changes === 1
1109
+ if (!baseChanged) {
1110
+ const status = results[statusResultIndex]?.results?.[0]?.status
1111
+ return typeof status === 'string' ? { slug: existing.slug, statusConflict: status } : null
1112
+ }
1113
+ return { slug }
1114
+ }
1115
+
952
1116
  await db
953
1117
  .prepare(
954
1118
  `
@@ -140,6 +140,8 @@ export interface ContentRouteConfig extends CmsRouteConfig {
140
140
  dispatchFaq?: DispatchFaqHook
141
141
  /** Optional host-specific readiness guard. A not-ready result blocks publish. */
142
142
  publishReadiness?: PublishReadinessHook
143
+ /** Internal machine-route constraint: atomically update only in this status. */
144
+ updateStatusConstraint?: ContentStatus
143
145
  /**
144
146
  * Optional webhook dispatcher hook (P7 Task 9). Fired at each content
145
147
  * lifecycle transition (publish, unpublish, schedule, archive, create, etc.).
@@ -1238,6 +1240,9 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1238
1240
  existing.author_id,
1239
1241
  )
1240
1242
  if (denied) return denied
1243
+ if (config.updateStatusConstraint && existing.status !== config.updateStatusConstraint) {
1244
+ return json({ error: 'update_status_conflict', state: existing.status }, 409)
1245
+ }
1241
1246
 
1242
1247
  const parsed = UpdateSchema.safeParse(await readJson(ctx))
1243
1248
  if (!parsed.success) {
@@ -1320,7 +1325,9 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1320
1325
  const priorSlug = existing.slug
1321
1326
  let updated: Awaited<ReturnType<typeof updateContentItem>>
1322
1327
  try {
1323
- updated = await updateContentItem(ctx.db, id, parsed.data)
1328
+ updated = await updateContentItem(ctx.db, id, parsed.data, {
1329
+ requiredStatus: config.updateStatusConstraint,
1330
+ })
1324
1331
  } catch (error) {
1325
1332
  if (error instanceof TagInputError) return tagInputErrorResponse(error)
1326
1333
  const conflict = verifiedMediaDurationConflictResponse(error)
@@ -1328,6 +1335,9 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1328
1335
  throw error
1329
1336
  }
1330
1337
  if (!updated) return json({ error: 'Not found' }, 404)
1338
+ if (updated.statusConflict) {
1339
+ return json({ error: 'update_status_conflict', state: updated.statusConflict }, 409)
1340
+ }
1331
1341
  const podcastAttempt: PodcastFoundryAttempt =
1332
1342
  existing.type === 'podcast'
1333
1343
  ? await dispatchPodcastAfterSave(ctx, id)
@@ -123,6 +123,7 @@ function receipt(row: Pick<FoundryArticleRow, 'external_id' | 'receipt_id' | 'st
123
123
  function machineConfig(config: ContentRouteConfig): ContentRouteConfig {
124
124
  return {
125
125
  ...config,
126
+ updateStatusConstraint: 'draft',
126
127
  authz: {
127
128
  requireAdmin: () => null,
128
129
  requirePublisher: () => null,
@@ -170,12 +171,16 @@ async function upsertReceipt(
170
171
  .run()
171
172
  }
172
173
 
173
- async function heroAssetId(
174
+ async function heroAssetId(url: string): Promise<string> {
175
+ return `foundry-hero-${(await sha256(url)).slice(0, 24)}`
176
+ }
177
+
178
+ async function persistHeroAsset(
174
179
  ctx: RouteContext,
175
180
  config: ContentRouteConfig,
181
+ id: string,
176
182
  url: string,
177
- ): Promise<string> {
178
- const id = `foundry-hero-${(await sha256(url)).slice(0, 24)}`
183
+ ): Promise<void> {
179
184
  await insertMediaAsset(ctx.db, {
180
185
  id,
181
186
  siteId: config.mediaSiteId ?? 'fronts',
@@ -191,7 +196,23 @@ async function heroAssetId(
191
196
  variantsJson: null,
192
197
  r2Binding: config.mediaR2Binding ?? 'PUBLIC_MEDIA',
193
198
  })
194
- return id
199
+ }
200
+
201
+ function approvalConflict(id: string, state: string): Response {
202
+ return json({ error: 'published_requires_approval', external_id: id, state }, 409)
203
+ }
204
+
205
+ async function mapUpdateConflict(response: Response, id: string): Promise<Response> {
206
+ if (response.status !== 409) return response
207
+ try {
208
+ const payload = (await response.clone().json()) as { error?: unknown; state?: unknown }
209
+ if (payload.error === 'update_status_conflict' && typeof payload.state === 'string') {
210
+ return approvalConflict(id, payload.state)
211
+ }
212
+ } catch {
213
+ // Preserve an unrelated native 409 response byte-for-byte.
214
+ }
215
+ return response
195
216
  }
196
217
 
197
218
  export function createFoundryPublishRoutes(
@@ -224,15 +245,19 @@ export function createFoundryPublishRoutes(
224
245
  })
225
246
  return json(receipt(prior))
226
247
  }
248
+ if (prior && prior.status !== 'draft') return approvalConflict(id, prior.status)
227
249
 
228
250
  const sourceContent = prior ? null : await findContentBySource(ctx, id)
229
251
  if (sourceContent && sourceContent.type !== 'article')
230
252
  return json({ error: 'external_id_conflict' }, 409)
253
+ if (sourceContent && sourceContent.status !== 'draft') {
254
+ return approvalConflict(id, sourceContent.status)
255
+ }
231
256
  const contentId = prior?.content_id ?? sourceContent?.id ?? null
232
257
  const digestSlug = `article-${auth.bodyHash.slice(0, 12)}`
233
258
  const slug = slugify(parsed.data.title) || digestSlug
234
259
  const heroImageId = parsed.data.hero_image_url
235
- ? await heroAssetId(ctx, config, parsed.data.hero_image_url)
260
+ ? await heroAssetId(parsed.data.hero_image_url)
236
261
  : undefined
237
262
  const body = {
238
263
  title: parsed.data.title,
@@ -243,7 +268,10 @@ export function createFoundryPublishRoutes(
243
268
 
244
269
  let nativeResponse: Response
245
270
  if (contentId) {
246
- nativeResponse = await content.update(nativeContext(ctx, 'PATCH', body, { id: contentId }))
271
+ nativeResponse = await mapUpdateConflict(
272
+ await content.update(nativeContext(ctx, 'PATCH', body, { id: contentId })),
273
+ id,
274
+ )
247
275
  } else {
248
276
  try {
249
277
  nativeResponse = await content.create(
@@ -257,10 +285,16 @@ export function createFoundryPublishRoutes(
257
285
  } catch (error) {
258
286
  const raced = await findContentBySource(ctx, id)
259
287
  if (!raced || raced.type !== 'article') throw error
260
- nativeResponse = await content.update(nativeContext(ctx, 'PATCH', body, { id: raced.id }))
288
+ nativeResponse = await mapUpdateConflict(
289
+ await content.update(nativeContext(ctx, 'PATCH', body, { id: raced.id })),
290
+ id,
291
+ )
261
292
  }
262
293
  }
263
294
  if (!nativeResponse.ok) return nativeResponse
295
+ if (heroImageId && parsed.data.hero_image_url) {
296
+ await persistHeroAsset(ctx, config, heroImageId, parsed.data.hero_image_url)
297
+ }
264
298
  const native = (await nativeResponse.json()) as { id?: string; slug?: string }
265
299
  const resolvedId = contentId ?? native.id
266
300
  if (!resolvedId) return json({ error: 'write_failed' }, 500)