@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.
- package/README.md +36 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/integration/index.d.ts.map +1 -1
- package/dist/integration/index.js +9 -0
- package/dist/integration/index.js.map +1 -1
- package/dist/integration/options.d.ts +19 -0
- package/dist/integration/options.d.ts.map +1 -1
- package/dist/integration/options.js +7 -0
- package/dist/integration/options.js.map +1 -1
- package/dist/providers/null.d.ts +2 -1
- package/dist/providers/null.d.ts.map +1 -1
- package/dist/providers/null.js +9 -0
- package/dist/providers/null.js.map +1 -1
- package/dist/providers/types.d.ts +10 -0
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/providers/types.js.map +1 -1
- package/dist/routes/authz-matrix.d.ts.map +1 -1
- package/dist/routes/authz-matrix.js +2 -0
- package/dist/routes/authz-matrix.js.map +1 -1
- package/dist/routes/context.d.ts +1 -1
- package/dist/routes/context.d.ts.map +1 -1
- package/dist/routes/context.js.map +1 -1
- package/dist/routes/index.d.ts +1 -0
- package/dist/routes/index.d.ts.map +1 -1
- package/dist/routes/index.js +2 -0
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/surveys.d.ts +12 -0
- package/dist/routes/surveys.d.ts.map +1 -0
- package/dist/routes/surveys.js +109 -0
- package/dist/routes/surveys.js.map +1 -0
- package/dist/schema/insights-ingest.d.ts +46 -46
- package/dist/surveys/access.d.ts +4 -0
- package/dist/surveys/access.d.ts.map +1 -0
- package/dist/surveys/access.js +9 -0
- package/dist/surveys/access.js.map +1 -0
- package/dist/surveys/errors.d.ts +43 -0
- package/dist/surveys/errors.d.ts.map +1 -0
- package/dist/surveys/errors.js +42 -0
- package/dist/surveys/errors.js.map +1 -0
- package/dist/surveys/schema.d.ts +939 -0
- package/dist/surveys/schema.d.ts.map +1 -0
- package/dist/surveys/schema.js +385 -0
- package/dist/surveys/schema.js.map +1 -0
- package/dist/ui/api/surveys/[surveyId]/responses.csv.d.ts +3 -0
- package/dist/ui/api/surveys/[surveyId]/responses.csv.d.ts.map +1 -0
- package/dist/ui/api/surveys/[surveyId]/responses.csv.js +17 -0
- package/dist/ui/api/surveys/[surveyId]/responses.csv.js.map +1 -0
- package/dist/ui/api/surveys/[surveyId].d.ts +3 -0
- package/dist/ui/api/surveys/[surveyId].d.ts.map +1 -0
- package/dist/ui/api/surveys/[surveyId].js +17 -0
- package/dist/ui/api/surveys/[surveyId].js.map +1 -0
- package/dist/ui/client/mount.d.ts.map +1 -1
- package/dist/ui/client/mount.js +1 -0
- package/dist/ui/client/mount.js.map +1 -1
- package/dist/ui/commands.d.ts +5 -1
- package/dist/ui/commands.d.ts.map +1 -1
- package/dist/ui/commands.js +7 -0
- package/dist/ui/commands.js.map +1 -1
- package/dist/ui/components/CmsApp.d.ts +2 -0
- package/dist/ui/components/CmsApp.d.ts.map +1 -1
- package/dist/ui/components/CmsApp.js +14 -3
- package/dist/ui/components/CmsApp.js.map +1 -1
- package/dist/ui/components/CommandPalette.d.ts +2 -0
- package/dist/ui/components/CommandPalette.d.ts.map +1 -1
- package/dist/ui/components/CommandPalette.js +1 -1
- package/dist/ui/components/CommandPalette.js.map +1 -1
- package/dist/ui/components/Sidebar.d.ts +1 -0
- package/dist/ui/components/Sidebar.d.ts.map +1 -1
- package/dist/ui/components/Sidebar.js +1 -1
- package/dist/ui/components/Sidebar.js.map +1 -1
- package/dist/ui/hash-router.d.ts +1 -1
- package/dist/ui/hash-router.d.ts.map +1 -1
- package/dist/ui/hash-router.js +1 -0
- package/dist/ui/hash-router.js.map +1 -1
- package/dist/ui/nav.d.ts +5 -1
- package/dist/ui/nav.d.ts.map +1 -1
- package/dist/ui/nav.js +10 -2
- package/dist/ui/nav.js.map +1 -1
- package/dist/ui/screens/SurveyResponsesScreen.d.ts +28 -0
- package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -0
- package/dist/ui/screens/SurveyResponsesScreen.js +110 -0
- package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -0
- package/dist/ui/screens/registry.d.ts +2 -1
- package/dist/ui/screens/registry.d.ts.map +1 -1
- package/dist/ui/screens/registry.js +5 -1
- package/dist/ui/screens/registry.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +2 -2
- package/src/integration/index.ts +9 -0
- package/src/integration/options.ts +8 -0
- package/src/providers/null.ts +19 -0
- package/src/providers/types.ts +7 -0
- package/src/routes/authz-matrix.ts +2 -0
- package/src/routes/context.ts +2 -0
- package/src/routes/index.ts +6 -0
- package/src/routes/surveys.ts +130 -0
- package/src/surveys/access.ts +11 -0
- package/src/surveys/errors.ts +51 -0
- package/src/surveys/schema.ts +435 -0
- package/src/ui/api/surveys/[surveyId]/responses.csv.ts +23 -0
- package/src/ui/api/surveys/[surveyId].ts +23 -0
- package/src/ui/client/mount.tsx +1 -0
- package/src/ui/commands.ts +14 -1
- package/src/ui/components/CmsApp.tsx +25 -3
- package/src/ui/components/CommandPalette.tsx +2 -1
- package/src/ui/components/Sidebar.tsx +10 -2
- package/src/ui/hash-router.ts +1 -0
- package/src/ui/nav.ts +15 -2
- package/src/ui/screens/SurveyResponsesScreen.tsx +271 -0
- 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))
|
package/src/ui/client/mount.tsx
CHANGED
|
@@ -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
|
}
|