@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.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/dist/api/index.d.ts +158 -0
  3. package/dist/api/index.js +317 -0
  4. package/dist/api/index.js.map +1 -0
  5. package/dist/catalog.d.ts +14 -0
  6. package/dist/catalog.js +209 -0
  7. package/dist/catalog.js.map +1 -0
  8. package/dist/components/index.d.ts +172 -0
  9. package/dist/components/index.js +1258 -0
  10. package/dist/components/index.js.map +1 -0
  11. package/dist/engine.d.ts +20 -0
  12. package/dist/engine.js +350 -0
  13. package/dist/engine.js.map +1 -0
  14. package/dist/index.d.ts +3 -0
  15. package/dist/index.js +353 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/providers/database.d.ts +79 -0
  18. package/dist/providers/database.js +533 -0
  19. package/dist/providers/database.js.map +1 -0
  20. package/dist/providers/derived.d.ts +45 -0
  21. package/dist/providers/derived.js +32 -0
  22. package/dist/providers/derived.js.map +1 -0
  23. package/dist/providers/ga4.d.ts +43 -0
  24. package/dist/providers/ga4.js +220 -0
  25. package/dist/providers/ga4.js.map +1 -0
  26. package/dist/providers/index.d.ts +8 -0
  27. package/dist/providers/index.js +1239 -0
  28. package/dist/providers/index.js.map +1 -0
  29. package/dist/providers/mux.d.ts +103 -0
  30. package/dist/providers/mux.js +241 -0
  31. package/dist/providers/mux.js.map +1 -0
  32. package/dist/providers/survey.d.ts +102 -0
  33. package/dist/providers/survey.js +233 -0
  34. package/dist/providers/survey.js.map +1 -0
  35. package/dist/types.d.ts +303 -0
  36. package/dist/types.js +1 -0
  37. package/dist/types.js.map +1 -0
  38. package/package.json +101 -0
  39. package/src/api/catalog-handler.ts +321 -0
  40. package/src/api/index.ts +4 -0
  41. package/src/api/token-handler.ts +71 -0
  42. package/src/catalog.ts +223 -0
  43. package/src/components/country-chart.tsx +114 -0
  44. package/src/components/index.ts +5 -0
  45. package/src/components/omnibus-dashboard.tsx +1460 -0
  46. package/src/components/revenue-chart.tsx +251 -0
  47. package/src/components/use-chart-colors.ts +75 -0
  48. package/src/engine.ts +201 -0
  49. package/src/index.ts +7 -0
  50. package/src/providers/database.ts +795 -0
  51. package/src/providers/derived.ts +79 -0
  52. package/src/providers/ga4.ts +173 -0
  53. package/src/providers/index.ts +44 -0
  54. package/src/providers/mux.ts +438 -0
  55. package/src/providers/survey.ts +487 -0
  56. 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
+ }