@growth-labs/cms 0.5.6 → 0.5.7

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 (66) hide show
  1. package/README.md +10 -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/routes/ai.d.ts +2 -0
  22. package/dist/routes/ai.d.ts.map +1 -1
  23. package/dist/routes/ai.js +42 -0
  24. package/dist/routes/ai.js.map +1 -1
  25. package/dist/routes/authz-matrix.d.ts +2 -1
  26. package/dist/routes/authz-matrix.d.ts.map +1 -1
  27. package/dist/routes/authz-matrix.js +3 -0
  28. package/dist/routes/authz-matrix.js.map +1 -1
  29. package/dist/routes/content.d.ts.map +1 -1
  30. package/dist/routes/content.js +43 -1
  31. package/dist/routes/content.js.map +1 -1
  32. package/dist/routes/context.d.ts +1 -1
  33. package/dist/routes/context.d.ts.map +1 -1
  34. package/dist/routes/context.js.map +1 -1
  35. package/dist/surveys/schema.d.ts +6 -6
  36. package/dist/ui/api/_content-config.d.ts.map +1 -1
  37. package/dist/ui/api/_content-config.js +1 -0
  38. package/dist/ui/api/_content-config.js.map +1 -1
  39. package/dist/ui/api/content/[id]/ai/dek.d.ts +3 -0
  40. package/dist/ui/api/content/[id]/ai/dek.d.ts.map +1 -0
  41. package/dist/ui/api/content/[id]/ai/dek.js +5 -0
  42. package/dist/ui/api/content/[id]/ai/dek.js.map +1 -0
  43. package/dist/ui/editor/AiAssistPanel.d.ts +3 -1
  44. package/dist/ui/editor/AiAssistPanel.d.ts.map +1 -1
  45. package/dist/ui/editor/AiAssistPanel.js +28 -4
  46. package/dist/ui/editor/AiAssistPanel.js.map +1 -1
  47. package/dist/ui/editor/ContentForm.d.ts.map +1 -1
  48. package/dist/ui/editor/ContentForm.js +1 -1
  49. package/dist/ui/editor/ContentForm.js.map +1 -1
  50. package/dist/ui/editor/ai-assist.d.ts +2 -2
  51. package/dist/ui/editor/ai-assist.d.ts.map +1 -1
  52. package/package.json +1 -1
  53. package/src/engine/ai-dek-notifications.ts +50 -0
  54. package/src/engine/ai-dek.ts +138 -0
  55. package/src/engine/ai-prompts.ts +25 -0
  56. package/src/engine/index.ts +14 -0
  57. package/src/integration/index.ts +4 -0
  58. package/src/routes/ai.ts +44 -0
  59. package/src/routes/authz-matrix.ts +12 -1
  60. package/src/routes/content.ts +45 -1
  61. package/src/routes/context.ts +1 -0
  62. package/src/ui/api/_content-config.ts +2 -0
  63. package/src/ui/api/content/[id]/ai/dek.ts +8 -0
  64. package/src/ui/editor/AiAssistPanel.tsx +55 -3
  65. package/src/ui/editor/ContentForm.tsx +3 -1
  66. package/src/ui/editor/ai-assist.ts +2 -2
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 {
@@ -1126,7 +1128,11 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1126
1128
  if (!existing) return json({ error: 'Not found' }, 404)
1127
1129
 
1128
1130
  // Edit = write; contributor/producer gated to their own content.
1129
- const denied = await requireContentAction(ctx, 'write', existing.author_id)
1131
+ const denied = await requireContentAction(
1132
+ ctx,
1133
+ existing.status === 'published' ? 'write_published' : 'write',
1134
+ existing.author_id,
1135
+ )
1130
1136
  if (denied) return denied
1131
1137
 
1132
1138
  const parsed = UpdateSchema.safeParse(await readJson(ctx))
@@ -1320,6 +1326,44 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
1320
1326
  422,
1321
1327
  )
1322
1328
  }
1329
+
1330
+ if (!existing.excerpt?.trim()) {
1331
+ let failureReason: string | null = null
1332
+ if (!resolved.hooks.llm) {
1333
+ failureReason = 'AI generation is not configured'
1334
+ } else {
1335
+ try {
1336
+ const generated = await generateContentDek({
1337
+ db: ctx.db,
1338
+ contentId: id,
1339
+ llm: resolved.hooks.llm,
1340
+ })
1341
+ if (generated.status === 'unavailable' || generated.status === 'not_found') {
1342
+ failureReason = generated.reason
1343
+ }
1344
+ } catch (error) {
1345
+ failureReason = error instanceof Error ? error.message : String(error)
1346
+ }
1347
+ }
1348
+ if (failureReason) {
1349
+ console.error('[cms] dek generation failed; publishing without a subheading', {
1350
+ contentId: id,
1351
+ reason: failureReason,
1352
+ })
1353
+ try {
1354
+ await createDekFailureNotifications({
1355
+ db: ctx.db,
1356
+ workspaceId: activeWorkspaceId,
1357
+ contentId: id,
1358
+ title: existing.title,
1359
+ reason: failureReason,
1360
+ userId: ctx.userId,
1361
+ })
1362
+ } catch (notificationError) {
1363
+ console.error('[cms] failed to create dek generation notification', notificationError)
1364
+ }
1365
+ }
1366
+ }
1323
1367
  await publishContent(ctx.db, id, now)
1324
1368
  await createRevision(ctx.db, id, ctx.userId || null)
1325
1369
 
@@ -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 ?? '' }))
@@ -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}>
@@ -795,7 +795,9 @@ export function ContentForm({
795
795
  </div>
796
796
 
797
797
  {/* AI assist panel — shown when toggle is on and doc is saved */}
798
- {form.aiOpen && form.savedId !== null && <AiAssistPanel docId={form.savedId} />}
798
+ {form.aiOpen && form.savedId !== null && (
799
+ <AiAssistPanel docId={form.savedId} onDekApplied={handleDekChange} />
800
+ )}
799
801
 
800
802
  {/* Title */}
801
803
  <div style={fieldGroup}>
@@ -10,8 +10,8 @@
10
10
  // streamAi(action, docId, onChunk) → reads SSE body incrementally
11
11
  // syncAi(action, docId) → POSTs and returns parsed JSON
12
12
 
13
- /** The four AI actions supported by the panel. */
14
- export type AiAction = 'proofread' | 'headlines' | 'takeaways' | 'meta-description'
13
+ /** The AI actions supported by the panel. */
14
+ export type AiAction = 'dek' | 'proofread' | 'headlines' | 'takeaways' | 'meta-description'
15
15
 
16
16
  /**
17
17
  * Build the URL for a given AI action + doc id.