@growth-labs/cms 0.5.6 → 0.5.8

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 (106) hide show
  1. package/README.md +15 -0
  2. package/dist/engine/ai-dek-notifications.d.ts +16 -0
  3. package/dist/engine/ai-dek-notifications.d.ts.map +1 -0
  4. package/dist/engine/ai-dek-notifications.js +34 -0
  5. package/dist/engine/ai-dek-notifications.js.map +1 -0
  6. package/dist/engine/ai-dek.d.ts +35 -0
  7. package/dist/engine/ai-dek.d.ts.map +1 -0
  8. package/dist/engine/ai-dek.js +99 -0
  9. package/dist/engine/ai-dek.js.map +1 -0
  10. package/dist/engine/ai-prompts.d.ts +12 -0
  11. package/dist/engine/ai-prompts.d.ts.map +1 -1
  12. package/dist/engine/ai-prompts.js +17 -0
  13. package/dist/engine/ai-prompts.js.map +1 -1
  14. package/dist/engine/index.d.ts +3 -1
  15. package/dist/engine/index.d.ts.map +1 -1
  16. package/dist/engine/index.js +4 -1
  17. package/dist/engine/index.js.map +1 -1
  18. package/dist/integration/index.d.ts.map +1 -1
  19. package/dist/integration/index.js +4 -0
  20. package/dist/integration/index.js.map +1 -1
  21. package/dist/integration/options.d.ts +26 -0
  22. package/dist/integration/options.d.ts.map +1 -1
  23. package/dist/integration/options.js +5 -0
  24. package/dist/integration/options.js.map +1 -1
  25. package/dist/routes/ai.d.ts +2 -0
  26. package/dist/routes/ai.d.ts.map +1 -1
  27. package/dist/routes/ai.js +42 -0
  28. package/dist/routes/ai.js.map +1 -1
  29. package/dist/routes/authz-matrix.d.ts +2 -1
  30. package/dist/routes/authz-matrix.d.ts.map +1 -1
  31. package/dist/routes/authz-matrix.js +3 -0
  32. package/dist/routes/authz-matrix.js.map +1 -1
  33. package/dist/routes/content.d.ts.map +1 -1
  34. package/dist/routes/content.js +49 -2
  35. package/dist/routes/content.js.map +1 -1
  36. package/dist/routes/context.d.ts +1 -1
  37. package/dist/routes/context.d.ts.map +1 -1
  38. package/dist/routes/context.js.map +1 -1
  39. package/dist/surveys/schema.d.ts +6 -6
  40. package/dist/ui/api/_content-config.d.ts.map +1 -1
  41. package/dist/ui/api/_content-config.js +1 -0
  42. package/dist/ui/api/_content-config.js.map +1 -1
  43. package/dist/ui/api/content/[id]/ai/dek.d.ts +3 -0
  44. package/dist/ui/api/content/[id]/ai/dek.d.ts.map +1 -0
  45. package/dist/ui/api/content/[id]/ai/dek.js +5 -0
  46. package/dist/ui/api/content/[id]/ai/dek.js.map +1 -0
  47. package/dist/ui/client/mount.d.ts.map +1 -1
  48. package/dist/ui/client/mount.js +1 -0
  49. package/dist/ui/client/mount.js.map +1 -1
  50. package/dist/ui/editor/AiAssistPanel.d.ts +3 -1
  51. package/dist/ui/editor/AiAssistPanel.d.ts.map +1 -1
  52. package/dist/ui/editor/AiAssistPanel.js +28 -4
  53. package/dist/ui/editor/AiAssistPanel.js.map +1 -1
  54. package/dist/ui/editor/ContentForm.d.ts +5 -1
  55. package/dist/ui/editor/ContentForm.d.ts.map +1 -1
  56. package/dist/ui/editor/ContentForm.js +8 -2
  57. package/dist/ui/editor/ContentForm.js.map +1 -1
  58. package/dist/ui/editor/ai-assist.d.ts +2 -2
  59. package/dist/ui/editor/ai-assist.d.ts.map +1 -1
  60. package/dist/ui/editor/content-payload.d.ts +1 -0
  61. package/dist/ui/editor/content-payload.d.ts.map +1 -1
  62. package/dist/ui/editor/content-payload.js +6 -1
  63. package/dist/ui/editor/content-payload.js.map +1 -1
  64. package/dist/ui/screens/ContentRoute.d.ts +5 -1
  65. package/dist/ui/screens/ContentRoute.d.ts.map +1 -1
  66. package/dist/ui/screens/ContentRoute.js +3 -3
  67. package/dist/ui/screens/ContentRoute.js.map +1 -1
  68. package/dist/ui/screens/EditorScreen.d.ts +5 -1
  69. package/dist/ui/screens/EditorScreen.d.ts.map +1 -1
  70. package/dist/ui/screens/EditorScreen.js +2 -2
  71. package/dist/ui/screens/EditorScreen.js.map +1 -1
  72. package/dist/ui/screens/LibraryScreen.d.ts +5 -1
  73. package/dist/ui/screens/LibraryScreen.d.ts.map +1 -1
  74. package/dist/ui/screens/LibraryScreen.js +20 -14
  75. package/dist/ui/screens/LibraryScreen.js.map +1 -1
  76. package/dist/ui/screens/library-data.d.ts +6 -0
  77. package/dist/ui/screens/library-data.d.ts.map +1 -1
  78. package/dist/ui/screens/library-data.js +8 -1
  79. package/dist/ui/screens/library-data.js.map +1 -1
  80. package/dist/ui/screens/registry.d.ts +5 -1
  81. package/dist/ui/screens/registry.d.ts.map +1 -1
  82. package/dist/ui/screens/registry.js +2 -2
  83. package/dist/ui/screens/registry.js.map +1 -1
  84. package/package.json +1 -1
  85. package/src/engine/ai-dek-notifications.ts +50 -0
  86. package/src/engine/ai-dek.ts +138 -0
  87. package/src/engine/ai-prompts.ts +25 -0
  88. package/src/engine/index.ts +14 -0
  89. package/src/integration/index.ts +4 -0
  90. package/src/integration/options.ts +6 -0
  91. package/src/routes/ai.ts +44 -0
  92. package/src/routes/authz-matrix.ts +12 -1
  93. package/src/routes/content.ts +52 -2
  94. package/src/routes/context.ts +1 -0
  95. package/src/ui/api/_content-config.ts +2 -0
  96. package/src/ui/api/content/[id]/ai/dek.ts +8 -0
  97. package/src/ui/client/mount.tsx +1 -0
  98. package/src/ui/editor/AiAssistPanel.tsx +55 -3
  99. package/src/ui/editor/ContentForm.tsx +33 -1
  100. package/src/ui/editor/ai-assist.ts +2 -2
  101. package/src/ui/editor/content-payload.ts +6 -1
  102. package/src/ui/screens/ContentRoute.tsx +10 -1
  103. package/src/ui/screens/EditorScreen.tsx +3 -0
  104. package/src/ui/screens/LibraryScreen.tsx +76 -6
  105. package/src/ui/screens/library-data.ts +12 -1
  106. package/src/ui/screens/registry.tsx +8 -1
@@ -0,0 +1,50 @@
1
+ import type { D1Database } from './d1.js'
2
+
3
+ export interface DekFailureNotificationInput {
4
+ db: D1Database
5
+ workspaceId: string
6
+ contentId: string
7
+ title: string
8
+ reason: string
9
+ userId?: string | null
10
+ }
11
+
12
+ /**
13
+ * Create unread, in-app notifications when publication ships without a dek.
14
+ * Interactive publishes target the actor; background publishes target every
15
+ * editorial member because no actor is present to receive the failure.
16
+ */
17
+ export async function createDekFailureNotifications(
18
+ input: DekFailureNotificationInput,
19
+ ): Promise<number> {
20
+ let userIds: string[]
21
+ if (input.userId) {
22
+ userIds = [input.userId]
23
+ } else {
24
+ const rows = await input.db
25
+ .prepare(
26
+ `SELECT user_id
27
+ FROM workspace_memberships
28
+ WHERE workspace_id = ?
29
+ AND role IN ('owner', 'senior_editor', 'editor')
30
+ ORDER BY user_id ASC`,
31
+ )
32
+ .bind(input.workspaceId)
33
+ .all<{ user_id: string }>()
34
+ userIds = (rows.results ?? []).map((row) => row.user_id)
35
+ }
36
+
37
+ const reason = input.reason.trim().slice(0, 160) || 'unknown error'
38
+ const body = `Content was published without a subheading: “${input.title}”. AI generation failed (${reason}). Open the item to add one manually.`
39
+ for (const userId of [...new Set(userIds)]) {
40
+ await input.db
41
+ .prepare(
42
+ `INSERT INTO cms_notifications
43
+ (id, workspace_id, user_id, kind, body, created_at)
44
+ VALUES (?, ?, ?, 'ai_dek_failed', ?, unixepoch())`,
45
+ )
46
+ .bind(crypto.randomUUID(), input.workspaceId, userId, body)
47
+ .run()
48
+ }
49
+ return new Set(userIds).size
50
+ }
@@ -0,0 +1,138 @@
1
+ import type { LlmDispatch } from '../providers/types.js'
2
+ import { dekPrompt } from './ai-prompts.js'
3
+ import { applyAiWrite, parseLockedFields } from './ai-writeback.js'
4
+ import type { D1Database } from './d1.js'
5
+ import { type ContentItemRecord, getContentItem } from './publisher.js'
6
+
7
+ export const DEK_EXEMPLAR_LIMIT = 6
8
+
9
+ export interface DekGenerationContext {
10
+ item: ContentItemRecord
11
+ source: string
12
+ exemplars: string[]
13
+ }
14
+
15
+ export type GenerateContentDekResult =
16
+ | { status: 'generated'; dek: string; written: true }
17
+ | { status: 'locked'; dek: string; written: false }
18
+ | { status: 'unavailable'; dek: null; written: false; reason: string }
19
+ | { status: 'not_found'; dek: null; written: false; reason: 'not_found' }
20
+
21
+ async function loadTypeSource(db: D1Database, item: ContentItemRecord): Promise<string> {
22
+ if (item.type === 'video') {
23
+ const row = await db
24
+ .prepare('SELECT script FROM video_content WHERE content_id = ? LIMIT 1')
25
+ .bind(item.id)
26
+ .first<{ script: string | null }>()
27
+ return row?.script?.trim() ?? ''
28
+ }
29
+ if (item.type === 'podcast') {
30
+ const row = await db
31
+ .prepare('SELECT transcript FROM podcast_content WHERE content_id = ? LIMIT 1')
32
+ .bind(item.id)
33
+ .first<{ transcript: string | null }>()
34
+ return row?.transcript?.trim() ?? ''
35
+ }
36
+ const row = await db
37
+ .prepare(
38
+ `SELECT body_markdown, body_html, subtitle
39
+ FROM article_content WHERE content_id = ? LIMIT 1`,
40
+ )
41
+ .bind(item.id)
42
+ .first<{
43
+ body_markdown: string | null
44
+ body_html: string | null
45
+ subtitle: string | null
46
+ }>()
47
+ return (row?.body_markdown || row?.body_html || row?.subtitle || '').trim()
48
+ }
49
+
50
+ export async function loadDekGenerationContext(
51
+ db: D1Database,
52
+ contentId: string,
53
+ ): Promise<DekGenerationContext | null> {
54
+ const item = await getContentItem(db, contentId)
55
+ if (!item) return null
56
+
57
+ const typeSource = await loadTypeSource(db, item)
58
+ const description = item.description?.trim() ?? ''
59
+ const source = [description, typeSource].filter(Boolean).join('\n\n')
60
+ const rows = await db
61
+ .prepare(
62
+ `SELECT title, excerpt
63
+ FROM content_items
64
+ WHERE status = 'published'
65
+ AND deleted_at IS NULL
66
+ AND id <> ?
67
+ AND excerpt IS NOT NULL
68
+ AND trim(excerpt) <> ''
69
+ ORDER BY CASE WHEN type = ? THEN 0 ELSE 1 END,
70
+ COALESCE(published_at, 0) DESC,
71
+ id ASC
72
+ LIMIT ?`,
73
+ )
74
+ .bind(contentId, item.type, DEK_EXEMPLAR_LIMIT)
75
+ .all<{ title: string; excerpt: string }>()
76
+ const exemplars = (rows.results ?? []).map(
77
+ (row) => `Headline: ${row.title}\nDek: ${row.excerpt.trim()}`,
78
+ )
79
+
80
+ return { item, source, exemplars }
81
+ }
82
+
83
+ function normalizeDek(text: string): string {
84
+ return text
85
+ .trim()
86
+ .replace(/^(["“])|(["”])$/g, '')
87
+ .replace(/\s+/g, ' ')
88
+ .trim()
89
+ }
90
+
91
+ export async function generateContentDek(input: {
92
+ db: D1Database
93
+ contentId: string
94
+ llm: LlmDispatch
95
+ }): Promise<GenerateContentDekResult> {
96
+ const context = await loadDekGenerationContext(input.db, input.contentId)
97
+ if (!context) {
98
+ return { status: 'not_found', dek: null, written: false, reason: 'not_found' }
99
+ }
100
+
101
+ const result = await input.llm.dispatch({
102
+ ...dekPrompt({
103
+ title: context.item.title,
104
+ source: context.source,
105
+ exemplars: context.exemplars,
106
+ }),
107
+ stream: false,
108
+ })
109
+ if (result instanceof ReadableStream) {
110
+ return { status: 'unavailable', dek: null, written: false, reason: 'stream_result' }
111
+ }
112
+ if (result.status === 'unavailable') {
113
+ return {
114
+ status: 'unavailable',
115
+ dek: null,
116
+ written: false,
117
+ reason: result.reason ?? 'unavailable',
118
+ }
119
+ }
120
+
121
+ const dek = normalizeDek(result.text)
122
+ if (!dek) {
123
+ return { status: 'unavailable', dek: null, written: false, reason: 'empty_result' }
124
+ }
125
+
126
+ const write = applyAiWrite({
127
+ locked: parseLockedFields(context.item.ai_locked_fields),
128
+ field: 'excerpt',
129
+ value: dek,
130
+ })
131
+ if (!write.written) return { status: 'locked', dek, written: false }
132
+
133
+ await input.db
134
+ .prepare('UPDATE content_items SET excerpt = ?, updated_at = unixepoch() WHERE id = ?')
135
+ .bind(dek, input.contentId)
136
+ .run()
137
+ return { status: 'generated', dek, written: true }
138
+ }
@@ -47,12 +47,37 @@ export interface MetaDescriptionPromptInput {
47
47
  body: string
48
48
  }
49
49
 
50
+ /** Input to dekPrompt. A dek is display copy, not SEO metadata. */
51
+ export interface DekPromptInput {
52
+ title: string
53
+ source: string
54
+ exemplars?: string[]
55
+ }
56
+
50
57
  /** The typed prompt contract shape consumed by LlmDispatch.dispatch(). */
51
58
  export interface AiPromptContract {
52
59
  task: string
53
60
  prompt: string
54
61
  }
55
62
 
63
+ /**
64
+ * Build the prompt for a standfirst-style display subheading (dek).
65
+ * This deliberately differs from the SEO meta-description task: it tells the
66
+ * reader what the piece argues, reveals, or shows in the publication's voice.
67
+ */
68
+ export function dekPrompt(input: DekPromptInput): AiPromptContract {
69
+ const exemplarsBlock = buildExemplarsBlock(input.exemplars)
70
+ const prompt = `${HOUSE_STYLE_SYSTEM}${exemplarsBlock}
71
+
72
+ Task: Write one standfirst-style subheading (dek) for this piece. It must be 1–2 sentences, roughly 120–200 characters, and reader-facing. State what the piece argues, reveals, or shows. It must not repeat or lightly rephrase the headline. Avoid clickbait and never use the boilerplate shape “with context on the stakes for…”. Return only the dek text with no label, quotation marks, commentary, or preamble.
73
+
74
+ Headline: ${input.title}
75
+
76
+ Source material:
77
+ ${input.source}`
78
+ return { task: 'cms.dek', prompt }
79
+ }
80
+
56
81
  /** Build the exemplars block when exemplars are supplied. */
57
82
  function buildExemplarsBlock(exemplars: string[] | undefined): string {
58
83
  if (!exemplars || exemplars.length === 0) return ''
@@ -5,9 +5,23 @@
5
5
 
6
6
  // Activity log write helper — first-party D1, cms_activity_log (P5 Task 3).
7
7
  export { type ActivityEntry, logActivity } from './activity-log.js'
8
+ // Display-subheading generation with deterministic house exemplars.
9
+ export {
10
+ DEK_EXEMPLAR_LIMIT,
11
+ type DekGenerationContext,
12
+ type GenerateContentDekResult,
13
+ generateContentDek,
14
+ loadDekGenerationContext,
15
+ } from './ai-dek.js'
16
+ export {
17
+ createDekFailureNotifications,
18
+ type DekFailureNotificationInput,
19
+ } from './ai-dek-notifications.js'
8
20
  // AI prompt contracts + house-style system prompt (P4 Task 1).
9
21
  export {
10
22
  type AiPromptContract,
23
+ type DekPromptInput,
24
+ dekPrompt,
11
25
  type HeadlinesPromptInput,
12
26
  HOUSE_STYLE_SYSTEM,
13
27
  headlinesPrompt,
@@ -121,6 +121,10 @@ export default function growthLabsCms(userOptions: CmsIntegrationOptions): Astro
121
121
  entrypoint: resolveEntrypoint('../ui/api/content/[id]'),
122
122
  })
123
123
  // AI sub-routes
124
+ injectRoute({
125
+ pattern: `${base}/api/content/[id]/ai/dek`,
126
+ entrypoint: resolveEntrypoint('../ui/api/content/[id]/ai/dek'),
127
+ })
124
128
  injectRoute({
125
129
  pattern: `${base}/api/content/[id]/ai/headlines`,
126
130
  entrypoint: resolveEntrypoint('../ui/api/content/[id]/ai/headlines'),
@@ -8,6 +8,11 @@ const workspaceSchema = z.object({
8
8
  color: z.string().default('#0c4a6e'),
9
9
  })
10
10
 
11
+ const primaryCategorySchema = z.object({
12
+ slug: z.string().min(1),
13
+ label: z.string().min(1),
14
+ })
15
+
11
16
  const themeSchema = z
12
17
  .object({
13
18
  mode: z.enum(['light', 'dark']).default('dark'),
@@ -32,6 +37,7 @@ const surveyResultsSchema = z
32
37
  export const cmsIntegrationOptionsSchema = z
33
38
  .object({
34
39
  workspaces: z.array(workspaceSchema).min(1, 'at least one workspace is required'),
40
+ primaryCategories: z.array(primaryCategorySchema).default([]),
35
41
  activeWorkspaceId: z.string().optional(),
36
42
  adminBasePath: z.string().default('/admin'),
37
43
  searchEnabled: z.boolean().default(true),
package/src/routes/ai.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  // apply the ai_locked_fields gate, persist when allowed, and return the result.
15
15
  // - OG-image generation lands in Task 5.
16
16
 
17
+ import { generateContentDek } from '../engine/ai-dek.js'
17
18
  import {
18
19
  headlinesPrompt,
19
20
  metaDescriptionPrompt,
@@ -24,6 +25,7 @@ import { applyAiWrite, parseLockedFields } from '../engine/ai-writeback.js'
24
25
  import { buildOgCardSpec } from '../engine/og-render.js'
25
26
  import { type ContentItemRecord, getContentItem } from '../engine/publisher.js'
26
27
  import type { LlmDispatchResult } from '../providers/types.js'
28
+ import { canPerformContentAction } from './authz-matrix.js'
27
29
  import type { CmsRouteConfig } from './config.js'
28
30
  import { resolveConfig } from './config.js'
29
31
  import { type Authz, type Capability, json, type RouteContext, requireId } from './context.js'
@@ -45,6 +47,8 @@ async function aiGuard(authz: Authz, ctx: RouteContext): Promise<Response | null
45
47
  }
46
48
 
47
49
  export interface AiRouteHandlers {
50
+ /** POST /content/:id/ai/dek — sync: generate + persist a display subheading. */
51
+ suggestDek(ctx: RouteContext): Promise<Response>
48
52
  /** POST /content/:id/ai/proofread — SSE streaming proofread & tighten. */
49
53
  proofreadAndTighten(ctx: RouteContext): Promise<Response>
50
54
  /** POST /content/:id/ai/headlines — SSE streaming 5-headline suggestions. */
@@ -171,6 +175,45 @@ async function loadArticleBody(
171
175
  export function createAiRoutes(config: CmsRouteConfig): AiRouteHandlers {
172
176
  const resolved = resolveConfig(config)
173
177
 
178
+ async function suggestDek(ctx: RouteContext): Promise<Response> {
179
+ const idResult = requireId(ctx)
180
+ if ('error' in idResult) return idResult.error
181
+ const item = await getContentItem(ctx.db, idResult.id)
182
+ if (!item) return json({ error: 'Not found' }, 404)
183
+
184
+ if (resolved.authz.getUser) {
185
+ const user = await resolved.authz.getUser(ctx)
186
+ if (!user) return json({ error: 'Unauthorized' }, 401)
187
+ const allowed = canPerformContentAction(
188
+ user.role,
189
+ { actorSubject: user.subject, contentAuthorId: item.author_id },
190
+ item.status === 'published' ? 'write_published' : 'write',
191
+ )
192
+ if (!allowed) return json({ error: 'Forbidden', role: user.role }, 403)
193
+ } else {
194
+ const authGuard = await aiGuard(resolved.authz, ctx)
195
+ if (authGuard) return authGuard
196
+ }
197
+ if (!resolved.hooks.llm) {
198
+ return json({ error: 'AI generation is not configured' }, 501)
199
+ }
200
+
201
+ const result = await generateContentDek({
202
+ db: ctx.db,
203
+ contentId: idResult.id,
204
+ llm: resolved.hooks.llm,
205
+ })
206
+ if (result.status === 'not_found') return json({ error: 'Not found' }, 404)
207
+ if (result.status === 'unavailable') {
208
+ return llmUnavailableResponse({ reason: result.reason }, { dek: null, written: false })
209
+ }
210
+ return json({
211
+ dek: result.dek,
212
+ written: result.written,
213
+ ...(result.status === 'locked' ? { locked: true } : {}),
214
+ })
215
+ }
216
+
174
217
  async function proofreadAndTighten(ctx: RouteContext): Promise<Response> {
175
218
  // Guard: edit_own_draft capability (any member; ownership-aware write-back).
176
219
  const authGuard = await aiGuard(resolved.authz, ctx)
@@ -452,6 +495,7 @@ export function createAiRoutes(config: CmsRouteConfig): AiRouteHandlers {
452
495
  }
453
496
 
454
497
  return {
498
+ suggestDek,
455
499
  proofreadAndTighten,
456
500
  suggestHeadlines,
457
501
  generateKeyTakeaways,
@@ -54,6 +54,7 @@ export const CAPABILITY_MATRIX: Record<Capability, ReadonlySet<CmsRole>> = {
54
54
  publish_article: new Set<CmsRole>(['owner', 'senior_editor', 'editor']),
55
55
  publish_video: new Set<CmsRole>(['owner', 'senior_editor', 'editor', 'producer']),
56
56
  edit_own_draft: new Set<CmsRole>(['owner', 'senior_editor', 'editor', 'contributor', 'producer']),
57
+ edit_published: new Set<CmsRole>(['owner', 'senior_editor', 'editor']),
57
58
  publish_others_draft: new Set<CmsRole>(['owner', 'senior_editor', 'editor']),
58
59
  submit_for_review: new Set<CmsRole>([
59
60
  'owner',
@@ -150,6 +151,7 @@ export function canActOnContent(
150
151
  * - 'write' — create / update / save a draft, set-status, snapshot,
151
152
  * contributors edit. Lowest bar: edit_own_draft (any member;
152
153
  * contributor/producer gated to OWN content by canActOnContent).
154
+ * - 'write_published' — update published copy. Explicitly editorial-only.
153
155
  * - 'publish' — publish/unpublish/archive/unschedule an article. Requires
154
156
  * publish_others_draft (owner/senior_editor/editor); on OWN
155
157
  * content publish_article suffices (still excludes contributor).
@@ -158,7 +160,14 @@ export function canActOnContent(
158
160
  * - 'trash' — soft-delete / bulk-trash. Destructive → publish_others_draft.
159
161
  * - 'media' — dispatch-to-foundry / regenerate AI artefacts. media_pipeline.
160
162
  */
161
- export type ContentAction = 'write' | 'publish' | 'publish_video' | 'schedule' | 'trash' | 'media'
163
+ export type ContentAction =
164
+ | 'write'
165
+ | 'write_published'
166
+ | 'publish'
167
+ | 'publish_video'
168
+ | 'schedule'
169
+ | 'trash'
170
+ | 'media'
162
171
 
163
172
  /**
164
173
  * Resolve whether `role` may perform a content `action` on an item authored by
@@ -180,6 +189,8 @@ export function canPerformContentAction(
180
189
  switch (action) {
181
190
  case 'write':
182
191
  return canActOnContent(role, ownership, 'edit_own_draft')
192
+ case 'write_published':
193
+ return canActOnContent(role, ownership, 'edit_published')
183
194
  case 'media':
184
195
  return canActOnContent(role, ownership, 'media_pipeline')
185
196
  case 'publish_video':
@@ -16,6 +16,8 @@
16
16
 
17
17
  import { z } from 'zod'
18
18
  import { logActivity } from '../engine/activity-log.js'
19
+ import { generateContentDek } from '../engine/ai-dek.js'
20
+ import { createDekFailureNotifications } from '../engine/ai-dek-notifications.js'
19
21
  import { listContributors, setContributors } from '../engine/contributors.js'
20
22
  import type { D1Database } from '../engine/d1.js'
21
23
  import {
@@ -823,6 +825,7 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
823
825
  const cursor = parseCursor(url.searchParams.get('cursor'))
824
826
  const q = url.searchParams.get('q') || null
825
827
  const author = url.searchParams.get('author') || null
828
+ const channel = url.searchParams.get('channel') || null
826
829
 
827
830
  const bindings: unknown[] = []
828
831
  // Always exclude soft-deleted rows (migration 0002 added deleted_at).
@@ -844,6 +847,10 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
844
847
  where += ' AND c.author_id = ?'
845
848
  bindings.push(author)
846
849
  }
850
+ if (channel) {
851
+ where += ' AND c.channel = ?'
852
+ bindings.push(channel)
853
+ }
847
854
  if (cursor) {
848
855
  where += ' AND (c.updated_at < ? OR (c.updated_at = ? AND c.id < ?))'
849
856
  bindings.push(cursor.updatedAt, cursor.updatedAt, cursor.id)
@@ -857,7 +864,7 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
857
864
  // EVERY row, mis-stamping all rows as PROCESSING. Mirrors the JOIN the
858
865
  // counts handler below already uses.
859
866
  const query = `
860
- SELECT c.id, c.type, c.status, c.slug, c.title, c.visibility, c.featured,
867
+ SELECT c.id, c.type, c.status, c.slug, c.title, c.channel, c.visibility, c.featured,
861
868
  c.publish_at, c.published_at, c.updated_at, c.author_id,
862
869
  a.slug AS author_slug, a.name AS author_name,
863
870
  vc.processing_state AS processing_state
@@ -877,6 +884,7 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
877
884
  status: ContentStatus
878
885
  slug: string
879
886
  title: string
887
+ channel: string | null
880
888
  visibility: 'free' | 'premium'
881
889
  featured: number | null
882
890
  publish_at: number | null
@@ -1126,7 +1134,11 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1126
1134
  if (!existing) return json({ error: 'Not found' }, 404)
1127
1135
 
1128
1136
  // Edit = write; contributor/producer gated to their own content.
1129
- const denied = await requireContentAction(ctx, 'write', existing.author_id)
1137
+ const denied = await requireContentAction(
1138
+ ctx,
1139
+ existing.status === 'published' ? 'write_published' : 'write',
1140
+ existing.author_id,
1141
+ )
1130
1142
  if (denied) return denied
1131
1143
 
1132
1144
  const parsed = UpdateSchema.safeParse(await readJson(ctx))
@@ -1320,6 +1332,44 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1320
1332
  422,
1321
1333
  )
1322
1334
  }
1335
+
1336
+ if (!existing.excerpt?.trim()) {
1337
+ let failureReason: string | null = null
1338
+ if (!resolved.hooks.llm) {
1339
+ failureReason = 'AI generation is not configured'
1340
+ } else {
1341
+ try {
1342
+ const generated = await generateContentDek({
1343
+ db: ctx.db,
1344
+ contentId: id,
1345
+ llm: resolved.hooks.llm,
1346
+ })
1347
+ if (generated.status === 'unavailable' || generated.status === 'not_found') {
1348
+ failureReason = generated.reason
1349
+ }
1350
+ } catch (error) {
1351
+ failureReason = error instanceof Error ? error.message : String(error)
1352
+ }
1353
+ }
1354
+ if (failureReason) {
1355
+ console.error('[cms] dek generation failed; publishing without a subheading', {
1356
+ contentId: id,
1357
+ reason: failureReason,
1358
+ })
1359
+ try {
1360
+ await createDekFailureNotifications({
1361
+ db: ctx.db,
1362
+ workspaceId: activeWorkspaceId,
1363
+ contentId: id,
1364
+ title: existing.title,
1365
+ reason: failureReason,
1366
+ userId: ctx.userId,
1367
+ })
1368
+ } catch (notificationError) {
1369
+ console.error('[cms] failed to create dek generation notification', notificationError)
1370
+ }
1371
+ }
1372
+ }
1323
1373
  await publishContent(ctx.db, id, now)
1324
1374
  await createRevision(ctx.db, id, ctx.userId || null)
1325
1375
 
@@ -25,6 +25,7 @@ export type Capability =
25
25
  | 'publish_article'
26
26
  | 'publish_video'
27
27
  | 'edit_own_draft'
28
+ | 'edit_published'
28
29
  | 'publish_others_draft'
29
30
  | 'submit_for_review'
30
31
  | 'media_pipeline'
@@ -19,6 +19,7 @@ import { buildSiteAuthz } from './_authz.js'
19
19
  * ContentRouteConfig so adapters can surface them without a separate locals key.
20
20
  */
21
21
  type CmsHooksLocals = ContentRouteConfig['hooks'] & {
22
+ activeWorkspaceId?: string
22
23
  dispatchTakeaways?: DispatchTakeawaysHook
23
24
  dispatchFaq?: DispatchFaqHook
24
25
  dispatchWebhook?: DispatchWebhookHook
@@ -62,6 +63,7 @@ export function toContentConfig(c: APIContext): ContentRouteConfig {
62
63
  const hooks = locals.cmsHooks ?? {}
63
64
  return {
64
65
  authz: buildSiteAuthz(c),
66
+ activeWorkspaceId: hooks.activeWorkspaceId,
65
67
  hooks: {
66
68
  llm: hooks.llm,
67
69
  foundryVideo: hooks.foundryVideo,
@@ -0,0 +1,8 @@
1
+ // POST /admin/api/content/:id/ai/dek — generate and persist a display subheading.
2
+
3
+ import type { APIContext } from 'astro'
4
+ import { createAiRoutes } from '../../../../../routes/ai.js'
5
+ import { toContentConfig, toContentCtx } from '../../../_content-config.js'
6
+
7
+ export const POST = (c: APIContext) =>
8
+ createAiRoutes(toContentConfig(c)).suggestDek(toContentCtx(c, { id: c.params.id ?? '' }))
@@ -37,6 +37,7 @@ export async function mount(elementId = 'masthead-root') {
37
37
  const initial = initialWorkspaceState(config.workspaces, config.activeWorkspaceId, user)
38
38
  const screens = createCmsScreens({
39
39
  allowInlineAuthorCreation: config.socialSharing.allowInlineAuthorCreation,
40
+ primaryCategories: config.primaryCategories,
40
41
  surveyId: config.surveyResults?.surveyId,
41
42
  })
42
43
  createRoot(el).render(<CmsApp initial={initial} screens={screens} boot={boot} />)
@@ -27,6 +27,13 @@ interface ActionMeta {
27
27
  }
28
28
 
29
29
  const ACTIONS: ActionMeta[] = [
30
+ {
31
+ action: 'dek',
32
+ icon: 'sparkle',
33
+ title: 'Suggest subheading',
34
+ description: 'Display copy in house voice',
35
+ kind: 'sync',
36
+ },
30
37
  {
31
38
  action: 'proofread',
32
39
  icon: 'wand',
@@ -74,6 +81,10 @@ interface PanelState {
74
81
  headlines: string[]
75
82
  /** Meta description text from the sync result (for the "Apply" button). */
76
83
  metaDescription: string | null
84
+ /** Generated display subheading. */
85
+ dek: string | null
86
+ /** Whether the generated subheading was applied to the controlled form. */
87
+ dekApplied: boolean
77
88
  /** Whether the meta was applied/written. */
78
89
  metaApplied: boolean
79
90
  /** Whether the takeaways were written (server-side). */
@@ -89,6 +100,8 @@ const INITIAL_STATE: PanelState = {
89
100
  activeAction: null,
90
101
  headlines: [],
91
102
  metaDescription: null,
103
+ dek: null,
104
+ dekApplied: false,
92
105
  metaApplied: false,
93
106
  takeawaysWritten: false,
94
107
  locked: false,
@@ -103,9 +116,11 @@ export interface AiAssistPanelProps {
103
116
  docId: string
104
117
  /** Called when the meta description should be applied to the SEO description field. */
105
118
  onMetaApplied?: (description: string) => void
119
+ /** Called after an unlocked generated dek is stored, keeping the form in sync. */
120
+ onDekApplied?: (dek: string) => void
106
121
  }
107
122
 
108
- export function AiAssistPanel({ docId, onMetaApplied }: AiAssistPanelProps) {
123
+ export function AiAssistPanel({ docId, onMetaApplied, onDekApplied }: AiAssistPanelProps) {
109
124
  const [state, setState] = useState<PanelState>(INITIAL_STATE)
110
125
 
111
126
  const handleAction = useCallback(
@@ -118,6 +133,8 @@ export function AiAssistPanel({ docId, onMetaApplied }: AiAssistPanelProps) {
118
133
  activeAction: meta.action,
119
134
  headlines: [],
120
135
  metaDescription: null,
136
+ dek: null,
137
+ dekApplied: false,
121
138
  metaApplied: false,
122
139
  takeawaysWritten: false,
123
140
  locked: false,
@@ -142,7 +159,18 @@ export function AiAssistPanel({ docId, onMetaApplied }: AiAssistPanelProps) {
142
159
  } else {
143
160
  // Sync: takeaways or meta-description
144
161
  const data = (await syncAi(meta.action, docId)) as Record<string, unknown>
145
- if (meta.action === 'meta-description') {
162
+ if (meta.action === 'dek') {
163
+ const dek = typeof data.dek === 'string' ? data.dek : null
164
+ const written = data.written === true
165
+ if (dek && written) onDekApplied?.(dek)
166
+ setState((prev) => ({
167
+ ...prev,
168
+ running: null,
169
+ dek,
170
+ dekApplied: Boolean(dek && written),
171
+ locked: data.locked === true,
172
+ }))
173
+ } else if (meta.action === 'meta-description') {
146
174
  setState((prev) => ({
147
175
  ...prev,
148
176
  running: null,
@@ -167,7 +195,7 @@ export function AiAssistPanel({ docId, onMetaApplied }: AiAssistPanelProps) {
167
195
  }))
168
196
  }
169
197
  },
170
- [docId],
198
+ [docId, onDekApplied],
171
199
  )
172
200
 
173
201
  const applyMeta = useCallback(async () => {
@@ -262,6 +290,30 @@ export function AiAssistPanel({ docId, onMetaApplied }: AiAssistPanelProps) {
262
290
  )}
263
291
 
264
292
  {/* Result pane — sync: meta description */}
293
+ {state.activeAction === 'dek' && state.dek !== null && (
294
+ <div style={resultPane}>
295
+ <div style={resultHeader}>
296
+ <span style={{ fontSize: 12, fontWeight: 600, color: 'var(--ink-soft)' }}>
297
+ Suggested subheading
298
+ </span>
299
+ {state.locked && (
300
+ <span style={{ fontSize: 11, color: 'var(--amber)', marginLeft: 6 }}>
301
+ field locked — not written
302
+ </span>
303
+ )}
304
+ </div>
305
+ <p style={{ fontSize: 13, color: 'var(--ink)', lineHeight: 1.5, margin: '6px 0' }}>
306
+ {state.dek}
307
+ </p>
308
+ {state.dekApplied && (
309
+ <span style={{ fontSize: 12, color: 'var(--green)' }}>
310
+ <Icon name="checkCircle" size={13} style={{ marginRight: 4 }} />
311
+ Applied to subheading
312
+ </span>
313
+ )}
314
+ </div>
315
+ )}
316
+
265
317
  {state.activeAction === 'meta-description' && state.metaDescription !== null && (
266
318
  <div style={resultPane}>
267
319
  <div style={resultHeader}>