@coursebuilder/analytics 1.1.0
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/LICENSE +21 -0
- package/dist/api/index.d.ts +158 -0
- package/dist/api/index.js +317 -0
- package/dist/api/index.js.map +1 -0
- package/dist/catalog.d.ts +14 -0
- package/dist/catalog.js +209 -0
- package/dist/catalog.js.map +1 -0
- package/dist/components/index.d.ts +172 -0
- package/dist/components/index.js +1258 -0
- package/dist/components/index.js.map +1 -0
- package/dist/engine.d.ts +20 -0
- package/dist/engine.js +350 -0
- package/dist/engine.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +353 -0
- package/dist/index.js.map +1 -0
- package/dist/providers/database.d.ts +79 -0
- package/dist/providers/database.js +533 -0
- package/dist/providers/database.js.map +1 -0
- package/dist/providers/derived.d.ts +45 -0
- package/dist/providers/derived.js +32 -0
- package/dist/providers/derived.js.map +1 -0
- package/dist/providers/ga4.d.ts +43 -0
- package/dist/providers/ga4.js +220 -0
- package/dist/providers/ga4.js.map +1 -0
- package/dist/providers/index.d.ts +8 -0
- package/dist/providers/index.js +1239 -0
- package/dist/providers/index.js.map +1 -0
- package/dist/providers/mux.d.ts +103 -0
- package/dist/providers/mux.js +241 -0
- package/dist/providers/mux.js.map +1 -0
- package/dist/providers/survey.d.ts +102 -0
- package/dist/providers/survey.js +233 -0
- package/dist/providers/survey.js.map +1 -0
- package/dist/types.d.ts +303 -0
- package/dist/types.js +1 -0
- package/dist/types.js.map +1 -0
- package/package.json +101 -0
- package/src/api/catalog-handler.ts +321 -0
- package/src/api/index.ts +4 -0
- package/src/api/token-handler.ts +71 -0
- package/src/catalog.ts +223 -0
- package/src/components/country-chart.tsx +114 -0
- package/src/components/index.ts +5 -0
- package/src/components/omnibus-dashboard.tsx +1460 -0
- package/src/components/revenue-chart.tsx +251 -0
- package/src/components/use-chart-colors.ts +75 -0
- package/src/engine.ts +201 -0
- package/src/index.ts +7 -0
- package/src/providers/database.ts +795 -0
- package/src/providers/derived.ts +79 -0
- package/src/providers/ga4.ts +173 -0
- package/src/providers/index.ts +44 -0
- package/src/providers/mux.ts +438 -0
- package/src/providers/survey.ts +487 -0
- package/src/types.ts +333 -0
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
import { and, count, eq, sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type { AnalyticsRange } from '../types'
|
|
4
|
+
|
|
5
|
+
// ─── Schema types ────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Minimal column shape required from the contentResource table.
|
|
9
|
+
* We only reference the columns we actually query against.
|
|
10
|
+
*/
|
|
11
|
+
interface ContentResourceTable {
|
|
12
|
+
id: any
|
|
13
|
+
type: any
|
|
14
|
+
fields: any
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Minimal column shape required from the contentResourceResource table.
|
|
19
|
+
*/
|
|
20
|
+
interface ContentResourceResourceTable {
|
|
21
|
+
resourceOfId: any
|
|
22
|
+
resourceId: any
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Minimal column shape required from the questionResponse table.
|
|
27
|
+
*/
|
|
28
|
+
interface QuestionResponseTable {
|
|
29
|
+
id: any
|
|
30
|
+
surveyId: any
|
|
31
|
+
questionId: any
|
|
32
|
+
respondentKey: any
|
|
33
|
+
surveySessionId: any
|
|
34
|
+
userId: any
|
|
35
|
+
emailListSubscriberId: any
|
|
36
|
+
createdAt: any
|
|
37
|
+
updatedAt: any
|
|
38
|
+
fields: any
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Minimal column shape required from the users table.
|
|
43
|
+
* Optional — only needed for getSurveyResponses to resolve user emails.
|
|
44
|
+
*/
|
|
45
|
+
interface UsersTable {
|
|
46
|
+
id: any
|
|
47
|
+
email: any
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface SurveyAnalyticsSchema {
|
|
51
|
+
contentResource: ContentResourceTable
|
|
52
|
+
contentResourceResource: ContentResourceResourceTable
|
|
53
|
+
questionResponse: QuestionResponseTable
|
|
54
|
+
users?: UsersTable
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type CanonicalSurveyRow = {
|
|
58
|
+
responseId: string
|
|
59
|
+
surveyId: string
|
|
60
|
+
surveyTitle: string | null
|
|
61
|
+
surveySlug: string | null
|
|
62
|
+
questionId: string
|
|
63
|
+
questionText: string | null
|
|
64
|
+
questionType: string | null
|
|
65
|
+
answer: string | null
|
|
66
|
+
respondentKey: string
|
|
67
|
+
surveySessionId: string | null
|
|
68
|
+
userId: string | null
|
|
69
|
+
userEmail: string | null
|
|
70
|
+
emailListSubscriberId: string | null
|
|
71
|
+
createdAt: Date | null
|
|
72
|
+
updatedAt: Date | null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ─── Return type ─────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
export interface SurveyAnalyticsProvider {
|
|
78
|
+
getSurveySummary: (range?: AnalyticsRange) => Promise<{
|
|
79
|
+
totalSurveys: number
|
|
80
|
+
totalResponses: number
|
|
81
|
+
uniqueRespondents: number
|
|
82
|
+
avgResponsesPerSurvey: number
|
|
83
|
+
}>
|
|
84
|
+
|
|
85
|
+
getSurveyList: (range?: AnalyticsRange) => Promise<
|
|
86
|
+
Array<{
|
|
87
|
+
surveyId: string
|
|
88
|
+
surveyTitle: string
|
|
89
|
+
surveySlug: string
|
|
90
|
+
responses: number
|
|
91
|
+
uniqueRespondents: number
|
|
92
|
+
questionCount: number
|
|
93
|
+
}>
|
|
94
|
+
>
|
|
95
|
+
|
|
96
|
+
getSurveyResponsesByDay: (range?: AnalyticsRange) => Promise<
|
|
97
|
+
Array<{
|
|
98
|
+
date: string
|
|
99
|
+
responses: number
|
|
100
|
+
}>
|
|
101
|
+
>
|
|
102
|
+
|
|
103
|
+
getSurveyQuestionBreakdown: (
|
|
104
|
+
range?: AnalyticsRange,
|
|
105
|
+
limit?: number,
|
|
106
|
+
) => Promise<
|
|
107
|
+
Array<{
|
|
108
|
+
questionId: string
|
|
109
|
+
question: string
|
|
110
|
+
type: string | null
|
|
111
|
+
responses: number
|
|
112
|
+
uniqueRespondents: number
|
|
113
|
+
answerDistribution: Array<{ answer: string; count: number }>
|
|
114
|
+
}>
|
|
115
|
+
>
|
|
116
|
+
|
|
117
|
+
getSurveyResponses: (
|
|
118
|
+
range?: AnalyticsRange,
|
|
119
|
+
limit?: number,
|
|
120
|
+
) => Promise<
|
|
121
|
+
Array<{
|
|
122
|
+
responseId: string
|
|
123
|
+
surveyId: string
|
|
124
|
+
surveyTitle: string
|
|
125
|
+
surveySlug: string
|
|
126
|
+
questionId: string
|
|
127
|
+
questionText: string
|
|
128
|
+
questionType: string | null
|
|
129
|
+
answer: string
|
|
130
|
+
userId: string | null
|
|
131
|
+
userEmail: string | null
|
|
132
|
+
emailListSubscriberId: string | null
|
|
133
|
+
createdAt: string
|
|
134
|
+
}>
|
|
135
|
+
>
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function normalizeRespondentKey(row: {
|
|
139
|
+
respondentKey: string | null
|
|
140
|
+
userId: string | null
|
|
141
|
+
emailListSubscriberId: string | null
|
|
142
|
+
surveySessionId: string | null
|
|
143
|
+
}) {
|
|
144
|
+
if (row.respondentKey) return row.respondentKey
|
|
145
|
+
if (row.userId) return `user:${row.userId}`
|
|
146
|
+
if (row.emailListSubscriberId) {
|
|
147
|
+
return `subscriber:${row.emailListSubscriberId}`
|
|
148
|
+
}
|
|
149
|
+
if (row.surveySessionId) return `session:${row.surveySessionId}`
|
|
150
|
+
return null
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function getRowTimestamp(row: {
|
|
154
|
+
updatedAt: Date | null
|
|
155
|
+
createdAt: Date | null
|
|
156
|
+
}) {
|
|
157
|
+
const date = row.updatedAt ?? row.createdAt
|
|
158
|
+
return date instanceof Date ? date.getTime() : 0
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function sortByNewest<
|
|
162
|
+
T extends { createdAt: Date | null; updatedAt: Date | null },
|
|
163
|
+
>(rows: T[]) {
|
|
164
|
+
return [...rows].sort((a, b) => getRowTimestamp(b) - getRowTimestamp(a))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ─── Factory ─────────────────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Creates a survey analytics provider bound to the given Drizzle db instance
|
|
171
|
+
* and schema tables.
|
|
172
|
+
*
|
|
173
|
+
* @param db - Drizzle database instance
|
|
174
|
+
* @param schema - Object containing the required table references
|
|
175
|
+
*/
|
|
176
|
+
export function createSurveyProvider(
|
|
177
|
+
db: any,
|
|
178
|
+
schema: SurveyAnalyticsSchema,
|
|
179
|
+
): SurveyAnalyticsProvider {
|
|
180
|
+
const { contentResource, contentResourceResource, questionResponse } = schema
|
|
181
|
+
|
|
182
|
+
// ─── Range helpers ───────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
function rangeToInterval(range: AnalyticsRange): string {
|
|
185
|
+
switch (range) {
|
|
186
|
+
case '24h':
|
|
187
|
+
return '1 DAY'
|
|
188
|
+
case '7d':
|
|
189
|
+
return '7 DAY'
|
|
190
|
+
case '30d':
|
|
191
|
+
return '30 DAY'
|
|
192
|
+
case '90d':
|
|
193
|
+
return '90 DAY'
|
|
194
|
+
case 'all':
|
|
195
|
+
return '3650 DAY'
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function rangeWhere(range: AnalyticsRange, column: any) {
|
|
200
|
+
return sql`${column} >= DATE_SUB(NOW(), INTERVAL ${sql.raw(rangeToInterval(range))})`
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function fetchCanonicalRows(range: AnalyticsRange) {
|
|
204
|
+
const { users } = schema
|
|
205
|
+
|
|
206
|
+
const rawRows = users
|
|
207
|
+
? await db
|
|
208
|
+
.select({
|
|
209
|
+
responseId: questionResponse.id,
|
|
210
|
+
surveyId: questionResponse.surveyId,
|
|
211
|
+
surveyTitle: sql<string>`JSON_UNQUOTE(JSON_EXTRACT(survey_cr.fields, '$.title'))`,
|
|
212
|
+
surveySlug: sql<string>`JSON_UNQUOTE(JSON_EXTRACT(survey_cr.fields, '$.slug'))`,
|
|
213
|
+
questionId: questionResponse.questionId,
|
|
214
|
+
questionText: sql<string>`JSON_UNQUOTE(JSON_EXTRACT(question_cr.fields, '$.question'))`,
|
|
215
|
+
questionType: sql<string>`JSON_UNQUOTE(JSON_EXTRACT(question_cr.fields, '$.type'))`,
|
|
216
|
+
answer: sql<string>`JSON_UNQUOTE(JSON_EXTRACT(${questionResponse.fields}, '$.answer'))`,
|
|
217
|
+
respondentKey: questionResponse.respondentKey,
|
|
218
|
+
surveySessionId: questionResponse.surveySessionId,
|
|
219
|
+
userId: questionResponse.userId,
|
|
220
|
+
userEmail: users.email,
|
|
221
|
+
emailListSubscriberId: questionResponse.emailListSubscriberId,
|
|
222
|
+
createdAt: questionResponse.createdAt,
|
|
223
|
+
updatedAt: questionResponse.updatedAt,
|
|
224
|
+
})
|
|
225
|
+
.from(questionResponse)
|
|
226
|
+
.leftJoin(
|
|
227
|
+
sql`${contentResource} AS survey_cr`,
|
|
228
|
+
sql`survey_cr.id = ${questionResponse.surveyId}`,
|
|
229
|
+
)
|
|
230
|
+
.leftJoin(
|
|
231
|
+
sql`${contentResource} AS question_cr`,
|
|
232
|
+
sql`question_cr.id = ${questionResponse.questionId}`,
|
|
233
|
+
)
|
|
234
|
+
.leftJoin(users, eq(questionResponse.userId, users.id))
|
|
235
|
+
.where(rangeWhere(range, questionResponse.createdAt))
|
|
236
|
+
: await db
|
|
237
|
+
.select({
|
|
238
|
+
responseId: questionResponse.id,
|
|
239
|
+
surveyId: questionResponse.surveyId,
|
|
240
|
+
surveyTitle: sql<string>`JSON_UNQUOTE(JSON_EXTRACT(survey_cr.fields, '$.title'))`,
|
|
241
|
+
surveySlug: sql<string>`JSON_UNQUOTE(JSON_EXTRACT(survey_cr.fields, '$.slug'))`,
|
|
242
|
+
questionId: questionResponse.questionId,
|
|
243
|
+
questionText: sql<string>`JSON_UNQUOTE(JSON_EXTRACT(question_cr.fields, '$.question'))`,
|
|
244
|
+
questionType: sql<string>`JSON_UNQUOTE(JSON_EXTRACT(question_cr.fields, '$.type'))`,
|
|
245
|
+
answer: sql<string>`JSON_UNQUOTE(JSON_EXTRACT(${questionResponse.fields}, '$.answer'))`,
|
|
246
|
+
respondentKey: questionResponse.respondentKey,
|
|
247
|
+
surveySessionId: questionResponse.surveySessionId,
|
|
248
|
+
userId: questionResponse.userId,
|
|
249
|
+
userEmail: sql<string | null>`NULL`,
|
|
250
|
+
emailListSubscriberId: questionResponse.emailListSubscriberId,
|
|
251
|
+
createdAt: questionResponse.createdAt,
|
|
252
|
+
updatedAt: questionResponse.updatedAt,
|
|
253
|
+
})
|
|
254
|
+
.from(questionResponse)
|
|
255
|
+
.leftJoin(
|
|
256
|
+
sql`${contentResource} AS survey_cr`,
|
|
257
|
+
sql`survey_cr.id = ${questionResponse.surveyId}`,
|
|
258
|
+
)
|
|
259
|
+
.leftJoin(
|
|
260
|
+
sql`${contentResource} AS question_cr`,
|
|
261
|
+
sql`question_cr.id = ${questionResponse.questionId}`,
|
|
262
|
+
)
|
|
263
|
+
.where(rangeWhere(range, questionResponse.createdAt))
|
|
264
|
+
|
|
265
|
+
const latestByAnswer = new Map<string, CanonicalSurveyRow>()
|
|
266
|
+
|
|
267
|
+
for (const row of rawRows) {
|
|
268
|
+
const respondentKey = normalizeRespondentKey(row)
|
|
269
|
+
if (!respondentKey) continue
|
|
270
|
+
|
|
271
|
+
const dedupeKey = `${row.surveyId}::${row.questionId}::${respondentKey}`
|
|
272
|
+
const current = latestByAnswer.get(dedupeKey)
|
|
273
|
+
|
|
274
|
+
if (!current || getRowTimestamp(row) >= getRowTimestamp(current)) {
|
|
275
|
+
latestByAnswer.set(dedupeKey, {
|
|
276
|
+
...row,
|
|
277
|
+
respondentKey,
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return sortByNewest(Array.from(latestByAnswer.values()))
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ─── Survey Summary ──────────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
async function getSurveySummary(range: AnalyticsRange = '30d') {
|
|
288
|
+
const [surveyCount] = await db
|
|
289
|
+
.select({ total: count() })
|
|
290
|
+
.from(contentResource)
|
|
291
|
+
.where(eq(contentResource.type, 'survey'))
|
|
292
|
+
|
|
293
|
+
const canonicalRows = await fetchCanonicalRows(range)
|
|
294
|
+
const respondentKeys = new Set(
|
|
295
|
+
canonicalRows.map((row) => row.respondentKey),
|
|
296
|
+
)
|
|
297
|
+
const totalSurveys = surveyCount?.total ?? 0
|
|
298
|
+
const totalResponses = canonicalRows.length
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
totalSurveys,
|
|
302
|
+
totalResponses,
|
|
303
|
+
uniqueRespondents: respondentKeys.size,
|
|
304
|
+
avgResponsesPerSurvey:
|
|
305
|
+
totalSurveys > 0 ? totalResponses / totalSurveys : 0,
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ─── Survey List ─────────────────────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
async function getSurveyList(range: AnalyticsRange = '30d') {
|
|
312
|
+
const canonicalRows = await fetchCanonicalRows(range)
|
|
313
|
+
const responsesBySurvey = new Map<
|
|
314
|
+
string,
|
|
315
|
+
{ responses: number; respondents: Set<string> }
|
|
316
|
+
>()
|
|
317
|
+
|
|
318
|
+
for (const row of canonicalRows) {
|
|
319
|
+
const current = responsesBySurvey.get(row.surveyId) ?? {
|
|
320
|
+
responses: 0,
|
|
321
|
+
respondents: new Set<string>(),
|
|
322
|
+
}
|
|
323
|
+
current.responses += 1
|
|
324
|
+
current.respondents.add(row.respondentKey)
|
|
325
|
+
responsesBySurvey.set(row.surveyId, current)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const surveys = await db
|
|
329
|
+
.select({
|
|
330
|
+
surveyId: contentResource.id,
|
|
331
|
+
surveyTitle: sql<string>`JSON_UNQUOTE(JSON_EXTRACT(${contentResource.fields}, '$.title'))`,
|
|
332
|
+
surveySlug: sql<string>`JSON_UNQUOTE(JSON_EXTRACT(${contentResource.fields}, '$.slug'))`,
|
|
333
|
+
})
|
|
334
|
+
.from(contentResource)
|
|
335
|
+
.where(eq(contentResource.type, 'survey'))
|
|
336
|
+
|
|
337
|
+
const questionCounts = await db
|
|
338
|
+
.select({
|
|
339
|
+
surveyId: contentResourceResource.resourceOfId,
|
|
340
|
+
questionCount: count(),
|
|
341
|
+
})
|
|
342
|
+
.from(contentResourceResource)
|
|
343
|
+
.innerJoin(
|
|
344
|
+
contentResource,
|
|
345
|
+
and(
|
|
346
|
+
eq(contentResourceResource.resourceId, contentResource.id),
|
|
347
|
+
eq(contentResource.type, 'question'),
|
|
348
|
+
),
|
|
349
|
+
)
|
|
350
|
+
.groupBy(contentResourceResource.resourceOfId)
|
|
351
|
+
|
|
352
|
+
const questionCountMap = new Map(
|
|
353
|
+
questionCounts.map((qc: { surveyId: string; questionCount: number }) => [
|
|
354
|
+
qc.surveyId,
|
|
355
|
+
qc.questionCount,
|
|
356
|
+
]),
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
return surveys
|
|
360
|
+
.map(
|
|
361
|
+
(s: {
|
|
362
|
+
surveyId: string
|
|
363
|
+
surveyTitle: string | null
|
|
364
|
+
surveySlug: string | null
|
|
365
|
+
}) => {
|
|
366
|
+
const counts = responsesBySurvey.get(s.surveyId)
|
|
367
|
+
return {
|
|
368
|
+
surveyId: s.surveyId,
|
|
369
|
+
surveyTitle: s.surveyTitle ?? '',
|
|
370
|
+
surveySlug: s.surveySlug ?? '',
|
|
371
|
+
responses: counts?.responses ?? 0,
|
|
372
|
+
uniqueRespondents: counts?.respondents.size ?? 0,
|
|
373
|
+
questionCount: questionCountMap.get(s.surveyId) ?? 0,
|
|
374
|
+
}
|
|
375
|
+
},
|
|
376
|
+
)
|
|
377
|
+
.sort(
|
|
378
|
+
(a: { responses: number }, b: { responses: number }) =>
|
|
379
|
+
b.responses - a.responses,
|
|
380
|
+
)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ─── Daily Responses ─────────────────────────────────────────────────────
|
|
384
|
+
|
|
385
|
+
async function getSurveyResponsesByDay(range: AnalyticsRange = '30d') {
|
|
386
|
+
const canonicalRows = await fetchCanonicalRows(range)
|
|
387
|
+
const grouped = new Map<string, number>()
|
|
388
|
+
|
|
389
|
+
for (const row of canonicalRows) {
|
|
390
|
+
if (!(row.createdAt instanceof Date)) continue
|
|
391
|
+
const date = row.createdAt.toISOString().slice(0, 10)
|
|
392
|
+
grouped.set(date, (grouped.get(date) ?? 0) + 1)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return Array.from(grouped.entries())
|
|
396
|
+
.sort((entryA: [string, number], entryB: [string, number]) =>
|
|
397
|
+
entryA[0].localeCompare(entryB[0]),
|
|
398
|
+
)
|
|
399
|
+
.map(([date, responses]) => ({ date, responses }))
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ─── Question Breakdown ──────────────────────────────────────────────────
|
|
403
|
+
|
|
404
|
+
async function getSurveyQuestionBreakdown(
|
|
405
|
+
range: AnalyticsRange = '30d',
|
|
406
|
+
limit = 20,
|
|
407
|
+
) {
|
|
408
|
+
const canonicalRows = await fetchCanonicalRows(range)
|
|
409
|
+
const grouped = new Map<
|
|
410
|
+
string,
|
|
411
|
+
{
|
|
412
|
+
questionId: string
|
|
413
|
+
question: string
|
|
414
|
+
type: string | null
|
|
415
|
+
responses: number
|
|
416
|
+
respondents: Set<string>
|
|
417
|
+
answers: Map<string, number>
|
|
418
|
+
}
|
|
419
|
+
>()
|
|
420
|
+
|
|
421
|
+
for (const row of canonicalRows) {
|
|
422
|
+
const current = grouped.get(row.questionId) ?? {
|
|
423
|
+
questionId: row.questionId,
|
|
424
|
+
question: row.questionText ?? '',
|
|
425
|
+
type: row.questionType ?? null,
|
|
426
|
+
responses: 0,
|
|
427
|
+
respondents: new Set<string>(),
|
|
428
|
+
answers: new Map<string, number>(),
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
current.responses += 1
|
|
432
|
+
current.respondents.add(row.respondentKey)
|
|
433
|
+
const answer = row.answer ?? '(no answer)'
|
|
434
|
+
current.answers.set(answer, (current.answers.get(answer) ?? 0) + 1)
|
|
435
|
+
grouped.set(row.questionId, current)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return Array.from(grouped.values())
|
|
439
|
+
.sort(
|
|
440
|
+
(a: { responses: number }, b: { responses: number }) =>
|
|
441
|
+
b.responses - a.responses,
|
|
442
|
+
)
|
|
443
|
+
.slice(0, limit)
|
|
444
|
+
.map((entry) => ({
|
|
445
|
+
questionId: entry.questionId,
|
|
446
|
+
question: entry.question,
|
|
447
|
+
type: entry.type,
|
|
448
|
+
responses: entry.responses,
|
|
449
|
+
uniqueRespondents: entry.respondents.size,
|
|
450
|
+
answerDistribution: Array.from(entry.answers.entries())
|
|
451
|
+
.sort((a: [string, number], b: [string, number]) => b[1] - a[1])
|
|
452
|
+
.map(([answer, count]) => ({ answer, count })),
|
|
453
|
+
}))
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ─── Individual Response Rows ────────────────────────────────────────────
|
|
457
|
+
|
|
458
|
+
async function getSurveyResponses(
|
|
459
|
+
range: AnalyticsRange = '30d',
|
|
460
|
+
limit = 100,
|
|
461
|
+
) {
|
|
462
|
+
const canonicalRows = await fetchCanonicalRows(range)
|
|
463
|
+
|
|
464
|
+
return canonicalRows.slice(0, limit).map((row) => ({
|
|
465
|
+
responseId: row.responseId,
|
|
466
|
+
surveyId: row.surveyId,
|
|
467
|
+
surveyTitle: row.surveyTitle ?? '',
|
|
468
|
+
surveySlug: row.surveySlug ?? '',
|
|
469
|
+
questionId: row.questionId,
|
|
470
|
+
questionText: row.questionText ?? '',
|
|
471
|
+
questionType: row.questionType ?? null,
|
|
472
|
+
answer: row.answer ?? '',
|
|
473
|
+
userId: row.userId ?? null,
|
|
474
|
+
userEmail: row.userEmail ?? null,
|
|
475
|
+
emailListSubscriberId: row.emailListSubscriberId ?? null,
|
|
476
|
+
createdAt: row.createdAt ? String(row.createdAt) : '',
|
|
477
|
+
}))
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return {
|
|
481
|
+
getSurveySummary,
|
|
482
|
+
getSurveyList,
|
|
483
|
+
getSurveyResponsesByDay,
|
|
484
|
+
getSurveyQuestionBreakdown,
|
|
485
|
+
getSurveyResponses,
|
|
486
|
+
}
|
|
487
|
+
}
|