@growth-labs/cms 0.5.4 → 0.5.6

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 (166) hide show
  1. package/README.md +55 -1
  2. package/dist/index.d.ts +2 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +2 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/integration/index.d.ts.map +1 -1
  7. package/dist/integration/index.js +9 -0
  8. package/dist/integration/index.js.map +1 -1
  9. package/dist/integration/options.d.ts +38 -0
  10. package/dist/integration/options.d.ts.map +1 -1
  11. package/dist/integration/options.js +13 -0
  12. package/dist/integration/options.js.map +1 -1
  13. package/dist/providers/null.d.ts +2 -1
  14. package/dist/providers/null.d.ts.map +1 -1
  15. package/dist/providers/null.js +9 -0
  16. package/dist/providers/null.js.map +1 -1
  17. package/dist/providers/types.d.ts +10 -0
  18. package/dist/providers/types.d.ts.map +1 -1
  19. package/dist/providers/types.js.map +1 -1
  20. package/dist/routes/authors.d.ts.map +1 -1
  21. package/dist/routes/authors.js +33 -8
  22. package/dist/routes/authors.js.map +1 -1
  23. package/dist/routes/authz-matrix.d.ts.map +1 -1
  24. package/dist/routes/authz-matrix.js +2 -0
  25. package/dist/routes/authz-matrix.js.map +1 -1
  26. package/dist/routes/context.d.ts +1 -1
  27. package/dist/routes/context.d.ts.map +1 -1
  28. package/dist/routes/context.js.map +1 -1
  29. package/dist/routes/index.d.ts +1 -0
  30. package/dist/routes/index.d.ts.map +1 -1
  31. package/dist/routes/index.js +2 -0
  32. package/dist/routes/index.js.map +1 -1
  33. package/dist/routes/recovery-errors.d.ts +21 -0
  34. package/dist/routes/recovery-errors.d.ts.map +1 -0
  35. package/dist/routes/recovery-errors.js +53 -0
  36. package/dist/routes/recovery-errors.js.map +1 -0
  37. package/dist/routes/surveys.d.ts +12 -0
  38. package/dist/routes/surveys.d.ts.map +1 -0
  39. package/dist/routes/surveys.js +109 -0
  40. package/dist/routes/surveys.js.map +1 -0
  41. package/dist/schema/insights-ingest.d.ts +46 -46
  42. package/dist/surveys/access.d.ts +4 -0
  43. package/dist/surveys/access.d.ts.map +1 -0
  44. package/dist/surveys/access.js +9 -0
  45. package/dist/surveys/access.js.map +1 -0
  46. package/dist/surveys/errors.d.ts +43 -0
  47. package/dist/surveys/errors.d.ts.map +1 -0
  48. package/dist/surveys/errors.js +42 -0
  49. package/dist/surveys/errors.js.map +1 -0
  50. package/dist/surveys/schema.d.ts +939 -0
  51. package/dist/surveys/schema.d.ts.map +1 -0
  52. package/dist/surveys/schema.js +385 -0
  53. package/dist/surveys/schema.js.map +1 -0
  54. package/dist/ui/api/surveys/[surveyId]/responses.csv.d.ts +3 -0
  55. package/dist/ui/api/surveys/[surveyId]/responses.csv.d.ts.map +1 -0
  56. package/dist/ui/api/surveys/[surveyId]/responses.csv.js +17 -0
  57. package/dist/ui/api/surveys/[surveyId]/responses.csv.js.map +1 -0
  58. package/dist/ui/api/surveys/[surveyId].d.ts +3 -0
  59. package/dist/ui/api/surveys/[surveyId].d.ts.map +1 -0
  60. package/dist/ui/api/surveys/[surveyId].js +17 -0
  61. package/dist/ui/api/surveys/[surveyId].js.map +1 -0
  62. package/dist/ui/client/mount.d.ts.map +1 -1
  63. package/dist/ui/client/mount.js +6 -2
  64. package/dist/ui/client/mount.js.map +1 -1
  65. package/dist/ui/commands.d.ts +5 -1
  66. package/dist/ui/commands.d.ts.map +1 -1
  67. package/dist/ui/commands.js +7 -0
  68. package/dist/ui/commands.js.map +1 -1
  69. package/dist/ui/components/CmsApp.d.ts +2 -0
  70. package/dist/ui/components/CmsApp.d.ts.map +1 -1
  71. package/dist/ui/components/CmsApp.js +14 -3
  72. package/dist/ui/components/CmsApp.js.map +1 -1
  73. package/dist/ui/components/CommandPalette.d.ts +2 -0
  74. package/dist/ui/components/CommandPalette.d.ts.map +1 -1
  75. package/dist/ui/components/CommandPalette.js +1 -1
  76. package/dist/ui/components/CommandPalette.js.map +1 -1
  77. package/dist/ui/components/ShareModal.d.ts +4 -1
  78. package/dist/ui/components/ShareModal.d.ts.map +1 -1
  79. package/dist/ui/components/ShareModal.js +39 -9
  80. package/dist/ui/components/ShareModal.js.map +1 -1
  81. package/dist/ui/components/SharePickers.d.ts +29 -2
  82. package/dist/ui/components/SharePickers.d.ts.map +1 -1
  83. package/dist/ui/components/SharePickers.js +33 -8
  84. package/dist/ui/components/SharePickers.js.map +1 -1
  85. package/dist/ui/components/Sidebar.d.ts +1 -0
  86. package/dist/ui/components/Sidebar.d.ts.map +1 -1
  87. package/dist/ui/components/Sidebar.js +1 -1
  88. package/dist/ui/components/Sidebar.js.map +1 -1
  89. package/dist/ui/hash-router.d.ts +1 -1
  90. package/dist/ui/hash-router.d.ts.map +1 -1
  91. package/dist/ui/hash-router.js +1 -0
  92. package/dist/ui/hash-router.js.map +1 -1
  93. package/dist/ui/nav.d.ts +5 -1
  94. package/dist/ui/nav.d.ts.map +1 -1
  95. package/dist/ui/nav.js +10 -2
  96. package/dist/ui/nav.js.map +1 -1
  97. package/dist/ui/screens/AuthorsScreen.d.ts.map +1 -1
  98. package/dist/ui/screens/AuthorsScreen.js +2 -1
  99. package/dist/ui/screens/AuthorsScreen.js.map +1 -1
  100. package/dist/ui/screens/ContentRoute.d.ts +3 -1
  101. package/dist/ui/screens/ContentRoute.d.ts.map +1 -1
  102. package/dist/ui/screens/ContentRoute.js +3 -3
  103. package/dist/ui/screens/ContentRoute.js.map +1 -1
  104. package/dist/ui/screens/EditorScreen.d.ts +2 -1
  105. package/dist/ui/screens/EditorScreen.d.ts.map +1 -1
  106. package/dist/ui/screens/EditorScreen.js +2 -2
  107. package/dist/ui/screens/EditorScreen.js.map +1 -1
  108. package/dist/ui/screens/LibraryScreen.d.ts +2 -1
  109. package/dist/ui/screens/LibraryScreen.d.ts.map +1 -1
  110. package/dist/ui/screens/LibraryScreen.js +2 -2
  111. package/dist/ui/screens/LibraryScreen.js.map +1 -1
  112. package/dist/ui/screens/SocialShareScreen.d.ts +3 -1
  113. package/dist/ui/screens/SocialShareScreen.d.ts.map +1 -1
  114. package/dist/ui/screens/SocialShareScreen.js +20 -8
  115. package/dist/ui/screens/SocialShareScreen.js.map +1 -1
  116. package/dist/ui/screens/SurveyResponsesScreen.d.ts +28 -0
  117. package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -0
  118. package/dist/ui/screens/SurveyResponsesScreen.js +110 -0
  119. package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -0
  120. package/dist/ui/screens/author-recovery.d.ts +11 -0
  121. package/dist/ui/screens/author-recovery.d.ts.map +1 -0
  122. package/dist/ui/screens/author-recovery.js +18 -0
  123. package/dist/ui/screens/author-recovery.js.map +1 -0
  124. package/dist/ui/screens/registry.d.ts +4 -0
  125. package/dist/ui/screens/registry.d.ts.map +1 -1
  126. package/dist/ui/screens/registry.js +20 -13
  127. package/dist/ui/screens/registry.js.map +1 -1
  128. package/dist/ui/screens/social-share-data.d.ts +23 -2
  129. package/dist/ui/screens/social-share-data.d.ts.map +1 -1
  130. package/dist/ui/screens/social-share-data.js +57 -11
  131. package/dist/ui/screens/social-share-data.js.map +1 -1
  132. package/package.json +1 -1
  133. package/src/index.ts +2 -2
  134. package/src/integration/index.ts +9 -0
  135. package/src/integration/options.ts +15 -0
  136. package/src/providers/null.ts +19 -0
  137. package/src/providers/types.ts +7 -0
  138. package/src/routes/authors.ts +41 -20
  139. package/src/routes/authz-matrix.ts +2 -0
  140. package/src/routes/context.ts +2 -0
  141. package/src/routes/index.ts +6 -0
  142. package/src/routes/recovery-errors.ts +70 -0
  143. package/src/routes/surveys.ts +130 -0
  144. package/src/surveys/access.ts +11 -0
  145. package/src/surveys/errors.ts +51 -0
  146. package/src/surveys/schema.ts +435 -0
  147. package/src/ui/api/surveys/[surveyId]/responses.csv.ts +23 -0
  148. package/src/ui/api/surveys/[surveyId].ts +23 -0
  149. package/src/ui/client/mount.tsx +6 -2
  150. package/src/ui/commands.ts +14 -1
  151. package/src/ui/components/CmsApp.tsx +25 -3
  152. package/src/ui/components/CommandPalette.tsx +2 -1
  153. package/src/ui/components/ShareModal.tsx +102 -19
  154. package/src/ui/components/SharePickers.tsx +76 -27
  155. package/src/ui/components/Sidebar.tsx +10 -2
  156. package/src/ui/hash-router.ts +1 -0
  157. package/src/ui/nav.ts +15 -2
  158. package/src/ui/screens/AuthorsScreen.tsx +7 -2
  159. package/src/ui/screens/ContentRoute.tsx +13 -3
  160. package/src/ui/screens/EditorScreen.tsx +7 -1
  161. package/src/ui/screens/LibraryScreen.tsx +3 -1
  162. package/src/ui/screens/SocialShareScreen.tsx +53 -6
  163. package/src/ui/screens/SurveyResponsesScreen.tsx +271 -0
  164. package/src/ui/screens/author-recovery.ts +32 -0
  165. package/src/ui/screens/registry.tsx +28 -12
  166. package/src/ui/screens/social-share-data.ts +92 -13
@@ -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
+ }
@@ -0,0 +1,435 @@
1
+ import { z } from 'zod'
2
+
3
+ export const SURVEY_ID_MAX_LENGTH = 200
4
+ const MAX_TITLE_LENGTH = 300
5
+ const MAX_PROMPT_LENGTH = 1_000
6
+ const MAX_QUESTIONS = 100
7
+ const MAX_RESPONSES = 500
8
+ const MAX_FREE_TEXT_ENTRIES = 500
9
+ const MAX_FREE_TEXT_LENGTH = 20_000
10
+ const MAX_CHOICE_OPTIONS = 100
11
+ const MAX_OPTION_LENGTH = 256
12
+ const MAX_SCALE_POINTS = 100
13
+ const MAX_COUNT = 1_000_000_000
14
+ const MAX_CSV_FILENAME_LENGTH = 200
15
+ const MAX_CSV_CONTENT_LENGTH = 10_000_000
16
+ const MAX_CSV_COLUMNS = 256
17
+ const MAX_CSV_HEADER_LENGTH = 256
18
+
19
+ export const SurveyIdSchema = z
20
+ .string()
21
+ .min(1)
22
+ .max(SURVEY_ID_MAX_LENGTH)
23
+ .regex(/^[A-Za-z0-9_-]+$/)
24
+ export const SurveyQuestionIdSchema = SurveyIdSchema
25
+ const BoundedRecordIdSchema = z
26
+ .string()
27
+ .min(1)
28
+ .max(SURVEY_ID_MAX_LENGTH)
29
+ .regex(/^[A-Za-z0-9][A-Za-z0-9._:-]*$/)
30
+ const IsoTimestampSchema = z.string().datetime({ offset: true })
31
+ const CountSchema = z.number().int().nonnegative().max(MAX_COUNT)
32
+ const NonemptyBoundedStringSchema = z
33
+ .string()
34
+ .min(1)
35
+ .max(MAX_OPTION_LENGTH)
36
+ .refine((value) => value.trim().length > 0)
37
+
38
+ export const SurveyAudienceSchema = z.enum(['all', 'member', 'non_member'])
39
+ export const SurveyRespondentSegmentSchema = z.enum(['member', 'non_member', 'unknown'])
40
+
41
+ const ChoiceOptionsSchema = z
42
+ .array(NonemptyBoundedStringSchema)
43
+ .min(1)
44
+ .max(MAX_CHOICE_OPTIONS)
45
+ .refine((options) => new Set(options).size === options.length)
46
+
47
+ const CountsSchema = z
48
+ .record(CountSchema)
49
+ .refine((counts) => Object.keys(counts).length <= MAX_CHOICE_OPTIONS)
50
+
51
+ const QuestionBase = {
52
+ id: SurveyQuestionIdSchema,
53
+ position: z
54
+ .number()
55
+ .int()
56
+ .nonnegative()
57
+ .max(MAX_QUESTIONS - 1),
58
+ prompt: z
59
+ .string()
60
+ .min(1)
61
+ .max(MAX_PROMPT_LENGTH)
62
+ .refine((value) => value.trim().length > 0),
63
+ audience: SurveyAudienceSchema,
64
+ answerCount: CountSchema,
65
+ }
66
+
67
+ const SingleChoiceQuestionSchema = z
68
+ .object({
69
+ ...QuestionBase,
70
+ kind: z.literal('single_choice'),
71
+ options: ChoiceOptionsSchema,
72
+ counts: CountsSchema,
73
+ average: z.null(),
74
+ distribution: z.null(),
75
+ responseCount: z.null(),
76
+ })
77
+ .strict()
78
+
79
+ const MultiChoiceQuestionSchema = z
80
+ .object({
81
+ ...QuestionBase,
82
+ kind: z.literal('multi_choice'),
83
+ options: ChoiceOptionsSchema,
84
+ counts: CountsSchema,
85
+ average: z.null(),
86
+ distribution: z.null(),
87
+ responseCount: z.null(),
88
+ })
89
+ .strict()
90
+
91
+ const ScaleOptionsSchema = z
92
+ .object({
93
+ min: z.number().int(),
94
+ max: z.number().int(),
95
+ })
96
+ .strict()
97
+ .refine(
98
+ (options) => options.max >= options.min && options.max - options.min + 1 <= MAX_SCALE_POINTS,
99
+ )
100
+
101
+ const ScaleQuestionSchema = z
102
+ .object({
103
+ ...QuestionBase,
104
+ kind: z.literal('scale'),
105
+ options: ScaleOptionsSchema,
106
+ counts: z.null(),
107
+ average: z.number().finite().nullable(),
108
+ distribution: z.record(CountSchema),
109
+ responseCount: z.null(),
110
+ })
111
+ .strict()
112
+
113
+ const TextQuestionSchema = z
114
+ .object({
115
+ ...QuestionBase,
116
+ kind: z.literal('text'),
117
+ options: z
118
+ .object({ max_length: z.number().int().positive().max(MAX_FREE_TEXT_LENGTH) })
119
+ .strict(),
120
+ counts: z.null(),
121
+ average: z.null(),
122
+ distribution: z.null(),
123
+ responseCount: CountSchema,
124
+ })
125
+ .strict()
126
+
127
+ export const SurveyQuestionSchema = z
128
+ .discriminatedUnion('kind', [
129
+ SingleChoiceQuestionSchema,
130
+ MultiChoiceQuestionSchema,
131
+ ScaleQuestionSchema,
132
+ TextQuestionSchema,
133
+ ])
134
+ .superRefine((question, ctx) => {
135
+ if (question.kind === 'single_choice' || question.kind === 'multi_choice') {
136
+ if (!hasExactKeys(question.counts, question.options)) {
137
+ ctx.addIssue({
138
+ code: z.ZodIssueCode.custom,
139
+ message: 'Counts must match configured options',
140
+ })
141
+ }
142
+ const total = Object.values(question.counts).reduce((sum, count) => sum + count, 0)
143
+ if (question.kind === 'single_choice' && total !== question.answerCount) {
144
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Choice total is incoherent' })
145
+ }
146
+ if (
147
+ question.kind === 'multi_choice' &&
148
+ Object.values(question.counts).some((count) => count > question.answerCount)
149
+ ) {
150
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Multi-choice total is incoherent' })
151
+ }
152
+ return
153
+ }
154
+
155
+ if (question.kind === 'scale') {
156
+ const keys = Array.from(
157
+ { length: question.options.max - question.options.min + 1 },
158
+ (_, index) => String(question.options.min + index),
159
+ )
160
+ if (!hasExactKeys(question.distribution, keys)) {
161
+ ctx.addIssue({
162
+ code: z.ZodIssueCode.custom,
163
+ message: 'Scale distribution must include every configured value',
164
+ })
165
+ }
166
+ const total = Object.values(question.distribution).reduce((sum, count) => sum + count, 0)
167
+ if (total !== question.answerCount) {
168
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Scale total is incoherent' })
169
+ }
170
+ if (
171
+ (question.answerCount === 0 && question.average !== null) ||
172
+ (question.answerCount > 0 &&
173
+ (question.average === null ||
174
+ question.average < question.options.min ||
175
+ question.average > question.options.max))
176
+ ) {
177
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Scale average is incoherent' })
178
+ }
179
+ return
180
+ }
181
+
182
+ if (question.responseCount !== question.answerCount) {
183
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Text response total is incoherent' })
184
+ }
185
+ })
186
+
187
+ export const SurveyResultsSchema = z
188
+ .object({
189
+ survey: z
190
+ .object({
191
+ id: SurveyIdSchema,
192
+ title: z
193
+ .string()
194
+ .min(1)
195
+ .max(MAX_TITLE_LENGTH)
196
+ .refine((value) => value.trim().length > 0),
197
+ status: z.enum(['draft', 'active', 'closed']),
198
+ responseCount: CountSchema,
199
+ })
200
+ .strict(),
201
+ questions: z.array(SurveyQuestionSchema).max(MAX_QUESTIONS),
202
+ responses: z
203
+ .array(
204
+ z
205
+ .object({
206
+ id: BoundedRecordIdSchema,
207
+ channel: z.enum(['email', 'site', 'direct']),
208
+ submittedAt: IsoTimestampSchema,
209
+ respondentSegment: SurveyRespondentSegmentSchema,
210
+ anonymous: z.boolean(),
211
+ })
212
+ .strict(),
213
+ )
214
+ .max(MAX_RESPONSES),
215
+ freeText: z
216
+ .array(
217
+ z
218
+ .object({
219
+ questionId: SurveyQuestionIdSchema,
220
+ responseId: BoundedRecordIdSchema,
221
+ submittedAt: IsoTimestampSchema,
222
+ value: z.string().min(1).max(MAX_FREE_TEXT_LENGTH),
223
+ })
224
+ .strict(),
225
+ )
226
+ .max(MAX_FREE_TEXT_ENTRIES),
227
+ })
228
+ .strict()
229
+ .superRefine((results, ctx) => {
230
+ const questionIds = new Set<string>()
231
+ for (const [index, question] of results.questions.entries()) {
232
+ if (question.position !== index) {
233
+ ctx.addIssue({
234
+ code: z.ZodIssueCode.custom,
235
+ path: ['questions', index, 'position'],
236
+ message: 'Question positions must be ordered',
237
+ })
238
+ }
239
+ if (questionIds.has(question.id)) {
240
+ ctx.addIssue({
241
+ code: z.ZodIssueCode.custom,
242
+ path: ['questions', index, 'id'],
243
+ message: 'Question IDs must be unique',
244
+ })
245
+ }
246
+ questionIds.add(question.id)
247
+ if (question.answerCount > results.survey.responseCount) {
248
+ ctx.addIssue({
249
+ code: z.ZodIssueCode.custom,
250
+ path: ['questions', index, 'answerCount'],
251
+ message: 'Question total exceeds survey total',
252
+ })
253
+ }
254
+ }
255
+
256
+ if (results.responses.length > results.survey.responseCount) {
257
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Response rows exceed survey total' })
258
+ }
259
+ const responses = new Map<string, (typeof results.responses)[number]>()
260
+ for (const [index, response] of results.responses.entries()) {
261
+ if (responses.has(response.id)) {
262
+ ctx.addIssue({
263
+ code: z.ZodIssueCode.custom,
264
+ path: ['responses', index, 'id'],
265
+ message: 'Response IDs must be unique',
266
+ })
267
+ }
268
+ responses.set(response.id, response)
269
+ }
270
+
271
+ const textQuestions = new Map(
272
+ results.questions
273
+ .filter(
274
+ (question): question is z.infer<typeof TextQuestionSchema> => question.kind === 'text',
275
+ )
276
+ .map((question) => [question.id, question]),
277
+ )
278
+ const freeTextCounts = new Map<string, number>()
279
+ for (const [index, entry] of results.freeText.entries()) {
280
+ const question = textQuestions.get(entry.questionId)
281
+ const response = responses.get(entry.responseId)
282
+ if (!question || (response && response.submittedAt !== entry.submittedAt)) {
283
+ ctx.addIssue({
284
+ code: z.ZodIssueCode.custom,
285
+ path: ['freeText', index],
286
+ message: 'Free-text references must be coherent',
287
+ })
288
+ continue
289
+ }
290
+ if (entry.value.length > question.options.max_length) {
291
+ ctx.addIssue({
292
+ code: z.ZodIssueCode.custom,
293
+ path: ['freeText', index, 'value'],
294
+ message: 'Free-text value exceeds its question limit',
295
+ })
296
+ }
297
+ freeTextCounts.set(entry.questionId, (freeTextCounts.get(entry.questionId) ?? 0) + 1)
298
+ }
299
+ for (const question of textQuestions.values()) {
300
+ if ((freeTextCounts.get(question.id) ?? 0) > question.responseCount) {
301
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Free-text entries exceed total' })
302
+ }
303
+ }
304
+ })
305
+
306
+ export const SurveyCsvResultSchema = z
307
+ .object({
308
+ filename: z.string().min(1).max(MAX_CSV_FILENAME_LENGTH),
309
+ content: z.string().min(1).max(MAX_CSV_CONTENT_LENGTH),
310
+ })
311
+ .strict()
312
+ .superRefine((result, ctx) => {
313
+ const header = parseCsvHeader(result.content)
314
+ if (!header) {
315
+ ctx.addIssue({
316
+ code: z.ZodIssueCode.custom,
317
+ path: ['content'],
318
+ message: 'Invalid CSV header',
319
+ })
320
+ return
321
+ }
322
+ if (
323
+ header.length > MAX_CSV_COLUMNS ||
324
+ header.some(
325
+ (column) =>
326
+ column.length === 0 ||
327
+ column.length > MAX_CSV_HEADER_LENGTH ||
328
+ column.trim().length === 0,
329
+ )
330
+ ) {
331
+ ctx.addIssue({
332
+ code: z.ZodIssueCode.custom,
333
+ path: ['content'],
334
+ message: 'CSV header is too large',
335
+ })
336
+ return
337
+ }
338
+ if (header.some((column) => !isSafeCsvColumn(column))) {
339
+ ctx.addIssue({
340
+ code: z.ZodIssueCode.custom,
341
+ path: ['content'],
342
+ message: 'CSV header contains a non-allowlisted column',
343
+ })
344
+ }
345
+ })
346
+
347
+ const SafeCsvMetadataColumns = new Set([
348
+ 'response_id',
349
+ 'submitted_at',
350
+ 'channel',
351
+ 'respondent_segment',
352
+ 'anonymous',
353
+ ])
354
+
355
+ function isSafeCsvColumn(value: string): boolean {
356
+ if (SafeCsvMetadataColumns.has(value)) return true
357
+ const prefix = 'answer:'
358
+ return (
359
+ value.startsWith(prefix) && SurveyQuestionIdSchema.safeParse(value.slice(prefix.length)).success
360
+ )
361
+ }
362
+
363
+ function parseCsvHeader(content: string): string[] | null {
364
+ const fields: string[] = []
365
+ let field = ''
366
+ let inQuotes = false
367
+ let closedQuote = false
368
+ let atFieldStart = true
369
+ const source = content.startsWith('\uFEFF') ? content.slice(1) : content
370
+
371
+ for (let index = 0; index < source.length; index += 1) {
372
+ const char = source[index]
373
+ if (inQuotes) {
374
+ if (char !== '"') {
375
+ field += char
376
+ continue
377
+ }
378
+ if (source[index + 1] === '"') {
379
+ field += '"'
380
+ index += 1
381
+ continue
382
+ }
383
+ inQuotes = false
384
+ closedQuote = true
385
+ continue
386
+ }
387
+
388
+ if (closedQuote) {
389
+ if (char === ',') {
390
+ fields.push(field)
391
+ field = ''
392
+ closedQuote = false
393
+ atFieldStart = true
394
+ continue
395
+ }
396
+ if (char === '\r' || char === '\n') {
397
+ fields.push(field)
398
+ return fields
399
+ }
400
+ return null
401
+ }
402
+
403
+ if (atFieldStart && char === '"') {
404
+ inQuotes = true
405
+ atFieldStart = false
406
+ continue
407
+ }
408
+ if (char === '"') return null
409
+ if (char === ',') {
410
+ fields.push(field)
411
+ field = ''
412
+ atFieldStart = true
413
+ continue
414
+ }
415
+ if (char === '\r' || char === '\n') {
416
+ fields.push(field)
417
+ return fields
418
+ }
419
+ field += char
420
+ atFieldStart = false
421
+ }
422
+
423
+ if (inQuotes) return null
424
+ fields.push(field)
425
+ return fields
426
+ }
427
+
428
+ function hasExactKeys(record: Record<string, number>, expected: readonly string[]): boolean {
429
+ const actual = Object.keys(record)
430
+ return actual.length === expected.length && expected.every((key) => Object.hasOwn(record, key))
431
+ }
432
+
433
+ export type SurveyQuestion = z.infer<typeof SurveyQuestionSchema>
434
+ export type SurveyResults = z.infer<typeof SurveyResultsSchema>
435
+ export type SurveyCsvResult = z.infer<typeof SurveyCsvResultSchema>
@@ -0,0 +1,23 @@
1
+ import type { APIContext } from 'astro'
2
+ import type { CmsProviders } from '../../../../providers/types.js'
3
+ import type { RouteContext } from '../../../../routes/context.js'
4
+ import { createSurveyRoutes, type SurveyRouteConfig } from '../../../../routes/surveys.js'
5
+ import { buildSiteAuthz } from '../../_authz.js'
6
+
7
+ function toCtx(context: APIContext): RouteContext {
8
+ const env = (context.locals as { cmsEnv?: Record<string, unknown> }).cmsEnv ?? {}
9
+ return {
10
+ request: context.request,
11
+ params: { surveyId: context.params.surveyId },
12
+ db: env.SITE_DB as RouteContext['db'],
13
+ env,
14
+ }
15
+ }
16
+
17
+ function routeConfig(context: APIContext): SurveyRouteConfig {
18
+ const locals = context.locals as { cmsProviders?: Partial<CmsProviders> }
19
+ return { authz: buildSiteAuthz(context), providers: locals.cmsProviders }
20
+ }
21
+
22
+ export const GET = (context: APIContext) =>
23
+ createSurveyRoutes(routeConfig(context)).csv(toCtx(context))
@@ -0,0 +1,23 @@
1
+ import type { APIContext } from 'astro'
2
+ import type { CmsProviders } from '../../../providers/types.js'
3
+ import type { RouteContext } from '../../../routes/context.js'
4
+ import { createSurveyRoutes, type SurveyRouteConfig } from '../../../routes/surveys.js'
5
+ import { buildSiteAuthz } from '../_authz.js'
6
+
7
+ function toCtx(context: APIContext): RouteContext {
8
+ const env = (context.locals as { cmsEnv?: Record<string, unknown> }).cmsEnv ?? {}
9
+ return {
10
+ request: context.request,
11
+ params: { surveyId: context.params.surveyId },
12
+ db: env.SITE_DB as RouteContext['db'],
13
+ env,
14
+ }
15
+ }
16
+
17
+ function routeConfig(context: APIContext): SurveyRouteConfig {
18
+ const locals = context.locals as { cmsProviders?: Partial<CmsProviders> }
19
+ return { authz: buildSiteAuthz(context), providers: locals.cmsProviders }
20
+ }
21
+
22
+ export const GET = (context: APIContext) =>
23
+ createSurveyRoutes(routeConfig(context)).results(toCtx(context))
@@ -2,7 +2,7 @@ import { config } from 'virtual:growth-labs/cms/config'
2
2
  import { createRoot } from 'react-dom/client'
3
3
  import type { CmsUser } from '../../routes/context.js'
4
4
  import { CmsApp } from '../components/CmsApp.js'
5
- import { cmsScreens } from '../screens/registry.js'
5
+ import { createCmsScreens } from '../screens/registry.js'
6
6
  import { initialWorkspaceState } from '../workspace-context.js'
7
7
  import { type MeResponseSummary, resolveBootState } from './boot-state.js'
8
8
  import '../styles/broadsheet.css'
@@ -35,7 +35,11 @@ export async function mount(elementId = 'masthead-root') {
35
35
  : null
36
36
 
37
37
  const initial = initialWorkspaceState(config.workspaces, config.activeWorkspaceId, user)
38
- createRoot(el).render(<CmsApp initial={initial} screens={cmsScreens} boot={boot} />)
38
+ const screens = createCmsScreens({
39
+ allowInlineAuthorCreation: config.socialSharing.allowInlineAuthorCreation,
40
+ surveyId: config.surveyResults?.surveyId,
41
+ })
42
+ createRoot(el).render(<CmsApp initial={initial} screens={screens} boot={boot} />)
39
43
  }
40
44
 
41
45
  void mount()
@@ -1,6 +1,7 @@
1
1
  // src/ui/commands.ts — the ⌘K command set + the pure filter. "create"/"jump"
2
2
  // are static; "doc" entries are fed in from the /admin/api/search results.
3
- import type { ContentType } from '../schema/types.js'
3
+ import type { CmsRole, ContentType } from '../schema/types.js'
4
+ import { canViewSurveyResponses } from '../surveys/access.js'
4
5
  import { ROUTES, type Route } from './hash-router.js'
5
6
 
6
7
  export type Command =
@@ -24,6 +25,7 @@ const JUMP_LABELS: Record<Route, string> = {
24
25
  topics: 'Topics & tags',
25
26
  'content-insights': 'Content Insights',
26
27
  subscriptions: 'Subscriptions',
28
+ 'survey-responses': 'Survey responses',
27
29
  'social-share': 'Social Share',
28
30
  settings: 'Settings',
29
31
  }
@@ -37,6 +39,7 @@ const JUMP_ICONS: Record<Route, string> = {
37
39
  topics: 'tag',
38
40
  'content-insights': 'sparkle',
39
41
  subscriptions: 'inbox',
42
+ 'survey-responses': 'chart',
40
43
  'social-share': 'share',
41
44
  settings: 'settings',
42
45
  }
@@ -57,6 +60,16 @@ export const baseCommands: Command[] = [
57
60
  ),
58
61
  ]
59
62
 
63
+ export function commandsForAccess(options: {
64
+ surveyResultsEnabled: boolean
65
+ role: CmsRole | null
66
+ }): Command[] {
67
+ const canViewSurvey = options.surveyResultsEnabled && canViewSurveyResponses(options.role)
68
+ return baseCommands.filter(
69
+ (command) => command.kind !== 'jump' || command.route !== 'survey-responses' || canViewSurvey,
70
+ )
71
+ }
72
+
60
73
  export function filterCommands(query: string, all: Command[]): Command[] {
61
74
  const q = query.trim().toLowerCase()
62
75
  if (!q) return all.slice(0, 9)
@@ -1,9 +1,12 @@
1
1
  import { Component, type ReactNode, useEffect, useState } from 'react'
2
+ import type { CmsRole } from '../../schema/types.js'
3
+ import { canViewSurveyResponses } from '../../surveys/access.js'
2
4
  import type { BootState } from '../client/boot-state.js'
5
+ import { commandsForAccess } from '../commands.js'
3
6
  import type { Route } from '../hash-router.js'
4
7
  import { useHashRouter } from '../use-hash-router.js'
5
8
  import { useTweaks } from '../use-tweaks.js'
6
- import { WorkspaceProvider, type WorkspaceState } from '../workspace-context.js'
9
+ import { useWorkspace, WorkspaceProvider, type WorkspaceState } from '../workspace-context.js'
7
10
  import { CommandPalette } from './CommandPalette.js'
8
11
  import { NoAccessScreen } from './NoAccessScreen.js'
9
12
  import { Sidebar } from './Sidebar.js'
@@ -33,6 +36,7 @@ export function CmsApp(props: {
33
36
 
34
37
  function Shell({ screens }: { screens: ScreenRegistry }) {
35
38
  const { route, navigate } = useHashRouter()
39
+ const { state } = useWorkspace()
36
40
  const { t, setTweak } = useTweaks()
37
41
  const [cmd, setCmd] = useState(false)
38
42
  const [collapsed, setCollapsed] = useState(false)
@@ -47,10 +51,15 @@ function Shell({ screens }: { screens: ScreenRegistry }) {
47
51
  window.addEventListener('keydown', h)
48
52
  return () => window.removeEventListener('keydown', h)
49
53
  }, [])
50
- const screen = screens[route]
54
+ const screen = resolveScreenForAccess(screens, route, state.user?.role ?? null)
51
55
  return (
52
56
  <div className="app" data-collapsed={collapsed}>
53
- <Sidebar route={route} navigate={navigate} collapsed={collapsed} />
57
+ <Sidebar
58
+ route={route}
59
+ navigate={navigate}
60
+ collapsed={collapsed}
61
+ surveyResultsEnabled={Boolean(screens['survey-responses'])}
62
+ />
54
63
  <div className="main">
55
64
  <Topbar
56
65
  collapsed={collapsed}
@@ -70,12 +79,25 @@ function Shell({ screens }: { screens: ScreenRegistry }) {
70
79
  onClose={() => setCmd(false)}
71
80
  navigate={navigate}
72
81
  openDoc={() => setCmd(false)}
82
+ commands={commandsForAccess({
83
+ surveyResultsEnabled: Boolean(screens['survey-responses']),
84
+ role: state.user?.role ?? null,
85
+ })}
73
86
  />
74
87
  )}
75
88
  </div>
76
89
  )
77
90
  }
78
91
 
92
+ export function resolveScreenForAccess(
93
+ screens: ScreenRegistry,
94
+ route: Route,
95
+ role: CmsRole | null,
96
+ ): (() => ReactNode) | undefined {
97
+ if (route === 'survey-responses' && !canViewSurveyResponses(role)) return undefined
98
+ return screens[route]
99
+ }
100
+
79
101
  function EmptyRoute({ route }: { route: Route }) {
80
102
  return (
81
103
  <div className="page" style={{ padding: 40, color: 'var(--ink-muted)' }}>
@@ -12,6 +12,7 @@ export function CommandPalette(props: {
12
12
  onClose: () => void
13
13
  navigate: (r: Route) => void
14
14
  openDoc: (id: string | null, type?: string) => void
15
+ commands?: Command[]
15
16
  }) {
16
17
  const [q, setQ] = useState('')
17
18
  const [docs, setDocs] = useState<Command[]>([])
@@ -35,7 +36,7 @@ export function CommandPalette(props: {
35
36
  .catch(() => {})
36
37
  return () => ctrl.abort()
37
38
  }, [q])
38
- const all = [...baseCommands, ...docs]
39
+ const all = [...(props.commands ?? baseCommands), ...docs]
39
40
  const filtered = filterCommands(q, all)
40
41
  function run(c: Command) {
41
42
  if (c.kind === 'jump') props.navigate(c.route)