@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.
- package/README.md +55 -1
- 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 +38 -0
- package/dist/integration/options.d.ts.map +1 -1
- package/dist/integration/options.js +13 -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/authors.d.ts.map +1 -1
- package/dist/routes/authors.js +33 -8
- package/dist/routes/authors.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/recovery-errors.d.ts +21 -0
- package/dist/routes/recovery-errors.d.ts.map +1 -0
- package/dist/routes/recovery-errors.js +53 -0
- package/dist/routes/recovery-errors.js.map +1 -0
- 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 +6 -2
- 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/ShareModal.d.ts +4 -1
- package/dist/ui/components/ShareModal.d.ts.map +1 -1
- package/dist/ui/components/ShareModal.js +39 -9
- package/dist/ui/components/ShareModal.js.map +1 -1
- package/dist/ui/components/SharePickers.d.ts +29 -2
- package/dist/ui/components/SharePickers.d.ts.map +1 -1
- package/dist/ui/components/SharePickers.js +33 -8
- package/dist/ui/components/SharePickers.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/AuthorsScreen.d.ts.map +1 -1
- package/dist/ui/screens/AuthorsScreen.js +2 -1
- package/dist/ui/screens/AuthorsScreen.js.map +1 -1
- package/dist/ui/screens/ContentRoute.d.ts +3 -1
- package/dist/ui/screens/ContentRoute.d.ts.map +1 -1
- package/dist/ui/screens/ContentRoute.js +3 -3
- package/dist/ui/screens/ContentRoute.js.map +1 -1
- package/dist/ui/screens/EditorScreen.d.ts +2 -1
- package/dist/ui/screens/EditorScreen.d.ts.map +1 -1
- package/dist/ui/screens/EditorScreen.js +2 -2
- package/dist/ui/screens/EditorScreen.js.map +1 -1
- package/dist/ui/screens/LibraryScreen.d.ts +2 -1
- package/dist/ui/screens/LibraryScreen.d.ts.map +1 -1
- package/dist/ui/screens/LibraryScreen.js +2 -2
- package/dist/ui/screens/LibraryScreen.js.map +1 -1
- package/dist/ui/screens/SocialShareScreen.d.ts +3 -1
- package/dist/ui/screens/SocialShareScreen.d.ts.map +1 -1
- package/dist/ui/screens/SocialShareScreen.js +20 -8
- package/dist/ui/screens/SocialShareScreen.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/author-recovery.d.ts +11 -0
- package/dist/ui/screens/author-recovery.d.ts.map +1 -0
- package/dist/ui/screens/author-recovery.js +18 -0
- package/dist/ui/screens/author-recovery.js.map +1 -0
- package/dist/ui/screens/registry.d.ts +4 -0
- package/dist/ui/screens/registry.d.ts.map +1 -1
- package/dist/ui/screens/registry.js +20 -13
- package/dist/ui/screens/registry.js.map +1 -1
- package/dist/ui/screens/social-share-data.d.ts +23 -2
- package/dist/ui/screens/social-share-data.d.ts.map +1 -1
- package/dist/ui/screens/social-share-data.js +57 -11
- package/dist/ui/screens/social-share-data.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 +15 -0
- package/src/providers/null.ts +19 -0
- package/src/providers/types.ts +7 -0
- package/src/routes/authors.ts +41 -20
- 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/recovery-errors.ts +70 -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 +6 -2
- 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/ShareModal.tsx +102 -19
- package/src/ui/components/SharePickers.tsx +76 -27
- 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/AuthorsScreen.tsx +7 -2
- package/src/ui/screens/ContentRoute.tsx +13 -3
- package/src/ui/screens/EditorScreen.tsx +7 -1
- package/src/ui/screens/LibraryScreen.tsx +3 -1
- package/src/ui/screens/SocialShareScreen.tsx +53 -6
- package/src/ui/screens/SurveyResponsesScreen.tsx +271 -0
- package/src/ui/screens/author-recovery.ts +32 -0
- package/src/ui/screens/registry.tsx +28 -12
- 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))
|
package/src/ui/client/mount.tsx
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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()
|
package/src/ui/commands.ts
CHANGED
|
@@ -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
|
|
54
|
+
const screen = resolveScreenForAccess(screens, route, state.user?.role ?? null)
|
|
51
55
|
return (
|
|
52
56
|
<div className="app" data-collapsed={collapsed}>
|
|
53
|
-
<Sidebar
|
|
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)
|