@growth-labs/cms 0.5.5 → 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 (164) hide show
  1. package/README.md +46 -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/index.d.ts +2 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +2 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/integration/index.d.ts.map +1 -1
  23. package/dist/integration/index.js +13 -0
  24. package/dist/integration/index.js.map +1 -1
  25. package/dist/integration/options.d.ts +19 -0
  26. package/dist/integration/options.d.ts.map +1 -1
  27. package/dist/integration/options.js +7 -0
  28. package/dist/integration/options.js.map +1 -1
  29. package/dist/providers/null.d.ts +2 -1
  30. package/dist/providers/null.d.ts.map +1 -1
  31. package/dist/providers/null.js +9 -0
  32. package/dist/providers/null.js.map +1 -1
  33. package/dist/providers/types.d.ts +10 -0
  34. package/dist/providers/types.d.ts.map +1 -1
  35. package/dist/providers/types.js.map +1 -1
  36. package/dist/routes/ai.d.ts +2 -0
  37. package/dist/routes/ai.d.ts.map +1 -1
  38. package/dist/routes/ai.js +42 -0
  39. package/dist/routes/ai.js.map +1 -1
  40. package/dist/routes/authz-matrix.d.ts +2 -1
  41. package/dist/routes/authz-matrix.d.ts.map +1 -1
  42. package/dist/routes/authz-matrix.js +5 -0
  43. package/dist/routes/authz-matrix.js.map +1 -1
  44. package/dist/routes/content.d.ts.map +1 -1
  45. package/dist/routes/content.js +43 -1
  46. package/dist/routes/content.js.map +1 -1
  47. package/dist/routes/context.d.ts +1 -1
  48. package/dist/routes/context.d.ts.map +1 -1
  49. package/dist/routes/context.js.map +1 -1
  50. package/dist/routes/index.d.ts +1 -0
  51. package/dist/routes/index.d.ts.map +1 -1
  52. package/dist/routes/index.js +2 -0
  53. package/dist/routes/index.js.map +1 -1
  54. package/dist/routes/surveys.d.ts +12 -0
  55. package/dist/routes/surveys.d.ts.map +1 -0
  56. package/dist/routes/surveys.js +109 -0
  57. package/dist/routes/surveys.js.map +1 -0
  58. package/dist/schema/insights-ingest.d.ts +46 -46
  59. package/dist/surveys/access.d.ts +4 -0
  60. package/dist/surveys/access.d.ts.map +1 -0
  61. package/dist/surveys/access.js +9 -0
  62. package/dist/surveys/access.js.map +1 -0
  63. package/dist/surveys/errors.d.ts +43 -0
  64. package/dist/surveys/errors.d.ts.map +1 -0
  65. package/dist/surveys/errors.js +42 -0
  66. package/dist/surveys/errors.js.map +1 -0
  67. package/dist/surveys/schema.d.ts +939 -0
  68. package/dist/surveys/schema.d.ts.map +1 -0
  69. package/dist/surveys/schema.js +385 -0
  70. package/dist/surveys/schema.js.map +1 -0
  71. package/dist/ui/api/_content-config.d.ts.map +1 -1
  72. package/dist/ui/api/_content-config.js +1 -0
  73. package/dist/ui/api/_content-config.js.map +1 -1
  74. package/dist/ui/api/content/[id]/ai/dek.d.ts +3 -0
  75. package/dist/ui/api/content/[id]/ai/dek.d.ts.map +1 -0
  76. package/dist/ui/api/content/[id]/ai/dek.js +5 -0
  77. package/dist/ui/api/content/[id]/ai/dek.js.map +1 -0
  78. package/dist/ui/api/surveys/[surveyId]/responses.csv.d.ts +3 -0
  79. package/dist/ui/api/surveys/[surveyId]/responses.csv.d.ts.map +1 -0
  80. package/dist/ui/api/surveys/[surveyId]/responses.csv.js +17 -0
  81. package/dist/ui/api/surveys/[surveyId]/responses.csv.js.map +1 -0
  82. package/dist/ui/api/surveys/[surveyId].d.ts +3 -0
  83. package/dist/ui/api/surveys/[surveyId].d.ts.map +1 -0
  84. package/dist/ui/api/surveys/[surveyId].js +17 -0
  85. package/dist/ui/api/surveys/[surveyId].js.map +1 -0
  86. package/dist/ui/client/mount.d.ts.map +1 -1
  87. package/dist/ui/client/mount.js +1 -0
  88. package/dist/ui/client/mount.js.map +1 -1
  89. package/dist/ui/commands.d.ts +5 -1
  90. package/dist/ui/commands.d.ts.map +1 -1
  91. package/dist/ui/commands.js +7 -0
  92. package/dist/ui/commands.js.map +1 -1
  93. package/dist/ui/components/CmsApp.d.ts +2 -0
  94. package/dist/ui/components/CmsApp.d.ts.map +1 -1
  95. package/dist/ui/components/CmsApp.js +14 -3
  96. package/dist/ui/components/CmsApp.js.map +1 -1
  97. package/dist/ui/components/CommandPalette.d.ts +2 -0
  98. package/dist/ui/components/CommandPalette.d.ts.map +1 -1
  99. package/dist/ui/components/CommandPalette.js +1 -1
  100. package/dist/ui/components/CommandPalette.js.map +1 -1
  101. package/dist/ui/components/Sidebar.d.ts +1 -0
  102. package/dist/ui/components/Sidebar.d.ts.map +1 -1
  103. package/dist/ui/components/Sidebar.js +1 -1
  104. package/dist/ui/components/Sidebar.js.map +1 -1
  105. package/dist/ui/editor/AiAssistPanel.d.ts +3 -1
  106. package/dist/ui/editor/AiAssistPanel.d.ts.map +1 -1
  107. package/dist/ui/editor/AiAssistPanel.js +28 -4
  108. package/dist/ui/editor/AiAssistPanel.js.map +1 -1
  109. package/dist/ui/editor/ContentForm.d.ts.map +1 -1
  110. package/dist/ui/editor/ContentForm.js +1 -1
  111. package/dist/ui/editor/ContentForm.js.map +1 -1
  112. package/dist/ui/editor/ai-assist.d.ts +2 -2
  113. package/dist/ui/editor/ai-assist.d.ts.map +1 -1
  114. package/dist/ui/hash-router.d.ts +1 -1
  115. package/dist/ui/hash-router.d.ts.map +1 -1
  116. package/dist/ui/hash-router.js +1 -0
  117. package/dist/ui/hash-router.js.map +1 -1
  118. package/dist/ui/nav.d.ts +5 -1
  119. package/dist/ui/nav.d.ts.map +1 -1
  120. package/dist/ui/nav.js +10 -2
  121. package/dist/ui/nav.js.map +1 -1
  122. package/dist/ui/screens/SurveyResponsesScreen.d.ts +28 -0
  123. package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -0
  124. package/dist/ui/screens/SurveyResponsesScreen.js +110 -0
  125. package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -0
  126. package/dist/ui/screens/registry.d.ts +2 -1
  127. package/dist/ui/screens/registry.d.ts.map +1 -1
  128. package/dist/ui/screens/registry.js +5 -1
  129. package/dist/ui/screens/registry.js.map +1 -1
  130. package/package.json +1 -1
  131. package/src/engine/ai-dek-notifications.ts +50 -0
  132. package/src/engine/ai-dek.ts +138 -0
  133. package/src/engine/ai-prompts.ts +25 -0
  134. package/src/engine/index.ts +14 -0
  135. package/src/index.ts +2 -2
  136. package/src/integration/index.ts +13 -0
  137. package/src/integration/options.ts +8 -0
  138. package/src/providers/null.ts +19 -0
  139. package/src/providers/types.ts +7 -0
  140. package/src/routes/ai.ts +44 -0
  141. package/src/routes/authz-matrix.ts +14 -1
  142. package/src/routes/content.ts +45 -1
  143. package/src/routes/context.ts +3 -0
  144. package/src/routes/index.ts +6 -0
  145. package/src/routes/surveys.ts +130 -0
  146. package/src/surveys/access.ts +11 -0
  147. package/src/surveys/errors.ts +51 -0
  148. package/src/surveys/schema.ts +435 -0
  149. package/src/ui/api/_content-config.ts +2 -0
  150. package/src/ui/api/content/[id]/ai/dek.ts +8 -0
  151. package/src/ui/api/surveys/[surveyId]/responses.csv.ts +23 -0
  152. package/src/ui/api/surveys/[surveyId].ts +23 -0
  153. package/src/ui/client/mount.tsx +1 -0
  154. package/src/ui/commands.ts +14 -1
  155. package/src/ui/components/CmsApp.tsx +25 -3
  156. package/src/ui/components/CommandPalette.tsx +2 -1
  157. package/src/ui/components/Sidebar.tsx +10 -2
  158. package/src/ui/editor/AiAssistPanel.tsx +55 -3
  159. package/src/ui/editor/ContentForm.tsx +3 -1
  160. package/src/ui/editor/ai-assist.ts +2 -2
  161. package/src/ui/hash-router.ts +1 -0
  162. package/src/ui/nav.ts +15 -2
  163. package/src/ui/screens/SurveyResponsesScreen.tsx +271 -0
  164. package/src/ui/screens/registry.tsx +6 -0
@@ -1,4 +1,5 @@
1
1
  import type { SeoScoreInput, SeoScoreResult } from '../engine/seo.js'
2
+ import type { SurveyCsvResult, SurveyResults } from '../surveys/schema.js'
2
3
 
3
4
  export type ProviderStatus = 'live' | 'partial' | 'empty'
4
5
  export interface ProviderResult<T> {
@@ -402,6 +403,11 @@ export interface SettingsProvider {
402
403
  getIntegrations(): Promise<ProviderResult<Record<string, CmsIntegrationStatus | null>>>
403
404
  }
404
405
 
406
+ export interface SurveyProvider {
407
+ getResults(input: { surveyId: string }): Promise<SurveyResults>
408
+ getCsv(input: { surveyId: string }): Promise<SurveyCsvResult>
409
+ }
410
+
405
411
  export interface CmsProviders {
406
412
  analytics: AnalyticsProvider
407
413
  subscriptions: SubscriptionsProvider
@@ -409,4 +415,5 @@ export interface CmsProviders {
409
415
  seo: SeoProvider
410
416
  search: SearchProvider
411
417
  settings: SettingsProvider
418
+ surveys?: SurveyProvider
412
419
  }
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,
@@ -20,6 +20,7 @@
20
20
 
21
21
  import type { MembershipRecord } from '../engine/members.js'
22
22
  import type { CmsRole } from '../schema/types.js'
23
+ import { SURVEY_RESPONSE_ROLES } from '../surveys/access.js'
23
24
  import type { Capability, CmsUser, RouteContext } from './context.js'
24
25
  import { json } from './context.js'
25
26
 
@@ -53,6 +54,7 @@ export const CAPABILITY_MATRIX: Record<Capability, ReadonlySet<CmsRole>> = {
53
54
  publish_article: new Set<CmsRole>(['owner', 'senior_editor', 'editor']),
54
55
  publish_video: new Set<CmsRole>(['owner', 'senior_editor', 'editor', 'producer']),
55
56
  edit_own_draft: new Set<CmsRole>(['owner', 'senior_editor', 'editor', 'contributor', 'producer']),
57
+ edit_published: new Set<CmsRole>(['owner', 'senior_editor', 'editor']),
56
58
  publish_others_draft: new Set<CmsRole>(['owner', 'senior_editor', 'editor']),
57
59
  submit_for_review: new Set<CmsRole>([
58
60
  'owner',
@@ -75,6 +77,7 @@ export const CAPABILITY_MATRIX: Record<Capability, ReadonlySet<CmsRole>> = {
75
77
  // Complimentary access is an editorial admin action by explicit operator
76
78
  // decision; it is not subscriber-PII export or owner-only settings.
77
79
  manage_complimentary_access: new Set<CmsRole>(['owner', 'senior_editor', 'editor']),
80
+ view_survey_responses: SURVEY_RESPONSE_ROLES,
78
81
  }
79
82
 
80
83
  // ---------------------------------------------------------------------------
@@ -148,6 +151,7 @@ export function canActOnContent(
148
151
  * - 'write' — create / update / save a draft, set-status, snapshot,
149
152
  * contributors edit. Lowest bar: edit_own_draft (any member;
150
153
  * contributor/producer gated to OWN content by canActOnContent).
154
+ * - 'write_published' — update published copy. Explicitly editorial-only.
151
155
  * - 'publish' — publish/unpublish/archive/unschedule an article. Requires
152
156
  * publish_others_draft (owner/senior_editor/editor); on OWN
153
157
  * content publish_article suffices (still excludes contributor).
@@ -156,7 +160,14 @@ export function canActOnContent(
156
160
  * - 'trash' — soft-delete / bulk-trash. Destructive → publish_others_draft.
157
161
  * - 'media' — dispatch-to-foundry / regenerate AI artefacts. media_pipeline.
158
162
  */
159
- 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'
160
171
 
161
172
  /**
162
173
  * Resolve whether `role` may perform a content `action` on an item authored by
@@ -178,6 +189,8 @@ export function canPerformContentAction(
178
189
  switch (action) {
179
190
  case 'write':
180
191
  return canActOnContent(role, ownership, 'edit_own_draft')
192
+ case 'write_published':
193
+ return canActOnContent(role, ownership, 'edit_published')
181
194
  case 'media':
182
195
  return canActOnContent(role, ownership, 'media_pipeline')
183
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'
@@ -38,6 +39,8 @@ export type Capability =
38
39
  // Complimentary access administration. Explicit operator decision: owner,
39
40
  // senior_editor, and editor may grant/revoke/retry; contributor/producer may not.
40
41
  | 'manage_complimentary_access'
42
+ // Read-only survey reporting. No raw respondent identity is exposed in the screen contract.
43
+ | 'view_survey_responses'
41
44
 
42
45
  export interface CmsUser {
43
46
  subject: string
@@ -163,6 +163,12 @@ export {
163
163
  type SubscriptionsRouteConfig,
164
164
  type SubscriptionsRouteHandlers,
165
165
  } from './subscriptions.js'
166
+ // Survey reporting routes — optional storage-neutral provider, protected by role capability.
167
+ export {
168
+ createSurveyRoutes,
169
+ type SurveyRouteConfig,
170
+ type SurveyRouteHandlers,
171
+ } from './surveys.js'
166
172
  // Tag management routes — GET /tags, GET /tags?q=, DELETE /tags/:id (P3 Task 9).
167
173
  export { createTagRoutes, type TagRouteHandlers } from './tags.js'
168
174
  // Topic management over content_items.primary_topic.
@@ -0,0 +1,130 @@
1
+ import { nullProviders } from '../providers/null.js'
2
+ import { type CmsProviders, ProviderHttpError } from '../providers/types.js'
3
+ import {
4
+ SURVEY_ERROR_CODE_HEADER,
5
+ SURVEY_ERROR_DEFINITIONS,
6
+ type SurveyErrorCode,
7
+ surveyErrorBody,
8
+ } from '../surveys/errors.js'
9
+ import { SurveyCsvResultSchema, SurveyIdSchema, SurveyResultsSchema } from '../surveys/schema.js'
10
+ import type { Authz, Capability, RouteContext } from './context.js'
11
+
12
+ export interface SurveyRouteConfig {
13
+ authz: Authz
14
+ providers?: Partial<CmsProviders>
15
+ }
16
+
17
+ export interface SurveyRouteHandlers {
18
+ results(ctx: RouteContext): Promise<Response>
19
+ csv(ctx: RouteContext): Promise<Response>
20
+ }
21
+
22
+ export function createSurveyRoutes(config: SurveyRouteConfig): SurveyRouteHandlers {
23
+ const providers: CmsProviders = { ...nullProviders, ...(config.providers ?? {}) }
24
+
25
+ return {
26
+ async results(ctx) {
27
+ const denied = await requireSurveyAccess(config.authz, ctx)
28
+ if (denied) return withNoStoreAuthFailure(denied)
29
+ const surveyId = parseSurveyId(ctx)
30
+ if (!surveyId) return surveyErrorResponse('survey_invalid_request')
31
+ try {
32
+ const parsed = SurveyResultsSchema.safeParse(
33
+ await providers.surveys?.getResults({ surveyId }),
34
+ )
35
+ if (!parsed.success || parsed.data.survey.id !== surveyId) {
36
+ return surveyErrorResponse('survey_provider_malformed')
37
+ }
38
+ return responseJson(parsed.data)
39
+ } catch (error) {
40
+ return providerFailure(error)
41
+ }
42
+ },
43
+
44
+ async csv(ctx) {
45
+ const denied = await requireSurveyAccess(config.authz, ctx)
46
+ if (denied) return withNoStoreAuthFailure(denied)
47
+ const surveyId = parseSurveyId(ctx)
48
+ if (!surveyId) return surveyErrorResponse('survey_invalid_request')
49
+ try {
50
+ const parsed = SurveyCsvResultSchema.safeParse(
51
+ await providers.surveys?.getCsv({ surveyId }),
52
+ )
53
+ if (!parsed.success) return surveyErrorResponse('survey_provider_malformed')
54
+ return new Response(parsed.data.content, {
55
+ headers: {
56
+ 'Cache-Control': 'no-store',
57
+ 'Content-Disposition': `attachment; filename="${safeCsvFilename(parsed.data.filename)}"`,
58
+ 'Content-Type': 'text/csv; charset=utf-8',
59
+ },
60
+ })
61
+ } catch (error) {
62
+ return providerFailure(error)
63
+ }
64
+ },
65
+ }
66
+ }
67
+
68
+ async function requireSurveyAccess(authz: Authz, ctx: RouteContext): Promise<Response | null> {
69
+ if (!authz.requireRole) return surveyErrorResponse('survey_internal_failure')
70
+ return authz.requireRole('view_survey_responses' satisfies Capability, ctx)
71
+ }
72
+
73
+ function parseSurveyId(ctx: RouteContext): string | null {
74
+ const value = ctx.params.surveyId ?? ctx.params.id
75
+ const parsed = SurveyIdSchema.safeParse(value)
76
+ return parsed.success ? parsed.data : null
77
+ }
78
+
79
+ function safeCsvFilename(value: string): string {
80
+ const basename =
81
+ value
82
+ .split(/[\\/]/)
83
+ .at(-1)
84
+ ?.replace(/[\r\n]/g, '') ?? ''
85
+ const safe = basename
86
+ .replace(/[^A-Za-z0-9._-]+/g, '-')
87
+ .replace(/^\.+/, '')
88
+ .replace(/-+/g, '-')
89
+ .slice(0, 180)
90
+ return safe.endsWith('.csv') ? safe : `${safe || 'survey-responses'}.csv`
91
+ }
92
+
93
+ function providerFailure(error: unknown): Response {
94
+ if (error instanceof ProviderHttpError) {
95
+ const mapped = ProviderFailureAllowlist.get(`${error.status}:${error.code}`)
96
+ if (mapped) return surveyErrorResponse(mapped)
97
+ }
98
+ return surveyErrorResponse('survey_upstream_failure')
99
+ }
100
+
101
+ const ProviderFailureAllowlist = new Map<string, SurveyErrorCode>([
102
+ ['403:survey_forbidden', 'survey_forbidden'],
103
+ ['404:survey_not_found', 'survey_not_found'],
104
+ ['500:survey_internal_failure', 'survey_internal_failure'],
105
+ ['502:survey_upstream_failure', 'survey_upstream_failure'],
106
+ ['503:survey_provider_unavailable', 'survey_provider_unavailable'],
107
+ ['503:survey_upstream_unavailable', 'survey_upstream_unavailable'],
108
+ ])
109
+
110
+ function surveyErrorResponse(code: SurveyErrorCode): Response {
111
+ return responseJson(surveyErrorBody(code), SURVEY_ERROR_DEFINITIONS[code].status, code)
112
+ }
113
+
114
+ function withNoStoreAuthFailure(response: Response): Response {
115
+ const cloned = response.clone()
116
+ cloned.headers.set('Cache-Control', 'no-store')
117
+ if (cloned.status === 401 || cloned.status === 403) {
118
+ cloned.headers.set(SURVEY_ERROR_CODE_HEADER, 'survey_forbidden')
119
+ }
120
+ return cloned
121
+ }
122
+
123
+ function responseJson(body: unknown, status = 200, code?: SurveyErrorCode): Response {
124
+ const headers = new Headers({ 'Cache-Control': 'no-store', 'Content-Type': 'application/json' })
125
+ if (code) headers.set(SURVEY_ERROR_CODE_HEADER, code)
126
+ return new Response(JSON.stringify(body), {
127
+ status,
128
+ headers,
129
+ })
130
+ }
@@ -0,0 +1,11 @@
1
+ import type { CmsRole } from '../schema/types.js'
2
+
3
+ export const SURVEY_RESPONSE_ROLES: ReadonlySet<CmsRole> = new Set([
4
+ 'owner',
5
+ 'senior_editor',
6
+ 'editor',
7
+ ])
8
+
9
+ export function canViewSurveyResponses(role: CmsRole | null | undefined): boolean {
10
+ return role != null && SURVEY_RESPONSE_ROLES.has(role)
11
+ }
@@ -0,0 +1,51 @@
1
+ export const SURVEY_ERROR_CODE_HEADER = 'X-Growth-Labs-Survey-Error-Code'
2
+
3
+ export const SURVEY_ERROR_DEFINITIONS = {
4
+ survey_invalid_request: {
5
+ status: 400,
6
+ error: 'Invalid survey request.',
7
+ },
8
+ survey_forbidden: {
9
+ status: 403,
10
+ error: 'You do not have permission to view survey responses.',
11
+ },
12
+ survey_not_found: {
13
+ status: 404,
14
+ error: 'Survey not found.',
15
+ },
16
+ survey_provider_unavailable: {
17
+ status: 503,
18
+ error: 'Survey reporting is not configured for this site.',
19
+ },
20
+ survey_provider_malformed: {
21
+ status: 502,
22
+ error: 'Survey provider returned invalid data.',
23
+ },
24
+ survey_upstream_unavailable: {
25
+ status: 503,
26
+ error: 'Survey reporting is temporarily unavailable.',
27
+ },
28
+ survey_upstream_failure: {
29
+ status: 502,
30
+ error: 'Survey reporting could not be loaded.',
31
+ },
32
+ survey_internal_failure: {
33
+ status: 500,
34
+ error: 'Survey reporting could not be loaded.',
35
+ },
36
+ } as const
37
+
38
+ export type SurveyErrorCode = keyof typeof SURVEY_ERROR_DEFINITIONS
39
+
40
+ export interface SurveyErrorBody {
41
+ error: string
42
+ code: SurveyErrorCode
43
+ }
44
+
45
+ export function isSurveyErrorCode(value: unknown): value is SurveyErrorCode {
46
+ return typeof value === 'string' && Object.hasOwn(SURVEY_ERROR_DEFINITIONS, value)
47
+ }
48
+
49
+ export function surveyErrorBody(code: SurveyErrorCode): SurveyErrorBody {
50
+ return { error: SURVEY_ERROR_DEFINITIONS[code].error, code }
51
+ }