@growth-labs/cms 0.5.5 → 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 (113) hide show
  1. package/README.md +36 -0
  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 +19 -0
  10. package/dist/integration/options.d.ts.map +1 -1
  11. package/dist/integration/options.js +7 -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/authz-matrix.d.ts.map +1 -1
  21. package/dist/routes/authz-matrix.js +2 -0
  22. package/dist/routes/authz-matrix.js.map +1 -1
  23. package/dist/routes/context.d.ts +1 -1
  24. package/dist/routes/context.d.ts.map +1 -1
  25. package/dist/routes/context.js.map +1 -1
  26. package/dist/routes/index.d.ts +1 -0
  27. package/dist/routes/index.d.ts.map +1 -1
  28. package/dist/routes/index.js +2 -0
  29. package/dist/routes/index.js.map +1 -1
  30. package/dist/routes/surveys.d.ts +12 -0
  31. package/dist/routes/surveys.d.ts.map +1 -0
  32. package/dist/routes/surveys.js +109 -0
  33. package/dist/routes/surveys.js.map +1 -0
  34. package/dist/schema/insights-ingest.d.ts +46 -46
  35. package/dist/surveys/access.d.ts +4 -0
  36. package/dist/surveys/access.d.ts.map +1 -0
  37. package/dist/surveys/access.js +9 -0
  38. package/dist/surveys/access.js.map +1 -0
  39. package/dist/surveys/errors.d.ts +43 -0
  40. package/dist/surveys/errors.d.ts.map +1 -0
  41. package/dist/surveys/errors.js +42 -0
  42. package/dist/surveys/errors.js.map +1 -0
  43. package/dist/surveys/schema.d.ts +939 -0
  44. package/dist/surveys/schema.d.ts.map +1 -0
  45. package/dist/surveys/schema.js +385 -0
  46. package/dist/surveys/schema.js.map +1 -0
  47. package/dist/ui/api/surveys/[surveyId]/responses.csv.d.ts +3 -0
  48. package/dist/ui/api/surveys/[surveyId]/responses.csv.d.ts.map +1 -0
  49. package/dist/ui/api/surveys/[surveyId]/responses.csv.js +17 -0
  50. package/dist/ui/api/surveys/[surveyId]/responses.csv.js.map +1 -0
  51. package/dist/ui/api/surveys/[surveyId].d.ts +3 -0
  52. package/dist/ui/api/surveys/[surveyId].d.ts.map +1 -0
  53. package/dist/ui/api/surveys/[surveyId].js +17 -0
  54. package/dist/ui/api/surveys/[surveyId].js.map +1 -0
  55. package/dist/ui/client/mount.d.ts.map +1 -1
  56. package/dist/ui/client/mount.js +1 -0
  57. package/dist/ui/client/mount.js.map +1 -1
  58. package/dist/ui/commands.d.ts +5 -1
  59. package/dist/ui/commands.d.ts.map +1 -1
  60. package/dist/ui/commands.js +7 -0
  61. package/dist/ui/commands.js.map +1 -1
  62. package/dist/ui/components/CmsApp.d.ts +2 -0
  63. package/dist/ui/components/CmsApp.d.ts.map +1 -1
  64. package/dist/ui/components/CmsApp.js +14 -3
  65. package/dist/ui/components/CmsApp.js.map +1 -1
  66. package/dist/ui/components/CommandPalette.d.ts +2 -0
  67. package/dist/ui/components/CommandPalette.d.ts.map +1 -1
  68. package/dist/ui/components/CommandPalette.js +1 -1
  69. package/dist/ui/components/CommandPalette.js.map +1 -1
  70. package/dist/ui/components/Sidebar.d.ts +1 -0
  71. package/dist/ui/components/Sidebar.d.ts.map +1 -1
  72. package/dist/ui/components/Sidebar.js +1 -1
  73. package/dist/ui/components/Sidebar.js.map +1 -1
  74. package/dist/ui/hash-router.d.ts +1 -1
  75. package/dist/ui/hash-router.d.ts.map +1 -1
  76. package/dist/ui/hash-router.js +1 -0
  77. package/dist/ui/hash-router.js.map +1 -1
  78. package/dist/ui/nav.d.ts +5 -1
  79. package/dist/ui/nav.d.ts.map +1 -1
  80. package/dist/ui/nav.js +10 -2
  81. package/dist/ui/nav.js.map +1 -1
  82. package/dist/ui/screens/SurveyResponsesScreen.d.ts +28 -0
  83. package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -0
  84. package/dist/ui/screens/SurveyResponsesScreen.js +110 -0
  85. package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -0
  86. package/dist/ui/screens/registry.d.ts +2 -1
  87. package/dist/ui/screens/registry.d.ts.map +1 -1
  88. package/dist/ui/screens/registry.js +5 -1
  89. package/dist/ui/screens/registry.js.map +1 -1
  90. package/package.json +1 -1
  91. package/src/index.ts +2 -2
  92. package/src/integration/index.ts +9 -0
  93. package/src/integration/options.ts +8 -0
  94. package/src/providers/null.ts +19 -0
  95. package/src/providers/types.ts +7 -0
  96. package/src/routes/authz-matrix.ts +2 -0
  97. package/src/routes/context.ts +2 -0
  98. package/src/routes/index.ts +6 -0
  99. package/src/routes/surveys.ts +130 -0
  100. package/src/surveys/access.ts +11 -0
  101. package/src/surveys/errors.ts +51 -0
  102. package/src/surveys/schema.ts +435 -0
  103. package/src/ui/api/surveys/[surveyId]/responses.csv.ts +23 -0
  104. package/src/ui/api/surveys/[surveyId].ts +23 -0
  105. package/src/ui/client/mount.tsx +1 -0
  106. package/src/ui/commands.ts +14 -1
  107. package/src/ui/components/CmsApp.tsx +25 -3
  108. package/src/ui/components/CommandPalette.tsx +2 -1
  109. package/src/ui/components/Sidebar.tsx +10 -2
  110. package/src/ui/hash-router.ts +1 -0
  111. package/src/ui/nav.ts +15 -2
  112. package/src/ui/screens/SurveyResponsesScreen.tsx +271 -0
  113. package/src/ui/screens/registry.tsx +6 -0
@@ -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
+ }
@@ -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))
@@ -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
+ surveyId: config.surveyResults?.surveyId,
40
41
  })
41
42
  createRoot(el).render(<CmsApp initial={initial} screens={screens} boot={boot} />)
42
43
  }