@coursebuilder/analytics 1.1.0 → 1.1.1
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/dist/api/index.d.ts +22 -2
- package/dist/api/index.js +40 -5
- package/dist/api/index.js.map +1 -1
- package/dist/catalog.d.ts +1 -1
- package/dist/catalog.js +43 -1
- package/dist/catalog.js.map +1 -1
- package/dist/components/index.d.ts +29 -0
- package/dist/components/index.js +91 -2
- package/dist/components/index.js.map +1 -1
- package/dist/engine.js +94 -6
- package/dist/engine.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +94 -6
- package/dist/index.js.map +1 -1
- package/dist/providers/database.d.ts +144 -2
- package/dist/providers/database.js +652 -20
- package/dist/providers/database.js.map +1 -1
- package/dist/providers/index.js +654 -22
- package/dist/providers/index.js.map +1 -1
- package/dist/providers/survey.d.ts +1 -1
- package/dist/providers/survey.js +2 -2
- package/dist/providers/survey.js.map +1 -1
- package/dist/types.d.ts +151 -3
- package/package.json +5 -3
- package/src/api/catalog-handler.ts +44 -2
- package/src/api/token-handler.ts +3 -2
- package/src/catalog.ts +49 -1
- package/src/components/omnibus-dashboard.tsx +163 -0
- package/src/engine.ts +66 -6
- package/src/providers/attribution-recovery.test.ts +63 -0
- package/src/providers/attribution-recovery.ts +97 -0
- package/src/providers/database.ts +812 -42
- package/src/providers/survey.ts +3 -1
- package/src/types.ts +166 -2
|
@@ -75,7 +75,7 @@ interface SurveyAnalyticsProvider {
|
|
|
75
75
|
count: number;
|
|
76
76
|
}>;
|
|
77
77
|
}>>;
|
|
78
|
-
getSurveyResponses: (range?: AnalyticsRange, limit?: number) => Promise<Array<{
|
|
78
|
+
getSurveyResponses: (range?: AnalyticsRange, limit?: number, offset?: number) => Promise<Array<{
|
|
79
79
|
responseId: string;
|
|
80
80
|
surveyId: string;
|
|
81
81
|
surveyTitle: string;
|
package/dist/providers/survey.js
CHANGED
|
@@ -200,9 +200,9 @@ function createSurveyProvider(db, schema) {
|
|
|
200
200
|
}));
|
|
201
201
|
}
|
|
202
202
|
__name(getSurveyQuestionBreakdown, "getSurveyQuestionBreakdown");
|
|
203
|
-
async function getSurveyResponses(range = "30d", limit = 100) {
|
|
203
|
+
async function getSurveyResponses(range = "30d", limit = 100, offset = 0) {
|
|
204
204
|
const canonicalRows = await fetchCanonicalRows(range);
|
|
205
|
-
return canonicalRows.slice(
|
|
205
|
+
return canonicalRows.slice(offset, offset + limit).map((row) => ({
|
|
206
206
|
responseId: row.responseId,
|
|
207
207
|
surveyId: row.surveyId,
|
|
208
208
|
surveyTitle: row.surveyTitle ?? "",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/providers/survey.ts"],"sourcesContent":["import { and, count, eq, sql } from 'drizzle-orm'\n\nimport type { AnalyticsRange } from '../types'\n\n// ─── Schema types ────────────────────────────────────────────────────────────\n\n/**\n * Minimal column shape required from the contentResource table.\n * We only reference the columns we actually query against.\n */\ninterface ContentResourceTable {\n\tid: any\n\ttype: any\n\tfields: any\n}\n\n/**\n * Minimal column shape required from the contentResourceResource table.\n */\ninterface ContentResourceResourceTable {\n\tresourceOfId: any\n\tresourceId: any\n}\n\n/**\n * Minimal column shape required from the questionResponse table.\n */\ninterface QuestionResponseTable {\n\tid: any\n\tsurveyId: any\n\tquestionId: any\n\trespondentKey: any\n\tsurveySessionId: any\n\tuserId: any\n\temailListSubscriberId: any\n\tcreatedAt: any\n\tupdatedAt: any\n\tfields: any\n}\n\n/**\n * Minimal column shape required from the users table.\n * Optional — only needed for getSurveyResponses to resolve user emails.\n */\ninterface UsersTable {\n\tid: any\n\temail: any\n}\n\nexport interface SurveyAnalyticsSchema {\n\tcontentResource: ContentResourceTable\n\tcontentResourceResource: ContentResourceResourceTable\n\tquestionResponse: QuestionResponseTable\n\tusers?: UsersTable\n}\n\ntype CanonicalSurveyRow = {\n\tresponseId: string\n\tsurveyId: string\n\tsurveyTitle: string | null\n\tsurveySlug: string | null\n\tquestionId: string\n\tquestionText: string | null\n\tquestionType: string | null\n\tanswer: string | null\n\trespondentKey: string\n\tsurveySessionId: string | null\n\tuserId: string | null\n\tuserEmail: string | null\n\temailListSubscriberId: string | null\n\tcreatedAt: Date | null\n\tupdatedAt: Date | null\n}\n\n// ─── Return type ─────────────────────────────────────────────────────────────\n\nexport interface SurveyAnalyticsProvider {\n\tgetSurveySummary: (range?: AnalyticsRange) => Promise<{\n\t\ttotalSurveys: number\n\t\ttotalResponses: number\n\t\tuniqueRespondents: number\n\t\tavgResponsesPerSurvey: number\n\t}>\n\n\tgetSurveyList: (range?: AnalyticsRange) => Promise<\n\t\tArray<{\n\t\t\tsurveyId: string\n\t\t\tsurveyTitle: string\n\t\t\tsurveySlug: string\n\t\t\tresponses: number\n\t\t\tuniqueRespondents: number\n\t\t\tquestionCount: number\n\t\t}>\n\t>\n\n\tgetSurveyResponsesByDay: (range?: AnalyticsRange) => Promise<\n\t\tArray<{\n\t\t\tdate: string\n\t\t\tresponses: number\n\t\t}>\n\t>\n\n\tgetSurveyQuestionBreakdown: (\n\t\trange?: AnalyticsRange,\n\t\tlimit?: number,\n\t) => Promise<\n\t\tArray<{\n\t\t\tquestionId: string\n\t\t\tquestion: string\n\t\t\ttype: string | null\n\t\t\tresponses: number\n\t\t\tuniqueRespondents: number\n\t\t\tanswerDistribution: Array<{ answer: string; count: number }>\n\t\t}>\n\t>\n\n\tgetSurveyResponses: (\n\t\trange?: AnalyticsRange,\n\t\tlimit?: number,\n\t) => Promise<\n\t\tArray<{\n\t\t\tresponseId: string\n\t\t\tsurveyId: string\n\t\t\tsurveyTitle: string\n\t\t\tsurveySlug: string\n\t\t\tquestionId: string\n\t\t\tquestionText: string\n\t\t\tquestionType: string | null\n\t\t\tanswer: string\n\t\t\tuserId: string | null\n\t\t\tuserEmail: string | null\n\t\t\temailListSubscriberId: string | null\n\t\t\tcreatedAt: string\n\t\t}>\n\t>\n}\n\nfunction normalizeRespondentKey(row: {\n\trespondentKey: string | null\n\tuserId: string | null\n\temailListSubscriberId: string | null\n\tsurveySessionId: string | null\n}) {\n\tif (row.respondentKey) return row.respondentKey\n\tif (row.userId) return `user:${row.userId}`\n\tif (row.emailListSubscriberId) {\n\t\treturn `subscriber:${row.emailListSubscriberId}`\n\t}\n\tif (row.surveySessionId) return `session:${row.surveySessionId}`\n\treturn null\n}\n\nfunction getRowTimestamp(row: {\n\tupdatedAt: Date | null\n\tcreatedAt: Date | null\n}) {\n\tconst date = row.updatedAt ?? row.createdAt\n\treturn date instanceof Date ? date.getTime() : 0\n}\n\nfunction sortByNewest<\n\tT extends { createdAt: Date | null; updatedAt: Date | null },\n>(rows: T[]) {\n\treturn [...rows].sort((a, b) => getRowTimestamp(b) - getRowTimestamp(a))\n}\n\n// ─── Factory ─────────────────────────────────────────────────────────────────\n\n/**\n * Creates a survey analytics provider bound to the given Drizzle db instance\n * and schema tables.\n *\n * @param db - Drizzle database instance\n * @param schema - Object containing the required table references\n */\nexport function createSurveyProvider(\n\tdb: any,\n\tschema: SurveyAnalyticsSchema,\n): SurveyAnalyticsProvider {\n\tconst { contentResource, contentResourceResource, questionResponse } = schema\n\n\t// ─── Range helpers ───────────────────────────────────────────────────────\n\n\tfunction rangeToInterval(range: AnalyticsRange): string {\n\t\tswitch (range) {\n\t\t\tcase '24h':\n\t\t\t\treturn '1 DAY'\n\t\t\tcase '7d':\n\t\t\t\treturn '7 DAY'\n\t\t\tcase '30d':\n\t\t\t\treturn '30 DAY'\n\t\t\tcase '90d':\n\t\t\t\treturn '90 DAY'\n\t\t\tcase 'all':\n\t\t\t\treturn '3650 DAY'\n\t\t}\n\t}\n\n\tfunction rangeWhere(range: AnalyticsRange, column: any) {\n\t\treturn sql`${column} >= DATE_SUB(NOW(), INTERVAL ${sql.raw(rangeToInterval(range))})`\n\t}\n\n\tasync function fetchCanonicalRows(range: AnalyticsRange) {\n\t\tconst { users } = schema\n\n\t\tconst rawRows = users\n\t\t\t? await db\n\t\t\t\t\t.select({\n\t\t\t\t\t\tresponseId: questionResponse.id,\n\t\t\t\t\t\tsurveyId: questionResponse.surveyId,\n\t\t\t\t\t\tsurveyTitle: sql<string>`JSON_UNQUOTE(JSON_EXTRACT(survey_cr.fields, '$.title'))`,\n\t\t\t\t\t\tsurveySlug: sql<string>`JSON_UNQUOTE(JSON_EXTRACT(survey_cr.fields, '$.slug'))`,\n\t\t\t\t\t\tquestionId: questionResponse.questionId,\n\t\t\t\t\t\tquestionText: sql<string>`JSON_UNQUOTE(JSON_EXTRACT(question_cr.fields, '$.question'))`,\n\t\t\t\t\t\tquestionType: sql<string>`JSON_UNQUOTE(JSON_EXTRACT(question_cr.fields, '$.type'))`,\n\t\t\t\t\t\tanswer: sql<string>`JSON_UNQUOTE(JSON_EXTRACT(${questionResponse.fields}, '$.answer'))`,\n\t\t\t\t\t\trespondentKey: questionResponse.respondentKey,\n\t\t\t\t\t\tsurveySessionId: questionResponse.surveySessionId,\n\t\t\t\t\t\tuserId: questionResponse.userId,\n\t\t\t\t\t\tuserEmail: users.email,\n\t\t\t\t\t\temailListSubscriberId: questionResponse.emailListSubscriberId,\n\t\t\t\t\t\tcreatedAt: questionResponse.createdAt,\n\t\t\t\t\t\tupdatedAt: questionResponse.updatedAt,\n\t\t\t\t\t})\n\t\t\t\t\t.from(questionResponse)\n\t\t\t\t\t.leftJoin(\n\t\t\t\t\t\tsql`${contentResource} AS survey_cr`,\n\t\t\t\t\t\tsql`survey_cr.id = ${questionResponse.surveyId}`,\n\t\t\t\t\t)\n\t\t\t\t\t.leftJoin(\n\t\t\t\t\t\tsql`${contentResource} AS question_cr`,\n\t\t\t\t\t\tsql`question_cr.id = ${questionResponse.questionId}`,\n\t\t\t\t\t)\n\t\t\t\t\t.leftJoin(users, eq(questionResponse.userId, users.id))\n\t\t\t\t\t.where(rangeWhere(range, questionResponse.createdAt))\n\t\t\t: await db\n\t\t\t\t\t.select({\n\t\t\t\t\t\tresponseId: questionResponse.id,\n\t\t\t\t\t\tsurveyId: questionResponse.surveyId,\n\t\t\t\t\t\tsurveyTitle: sql<string>`JSON_UNQUOTE(JSON_EXTRACT(survey_cr.fields, '$.title'))`,\n\t\t\t\t\t\tsurveySlug: sql<string>`JSON_UNQUOTE(JSON_EXTRACT(survey_cr.fields, '$.slug'))`,\n\t\t\t\t\t\tquestionId: questionResponse.questionId,\n\t\t\t\t\t\tquestionText: sql<string>`JSON_UNQUOTE(JSON_EXTRACT(question_cr.fields, '$.question'))`,\n\t\t\t\t\t\tquestionType: sql<string>`JSON_UNQUOTE(JSON_EXTRACT(question_cr.fields, '$.type'))`,\n\t\t\t\t\t\tanswer: sql<string>`JSON_UNQUOTE(JSON_EXTRACT(${questionResponse.fields}, '$.answer'))`,\n\t\t\t\t\t\trespondentKey: questionResponse.respondentKey,\n\t\t\t\t\t\tsurveySessionId: questionResponse.surveySessionId,\n\t\t\t\t\t\tuserId: questionResponse.userId,\n\t\t\t\t\t\tuserEmail: sql<string | null>`NULL`,\n\t\t\t\t\t\temailListSubscriberId: questionResponse.emailListSubscriberId,\n\t\t\t\t\t\tcreatedAt: questionResponse.createdAt,\n\t\t\t\t\t\tupdatedAt: questionResponse.updatedAt,\n\t\t\t\t\t})\n\t\t\t\t\t.from(questionResponse)\n\t\t\t\t\t.leftJoin(\n\t\t\t\t\t\tsql`${contentResource} AS survey_cr`,\n\t\t\t\t\t\tsql`survey_cr.id = ${questionResponse.surveyId}`,\n\t\t\t\t\t)\n\t\t\t\t\t.leftJoin(\n\t\t\t\t\t\tsql`${contentResource} AS question_cr`,\n\t\t\t\t\t\tsql`question_cr.id = ${questionResponse.questionId}`,\n\t\t\t\t\t)\n\t\t\t\t\t.where(rangeWhere(range, questionResponse.createdAt))\n\n\t\tconst latestByAnswer = new Map<string, CanonicalSurveyRow>()\n\n\t\tfor (const row of rawRows) {\n\t\t\tconst respondentKey = normalizeRespondentKey(row)\n\t\t\tif (!respondentKey) continue\n\n\t\t\tconst dedupeKey = `${row.surveyId}::${row.questionId}::${respondentKey}`\n\t\t\tconst current = latestByAnswer.get(dedupeKey)\n\n\t\t\tif (!current || getRowTimestamp(row) >= getRowTimestamp(current)) {\n\t\t\t\tlatestByAnswer.set(dedupeKey, {\n\t\t\t\t\t...row,\n\t\t\t\t\trespondentKey,\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\n\t\treturn sortByNewest(Array.from(latestByAnswer.values()))\n\t}\n\n\t// ─── Survey Summary ──────────────────────────────────────────────────────\n\n\tasync function getSurveySummary(range: AnalyticsRange = '30d') {\n\t\tconst [surveyCount] = await db\n\t\t\t.select({ total: count() })\n\t\t\t.from(contentResource)\n\t\t\t.where(eq(contentResource.type, 'survey'))\n\n\t\tconst canonicalRows = await fetchCanonicalRows(range)\n\t\tconst respondentKeys = new Set(\n\t\t\tcanonicalRows.map((row) => row.respondentKey),\n\t\t)\n\t\tconst totalSurveys = surveyCount?.total ?? 0\n\t\tconst totalResponses = canonicalRows.length\n\n\t\treturn {\n\t\t\ttotalSurveys,\n\t\t\ttotalResponses,\n\t\t\tuniqueRespondents: respondentKeys.size,\n\t\t\tavgResponsesPerSurvey:\n\t\t\t\ttotalSurveys > 0 ? totalResponses / totalSurveys : 0,\n\t\t}\n\t}\n\n\t// ─── Survey List ─────────────────────────────────────────────────────────\n\n\tasync function getSurveyList(range: AnalyticsRange = '30d') {\n\t\tconst canonicalRows = await fetchCanonicalRows(range)\n\t\tconst responsesBySurvey = new Map<\n\t\t\tstring,\n\t\t\t{ responses: number; respondents: Set<string> }\n\t\t>()\n\n\t\tfor (const row of canonicalRows) {\n\t\t\tconst current = responsesBySurvey.get(row.surveyId) ?? {\n\t\t\t\tresponses: 0,\n\t\t\t\trespondents: new Set<string>(),\n\t\t\t}\n\t\t\tcurrent.responses += 1\n\t\t\tcurrent.respondents.add(row.respondentKey)\n\t\t\tresponsesBySurvey.set(row.surveyId, current)\n\t\t}\n\n\t\tconst surveys = await db\n\t\t\t.select({\n\t\t\t\tsurveyId: contentResource.id,\n\t\t\t\tsurveyTitle: sql<string>`JSON_UNQUOTE(JSON_EXTRACT(${contentResource.fields}, '$.title'))`,\n\t\t\t\tsurveySlug: sql<string>`JSON_UNQUOTE(JSON_EXTRACT(${contentResource.fields}, '$.slug'))`,\n\t\t\t})\n\t\t\t.from(contentResource)\n\t\t\t.where(eq(contentResource.type, 'survey'))\n\n\t\tconst questionCounts = await db\n\t\t\t.select({\n\t\t\t\tsurveyId: contentResourceResource.resourceOfId,\n\t\t\t\tquestionCount: count(),\n\t\t\t})\n\t\t\t.from(contentResourceResource)\n\t\t\t.innerJoin(\n\t\t\t\tcontentResource,\n\t\t\t\tand(\n\t\t\t\t\teq(contentResourceResource.resourceId, contentResource.id),\n\t\t\t\t\teq(contentResource.type, 'question'),\n\t\t\t\t),\n\t\t\t)\n\t\t\t.groupBy(contentResourceResource.resourceOfId)\n\n\t\tconst questionCountMap = new Map(\n\t\t\tquestionCounts.map((qc: { surveyId: string; questionCount: number }) => [\n\t\t\t\tqc.surveyId,\n\t\t\t\tqc.questionCount,\n\t\t\t]),\n\t\t)\n\n\t\treturn surveys\n\t\t\t.map(\n\t\t\t\t(s: {\n\t\t\t\t\tsurveyId: string\n\t\t\t\t\tsurveyTitle: string | null\n\t\t\t\t\tsurveySlug: string | null\n\t\t\t\t}) => {\n\t\t\t\t\tconst counts = responsesBySurvey.get(s.surveyId)\n\t\t\t\t\treturn {\n\t\t\t\t\t\tsurveyId: s.surveyId,\n\t\t\t\t\t\tsurveyTitle: s.surveyTitle ?? '',\n\t\t\t\t\t\tsurveySlug: s.surveySlug ?? '',\n\t\t\t\t\t\tresponses: counts?.responses ?? 0,\n\t\t\t\t\t\tuniqueRespondents: counts?.respondents.size ?? 0,\n\t\t\t\t\t\tquestionCount: questionCountMap.get(s.surveyId) ?? 0,\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t)\n\t\t\t.sort(\n\t\t\t\t(a: { responses: number }, b: { responses: number }) =>\n\t\t\t\t\tb.responses - a.responses,\n\t\t\t)\n\t}\n\n\t// ─── Daily Responses ─────────────────────────────────────────────────────\n\n\tasync function getSurveyResponsesByDay(range: AnalyticsRange = '30d') {\n\t\tconst canonicalRows = await fetchCanonicalRows(range)\n\t\tconst grouped = new Map<string, number>()\n\n\t\tfor (const row of canonicalRows) {\n\t\t\tif (!(row.createdAt instanceof Date)) continue\n\t\t\tconst date = row.createdAt.toISOString().slice(0, 10)\n\t\t\tgrouped.set(date, (grouped.get(date) ?? 0) + 1)\n\t\t}\n\n\t\treturn Array.from(grouped.entries())\n\t\t\t.sort((entryA: [string, number], entryB: [string, number]) =>\n\t\t\t\tentryA[0].localeCompare(entryB[0]),\n\t\t\t)\n\t\t\t.map(([date, responses]) => ({ date, responses }))\n\t}\n\n\t// ─── Question Breakdown ──────────────────────────────────────────────────\n\n\tasync function getSurveyQuestionBreakdown(\n\t\trange: AnalyticsRange = '30d',\n\t\tlimit = 20,\n\t) {\n\t\tconst canonicalRows = await fetchCanonicalRows(range)\n\t\tconst grouped = new Map<\n\t\t\tstring,\n\t\t\t{\n\t\t\t\tquestionId: string\n\t\t\t\tquestion: string\n\t\t\t\ttype: string | null\n\t\t\t\tresponses: number\n\t\t\t\trespondents: Set<string>\n\t\t\t\tanswers: Map<string, number>\n\t\t\t}\n\t\t>()\n\n\t\tfor (const row of canonicalRows) {\n\t\t\tconst current = grouped.get(row.questionId) ?? {\n\t\t\t\tquestionId: row.questionId,\n\t\t\t\tquestion: row.questionText ?? '',\n\t\t\t\ttype: row.questionType ?? null,\n\t\t\t\tresponses: 0,\n\t\t\t\trespondents: new Set<string>(),\n\t\t\t\tanswers: new Map<string, number>(),\n\t\t\t}\n\n\t\t\tcurrent.responses += 1\n\t\t\tcurrent.respondents.add(row.respondentKey)\n\t\t\tconst answer = row.answer ?? '(no answer)'\n\t\t\tcurrent.answers.set(answer, (current.answers.get(answer) ?? 0) + 1)\n\t\t\tgrouped.set(row.questionId, current)\n\t\t}\n\n\t\treturn Array.from(grouped.values())\n\t\t\t.sort(\n\t\t\t\t(a: { responses: number }, b: { responses: number }) =>\n\t\t\t\t\tb.responses - a.responses,\n\t\t\t)\n\t\t\t.slice(0, limit)\n\t\t\t.map((entry) => ({\n\t\t\t\tquestionId: entry.questionId,\n\t\t\t\tquestion: entry.question,\n\t\t\t\ttype: entry.type,\n\t\t\t\tresponses: entry.responses,\n\t\t\t\tuniqueRespondents: entry.respondents.size,\n\t\t\t\tanswerDistribution: Array.from(entry.answers.entries())\n\t\t\t\t\t.sort((a: [string, number], b: [string, number]) => b[1] - a[1])\n\t\t\t\t\t.map(([answer, count]) => ({ answer, count })),\n\t\t\t}))\n\t}\n\n\t// ─── Individual Response Rows ────────────────────────────────────────────\n\n\tasync function getSurveyResponses(\n\t\trange: AnalyticsRange = '30d',\n\t\tlimit = 100,\n\t) {\n\t\tconst canonicalRows = await fetchCanonicalRows(range)\n\n\t\treturn canonicalRows.slice(0, limit).map((row) => ({\n\t\t\tresponseId: row.responseId,\n\t\t\tsurveyId: row.surveyId,\n\t\t\tsurveyTitle: row.surveyTitle ?? '',\n\t\t\tsurveySlug: row.surveySlug ?? '',\n\t\t\tquestionId: row.questionId,\n\t\t\tquestionText: row.questionText ?? '',\n\t\t\tquestionType: row.questionType ?? null,\n\t\t\tanswer: row.answer ?? '',\n\t\t\tuserId: row.userId ?? null,\n\t\t\tuserEmail: row.userEmail ?? null,\n\t\t\temailListSubscriberId: row.emailListSubscriberId ?? null,\n\t\t\tcreatedAt: row.createdAt ? String(row.createdAt) : '',\n\t\t}))\n\t}\n\n\treturn {\n\t\tgetSurveySummary,\n\t\tgetSurveyList,\n\t\tgetSurveyResponsesByDay,\n\t\tgetSurveyQuestionBreakdown,\n\t\tgetSurveyResponses,\n\t}\n}\n"],"mappings":"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","names":["and","count","eq","sql","normalizeRespondentKey","row","respondentKey","userId","emailListSubscriberId","surveySessionId","getRowTimestamp","date","updatedAt","createdAt","Date","getTime","sortByNewest","rows","sort","a","b","createSurveyProvider","db","schema","contentResource","contentResourceResource","questionResponse","rangeToInterval","range","rangeWhere","column","sql","raw","fetchCanonicalRows","users","rawRows","select","responseId","id","surveyId","surveyTitle","surveySlug","questionId","questionText","questionType","answer","fields","userEmail","email","from","leftJoin","eq","where","latestByAnswer","Map","dedupeKey","current","get","set","Array","values","getSurveySummary","surveyCount","total","count","type","canonicalRows","respondentKeys","Set","map","totalSurveys","totalResponses","length","uniqueRespondents","size","avgResponsesPerSurvey","getSurveyList","responsesBySurvey","responses","respondents","add","surveys","questionCounts","resourceOfId","questionCount","innerJoin","and","resourceId","groupBy","questionCountMap","qc","s","counts","getSurveyResponsesByDay","grouped","toISOString","slice","entries","entryA","entryB","localeCompare","getSurveyQuestionBreakdown","limit","question","answers","entry","answerDistribution","getSurveyResponses","String"]}
|
|
1
|
+
{"version":3,"sources":["../../src/providers/survey.ts"],"sourcesContent":["import { and, count, eq, sql } from 'drizzle-orm'\n\nimport type { AnalyticsRange } from '../types'\n\n// ─── Schema types ────────────────────────────────────────────────────────────\n\n/**\n * Minimal column shape required from the contentResource table.\n * We only reference the columns we actually query against.\n */\ninterface ContentResourceTable {\n\tid: any\n\ttype: any\n\tfields: any\n}\n\n/**\n * Minimal column shape required from the contentResourceResource table.\n */\ninterface ContentResourceResourceTable {\n\tresourceOfId: any\n\tresourceId: any\n}\n\n/**\n * Minimal column shape required from the questionResponse table.\n */\ninterface QuestionResponseTable {\n\tid: any\n\tsurveyId: any\n\tquestionId: any\n\trespondentKey: any\n\tsurveySessionId: any\n\tuserId: any\n\temailListSubscriberId: any\n\tcreatedAt: any\n\tupdatedAt: any\n\tfields: any\n}\n\n/**\n * Minimal column shape required from the users table.\n * Optional — only needed for getSurveyResponses to resolve user emails.\n */\ninterface UsersTable {\n\tid: any\n\temail: any\n}\n\nexport interface SurveyAnalyticsSchema {\n\tcontentResource: ContentResourceTable\n\tcontentResourceResource: ContentResourceResourceTable\n\tquestionResponse: QuestionResponseTable\n\tusers?: UsersTable\n}\n\ntype CanonicalSurveyRow = {\n\tresponseId: string\n\tsurveyId: string\n\tsurveyTitle: string | null\n\tsurveySlug: string | null\n\tquestionId: string\n\tquestionText: string | null\n\tquestionType: string | null\n\tanswer: string | null\n\trespondentKey: string\n\tsurveySessionId: string | null\n\tuserId: string | null\n\tuserEmail: string | null\n\temailListSubscriberId: string | null\n\tcreatedAt: Date | null\n\tupdatedAt: Date | null\n}\n\n// ─── Return type ─────────────────────────────────────────────────────────────\n\nexport interface SurveyAnalyticsProvider {\n\tgetSurveySummary: (range?: AnalyticsRange) => Promise<{\n\t\ttotalSurveys: number\n\t\ttotalResponses: number\n\t\tuniqueRespondents: number\n\t\tavgResponsesPerSurvey: number\n\t}>\n\n\tgetSurveyList: (range?: AnalyticsRange) => Promise<\n\t\tArray<{\n\t\t\tsurveyId: string\n\t\t\tsurveyTitle: string\n\t\t\tsurveySlug: string\n\t\t\tresponses: number\n\t\t\tuniqueRespondents: number\n\t\t\tquestionCount: number\n\t\t}>\n\t>\n\n\tgetSurveyResponsesByDay: (range?: AnalyticsRange) => Promise<\n\t\tArray<{\n\t\t\tdate: string\n\t\t\tresponses: number\n\t\t}>\n\t>\n\n\tgetSurveyQuestionBreakdown: (\n\t\trange?: AnalyticsRange,\n\t\tlimit?: number,\n\t) => Promise<\n\t\tArray<{\n\t\t\tquestionId: string\n\t\t\tquestion: string\n\t\t\ttype: string | null\n\t\t\tresponses: number\n\t\t\tuniqueRespondents: number\n\t\t\tanswerDistribution: Array<{ answer: string; count: number }>\n\t\t}>\n\t>\n\n\tgetSurveyResponses: (\n\t\trange?: AnalyticsRange,\n\t\tlimit?: number,\n\t\toffset?: number,\n\t) => Promise<\n\t\tArray<{\n\t\t\tresponseId: string\n\t\t\tsurveyId: string\n\t\t\tsurveyTitle: string\n\t\t\tsurveySlug: string\n\t\t\tquestionId: string\n\t\t\tquestionText: string\n\t\t\tquestionType: string | null\n\t\t\tanswer: string\n\t\t\tuserId: string | null\n\t\t\tuserEmail: string | null\n\t\t\temailListSubscriberId: string | null\n\t\t\tcreatedAt: string\n\t\t}>\n\t>\n}\n\nfunction normalizeRespondentKey(row: {\n\trespondentKey: string | null\n\tuserId: string | null\n\temailListSubscriberId: string | null\n\tsurveySessionId: string | null\n}) {\n\tif (row.respondentKey) return row.respondentKey\n\tif (row.userId) return `user:${row.userId}`\n\tif (row.emailListSubscriberId) {\n\t\treturn `subscriber:${row.emailListSubscriberId}`\n\t}\n\tif (row.surveySessionId) return `session:${row.surveySessionId}`\n\treturn null\n}\n\nfunction getRowTimestamp(row: {\n\tupdatedAt: Date | null\n\tcreatedAt: Date | null\n}) {\n\tconst date = row.updatedAt ?? row.createdAt\n\treturn date instanceof Date ? date.getTime() : 0\n}\n\nfunction sortByNewest<\n\tT extends { createdAt: Date | null; updatedAt: Date | null },\n>(rows: T[]) {\n\treturn [...rows].sort((a, b) => getRowTimestamp(b) - getRowTimestamp(a))\n}\n\n// ─── Factory ─────────────────────────────────────────────────────────────────\n\n/**\n * Creates a survey analytics provider bound to the given Drizzle db instance\n * and schema tables.\n *\n * @param db - Drizzle database instance\n * @param schema - Object containing the required table references\n */\nexport function createSurveyProvider(\n\tdb: any,\n\tschema: SurveyAnalyticsSchema,\n): SurveyAnalyticsProvider {\n\tconst { contentResource, contentResourceResource, questionResponse } = schema\n\n\t// ─── Range helpers ───────────────────────────────────────────────────────\n\n\tfunction rangeToInterval(range: AnalyticsRange): string {\n\t\tswitch (range) {\n\t\t\tcase '24h':\n\t\t\t\treturn '1 DAY'\n\t\t\tcase '7d':\n\t\t\t\treturn '7 DAY'\n\t\t\tcase '30d':\n\t\t\t\treturn '30 DAY'\n\t\t\tcase '90d':\n\t\t\t\treturn '90 DAY'\n\t\t\tcase 'all':\n\t\t\t\treturn '3650 DAY'\n\t\t}\n\t}\n\n\tfunction rangeWhere(range: AnalyticsRange, column: any) {\n\t\treturn sql`${column} >= DATE_SUB(NOW(), INTERVAL ${sql.raw(rangeToInterval(range))})`\n\t}\n\n\tasync function fetchCanonicalRows(range: AnalyticsRange) {\n\t\tconst { users } = schema\n\n\t\tconst rawRows = users\n\t\t\t? await db\n\t\t\t\t\t.select({\n\t\t\t\t\t\tresponseId: questionResponse.id,\n\t\t\t\t\t\tsurveyId: questionResponse.surveyId,\n\t\t\t\t\t\tsurveyTitle: sql<string>`JSON_UNQUOTE(JSON_EXTRACT(survey_cr.fields, '$.title'))`,\n\t\t\t\t\t\tsurveySlug: sql<string>`JSON_UNQUOTE(JSON_EXTRACT(survey_cr.fields, '$.slug'))`,\n\t\t\t\t\t\tquestionId: questionResponse.questionId,\n\t\t\t\t\t\tquestionText: sql<string>`JSON_UNQUOTE(JSON_EXTRACT(question_cr.fields, '$.question'))`,\n\t\t\t\t\t\tquestionType: sql<string>`JSON_UNQUOTE(JSON_EXTRACT(question_cr.fields, '$.type'))`,\n\t\t\t\t\t\tanswer: sql<string>`JSON_UNQUOTE(JSON_EXTRACT(${questionResponse.fields}, '$.answer'))`,\n\t\t\t\t\t\trespondentKey: questionResponse.respondentKey,\n\t\t\t\t\t\tsurveySessionId: questionResponse.surveySessionId,\n\t\t\t\t\t\tuserId: questionResponse.userId,\n\t\t\t\t\t\tuserEmail: users.email,\n\t\t\t\t\t\temailListSubscriberId: questionResponse.emailListSubscriberId,\n\t\t\t\t\t\tcreatedAt: questionResponse.createdAt,\n\t\t\t\t\t\tupdatedAt: questionResponse.updatedAt,\n\t\t\t\t\t})\n\t\t\t\t\t.from(questionResponse)\n\t\t\t\t\t.leftJoin(\n\t\t\t\t\t\tsql`${contentResource} AS survey_cr`,\n\t\t\t\t\t\tsql`survey_cr.id = ${questionResponse.surveyId}`,\n\t\t\t\t\t)\n\t\t\t\t\t.leftJoin(\n\t\t\t\t\t\tsql`${contentResource} AS question_cr`,\n\t\t\t\t\t\tsql`question_cr.id = ${questionResponse.questionId}`,\n\t\t\t\t\t)\n\t\t\t\t\t.leftJoin(users, eq(questionResponse.userId, users.id))\n\t\t\t\t\t.where(rangeWhere(range, questionResponse.createdAt))\n\t\t\t: await db\n\t\t\t\t\t.select({\n\t\t\t\t\t\tresponseId: questionResponse.id,\n\t\t\t\t\t\tsurveyId: questionResponse.surveyId,\n\t\t\t\t\t\tsurveyTitle: sql<string>`JSON_UNQUOTE(JSON_EXTRACT(survey_cr.fields, '$.title'))`,\n\t\t\t\t\t\tsurveySlug: sql<string>`JSON_UNQUOTE(JSON_EXTRACT(survey_cr.fields, '$.slug'))`,\n\t\t\t\t\t\tquestionId: questionResponse.questionId,\n\t\t\t\t\t\tquestionText: sql<string>`JSON_UNQUOTE(JSON_EXTRACT(question_cr.fields, '$.question'))`,\n\t\t\t\t\t\tquestionType: sql<string>`JSON_UNQUOTE(JSON_EXTRACT(question_cr.fields, '$.type'))`,\n\t\t\t\t\t\tanswer: sql<string>`JSON_UNQUOTE(JSON_EXTRACT(${questionResponse.fields}, '$.answer'))`,\n\t\t\t\t\t\trespondentKey: questionResponse.respondentKey,\n\t\t\t\t\t\tsurveySessionId: questionResponse.surveySessionId,\n\t\t\t\t\t\tuserId: questionResponse.userId,\n\t\t\t\t\t\tuserEmail: sql<string | null>`NULL`,\n\t\t\t\t\t\temailListSubscriberId: questionResponse.emailListSubscriberId,\n\t\t\t\t\t\tcreatedAt: questionResponse.createdAt,\n\t\t\t\t\t\tupdatedAt: questionResponse.updatedAt,\n\t\t\t\t\t})\n\t\t\t\t\t.from(questionResponse)\n\t\t\t\t\t.leftJoin(\n\t\t\t\t\t\tsql`${contentResource} AS survey_cr`,\n\t\t\t\t\t\tsql`survey_cr.id = ${questionResponse.surveyId}`,\n\t\t\t\t\t)\n\t\t\t\t\t.leftJoin(\n\t\t\t\t\t\tsql`${contentResource} AS question_cr`,\n\t\t\t\t\t\tsql`question_cr.id = ${questionResponse.questionId}`,\n\t\t\t\t\t)\n\t\t\t\t\t.where(rangeWhere(range, questionResponse.createdAt))\n\n\t\tconst latestByAnswer = new Map<string, CanonicalSurveyRow>()\n\n\t\tfor (const row of rawRows) {\n\t\t\tconst respondentKey = normalizeRespondentKey(row)\n\t\t\tif (!respondentKey) continue\n\n\t\t\tconst dedupeKey = `${row.surveyId}::${row.questionId}::${respondentKey}`\n\t\t\tconst current = latestByAnswer.get(dedupeKey)\n\n\t\t\tif (!current || getRowTimestamp(row) >= getRowTimestamp(current)) {\n\t\t\t\tlatestByAnswer.set(dedupeKey, {\n\t\t\t\t\t...row,\n\t\t\t\t\trespondentKey,\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\n\t\treturn sortByNewest(Array.from(latestByAnswer.values()))\n\t}\n\n\t// ─── Survey Summary ──────────────────────────────────────────────────────\n\n\tasync function getSurveySummary(range: AnalyticsRange = '30d') {\n\t\tconst [surveyCount] = await db\n\t\t\t.select({ total: count() })\n\t\t\t.from(contentResource)\n\t\t\t.where(eq(contentResource.type, 'survey'))\n\n\t\tconst canonicalRows = await fetchCanonicalRows(range)\n\t\tconst respondentKeys = new Set(\n\t\t\tcanonicalRows.map((row) => row.respondentKey),\n\t\t)\n\t\tconst totalSurveys = surveyCount?.total ?? 0\n\t\tconst totalResponses = canonicalRows.length\n\n\t\treturn {\n\t\t\ttotalSurveys,\n\t\t\ttotalResponses,\n\t\t\tuniqueRespondents: respondentKeys.size,\n\t\t\tavgResponsesPerSurvey:\n\t\t\t\ttotalSurveys > 0 ? totalResponses / totalSurveys : 0,\n\t\t}\n\t}\n\n\t// ─── Survey List ─────────────────────────────────────────────────────────\n\n\tasync function getSurveyList(range: AnalyticsRange = '30d') {\n\t\tconst canonicalRows = await fetchCanonicalRows(range)\n\t\tconst responsesBySurvey = new Map<\n\t\t\tstring,\n\t\t\t{ responses: number; respondents: Set<string> }\n\t\t>()\n\n\t\tfor (const row of canonicalRows) {\n\t\t\tconst current = responsesBySurvey.get(row.surveyId) ?? {\n\t\t\t\tresponses: 0,\n\t\t\t\trespondents: new Set<string>(),\n\t\t\t}\n\t\t\tcurrent.responses += 1\n\t\t\tcurrent.respondents.add(row.respondentKey)\n\t\t\tresponsesBySurvey.set(row.surveyId, current)\n\t\t}\n\n\t\tconst surveys = await db\n\t\t\t.select({\n\t\t\t\tsurveyId: contentResource.id,\n\t\t\t\tsurveyTitle: sql<string>`JSON_UNQUOTE(JSON_EXTRACT(${contentResource.fields}, '$.title'))`,\n\t\t\t\tsurveySlug: sql<string>`JSON_UNQUOTE(JSON_EXTRACT(${contentResource.fields}, '$.slug'))`,\n\t\t\t})\n\t\t\t.from(contentResource)\n\t\t\t.where(eq(contentResource.type, 'survey'))\n\n\t\tconst questionCounts = await db\n\t\t\t.select({\n\t\t\t\tsurveyId: contentResourceResource.resourceOfId,\n\t\t\t\tquestionCount: count(),\n\t\t\t})\n\t\t\t.from(contentResourceResource)\n\t\t\t.innerJoin(\n\t\t\t\tcontentResource,\n\t\t\t\tand(\n\t\t\t\t\teq(contentResourceResource.resourceId, contentResource.id),\n\t\t\t\t\teq(contentResource.type, 'question'),\n\t\t\t\t),\n\t\t\t)\n\t\t\t.groupBy(contentResourceResource.resourceOfId)\n\n\t\tconst questionCountMap = new Map(\n\t\t\tquestionCounts.map((qc: { surveyId: string; questionCount: number }) => [\n\t\t\t\tqc.surveyId,\n\t\t\t\tqc.questionCount,\n\t\t\t]),\n\t\t)\n\n\t\treturn surveys\n\t\t\t.map(\n\t\t\t\t(s: {\n\t\t\t\t\tsurveyId: string\n\t\t\t\t\tsurveyTitle: string | null\n\t\t\t\t\tsurveySlug: string | null\n\t\t\t\t}) => {\n\t\t\t\t\tconst counts = responsesBySurvey.get(s.surveyId)\n\t\t\t\t\treturn {\n\t\t\t\t\t\tsurveyId: s.surveyId,\n\t\t\t\t\t\tsurveyTitle: s.surveyTitle ?? '',\n\t\t\t\t\t\tsurveySlug: s.surveySlug ?? '',\n\t\t\t\t\t\tresponses: counts?.responses ?? 0,\n\t\t\t\t\t\tuniqueRespondents: counts?.respondents.size ?? 0,\n\t\t\t\t\t\tquestionCount: questionCountMap.get(s.surveyId) ?? 0,\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t)\n\t\t\t.sort(\n\t\t\t\t(a: { responses: number }, b: { responses: number }) =>\n\t\t\t\t\tb.responses - a.responses,\n\t\t\t)\n\t}\n\n\t// ─── Daily Responses ─────────────────────────────────────────────────────\n\n\tasync function getSurveyResponsesByDay(range: AnalyticsRange = '30d') {\n\t\tconst canonicalRows = await fetchCanonicalRows(range)\n\t\tconst grouped = new Map<string, number>()\n\n\t\tfor (const row of canonicalRows) {\n\t\t\tif (!(row.createdAt instanceof Date)) continue\n\t\t\tconst date = row.createdAt.toISOString().slice(0, 10)\n\t\t\tgrouped.set(date, (grouped.get(date) ?? 0) + 1)\n\t\t}\n\n\t\treturn Array.from(grouped.entries())\n\t\t\t.sort((entryA: [string, number], entryB: [string, number]) =>\n\t\t\t\tentryA[0].localeCompare(entryB[0]),\n\t\t\t)\n\t\t\t.map(([date, responses]) => ({ date, responses }))\n\t}\n\n\t// ─── Question Breakdown ──────────────────────────────────────────────────\n\n\tasync function getSurveyQuestionBreakdown(\n\t\trange: AnalyticsRange = '30d',\n\t\tlimit = 20,\n\t) {\n\t\tconst canonicalRows = await fetchCanonicalRows(range)\n\t\tconst grouped = new Map<\n\t\t\tstring,\n\t\t\t{\n\t\t\t\tquestionId: string\n\t\t\t\tquestion: string\n\t\t\t\ttype: string | null\n\t\t\t\tresponses: number\n\t\t\t\trespondents: Set<string>\n\t\t\t\tanswers: Map<string, number>\n\t\t\t}\n\t\t>()\n\n\t\tfor (const row of canonicalRows) {\n\t\t\tconst current = grouped.get(row.questionId) ?? {\n\t\t\t\tquestionId: row.questionId,\n\t\t\t\tquestion: row.questionText ?? '',\n\t\t\t\ttype: row.questionType ?? null,\n\t\t\t\tresponses: 0,\n\t\t\t\trespondents: new Set<string>(),\n\t\t\t\tanswers: new Map<string, number>(),\n\t\t\t}\n\n\t\t\tcurrent.responses += 1\n\t\t\tcurrent.respondents.add(row.respondentKey)\n\t\t\tconst answer = row.answer ?? '(no answer)'\n\t\t\tcurrent.answers.set(answer, (current.answers.get(answer) ?? 0) + 1)\n\t\t\tgrouped.set(row.questionId, current)\n\t\t}\n\n\t\treturn Array.from(grouped.values())\n\t\t\t.sort(\n\t\t\t\t(a: { responses: number }, b: { responses: number }) =>\n\t\t\t\t\tb.responses - a.responses,\n\t\t\t)\n\t\t\t.slice(0, limit)\n\t\t\t.map((entry) => ({\n\t\t\t\tquestionId: entry.questionId,\n\t\t\t\tquestion: entry.question,\n\t\t\t\ttype: entry.type,\n\t\t\t\tresponses: entry.responses,\n\t\t\t\tuniqueRespondents: entry.respondents.size,\n\t\t\t\tanswerDistribution: Array.from(entry.answers.entries())\n\t\t\t\t\t.sort((a: [string, number], b: [string, number]) => b[1] - a[1])\n\t\t\t\t\t.map(([answer, count]) => ({ answer, count })),\n\t\t\t}))\n\t}\n\n\t// ─── Individual Response Rows ────────────────────────────────────────────\n\n\tasync function getSurveyResponses(\n\t\trange: AnalyticsRange = '30d',\n\t\tlimit = 100,\n\t\toffset = 0,\n\t) {\n\t\tconst canonicalRows = await fetchCanonicalRows(range)\n\n\t\treturn canonicalRows.slice(offset, offset + limit).map((row) => ({\n\t\t\tresponseId: row.responseId,\n\t\t\tsurveyId: row.surveyId,\n\t\t\tsurveyTitle: row.surveyTitle ?? '',\n\t\t\tsurveySlug: row.surveySlug ?? '',\n\t\t\tquestionId: row.questionId,\n\t\t\tquestionText: row.questionText ?? '',\n\t\t\tquestionType: row.questionType ?? null,\n\t\t\tanswer: row.answer ?? '',\n\t\t\tuserId: row.userId ?? null,\n\t\t\tuserEmail: row.userEmail ?? null,\n\t\t\temailListSubscriberId: row.emailListSubscriberId ?? null,\n\t\t\tcreatedAt: row.createdAt ? String(row.createdAt) : '',\n\t\t}))\n\t}\n\n\treturn {\n\t\tgetSurveySummary,\n\t\tgetSurveyList,\n\t\tgetSurveyResponsesByDay,\n\t\tgetSurveyQuestionBreakdown,\n\t\tgetSurveyResponses,\n\t}\n}\n"],"mappings":"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","names":["and","count","eq","sql","normalizeRespondentKey","row","respondentKey","userId","emailListSubscriberId","surveySessionId","getRowTimestamp","date","updatedAt","createdAt","Date","getTime","sortByNewest","rows","sort","a","b","createSurveyProvider","db","schema","contentResource","contentResourceResource","questionResponse","rangeToInterval","range","rangeWhere","column","sql","raw","fetchCanonicalRows","users","rawRows","select","responseId","id","surveyId","surveyTitle","surveySlug","questionId","questionText","questionType","answer","fields","userEmail","email","from","leftJoin","eq","where","latestByAnswer","Map","dedupeKey","current","get","set","Array","values","getSurveySummary","surveyCount","total","count","type","canonicalRows","respondentKeys","Set","map","totalSurveys","totalResponses","length","uniqueRespondents","size","avgResponsesPerSurvey","getSurveyList","responsesBySurvey","responses","respondents","add","surveys","questionCounts","resourceOfId","questionCount","innerJoin","and","resourceId","groupBy","questionCountMap","qc","s","counts","getSurveyResponsesByDay","grouped","toISOString","slice","entries","entryA","entryB","localeCompare","getSurveyQuestionBreakdown","limit","question","answers","entry","answerDistribution","getSurveyResponses","offset","String"]}
|
package/dist/types.d.ts
CHANGED
|
@@ -50,9 +50,11 @@ interface ShortlinkPerformance {
|
|
|
50
50
|
url: string;
|
|
51
51
|
clicks: Count;
|
|
52
52
|
}
|
|
53
|
+
type CommerceRecordKind = 'paid_conversion' | 'access_grant' | 'synthetic' | 'unknown';
|
|
53
54
|
interface RevenueBySource {
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
kind?: CommerceRecordKind;
|
|
56
|
+
source: string | null;
|
|
57
|
+
medium: string | null;
|
|
56
58
|
campaign: string | null;
|
|
57
59
|
revenue: USD;
|
|
58
60
|
count: Count;
|
|
@@ -69,12 +71,42 @@ interface ContentCorrelation {
|
|
|
69
71
|
resourceId: string;
|
|
70
72
|
purchaserCount: Count;
|
|
71
73
|
}
|
|
74
|
+
interface CommerceLaneSummary {
|
|
75
|
+
commerceRecords: Count;
|
|
76
|
+
paidPurchases: Count;
|
|
77
|
+
paidRevenue: USD;
|
|
78
|
+
accessGrants: Count;
|
|
79
|
+
accessGrantRevenue: USD;
|
|
80
|
+
freeUpgrades: Count;
|
|
81
|
+
syntheticPurchases: Count;
|
|
82
|
+
byAccessGrantReason: Record<string, Count>;
|
|
83
|
+
}
|
|
84
|
+
interface AttributionSignalSummary {
|
|
85
|
+
shortlink: Count;
|
|
86
|
+
utm: Count;
|
|
87
|
+
paidClickId: Count;
|
|
88
|
+
gaClientId: Count;
|
|
89
|
+
selfReportedSource: Count;
|
|
90
|
+
recoveredFromShortlinkAttributionTable?: Count;
|
|
91
|
+
internalFreeUpgrade: Count;
|
|
92
|
+
}
|
|
72
93
|
interface AttributionCoverage {
|
|
73
94
|
totalRevenue: USD;
|
|
74
95
|
attributedRevenue: USD;
|
|
75
96
|
unattributedRevenue: USD;
|
|
76
97
|
attributionRate: Percentage;
|
|
77
98
|
totalPurchases: Count;
|
|
99
|
+
paidPurchases?: Count;
|
|
100
|
+
purchaseFieldAttributedPurchases?: Count;
|
|
101
|
+
purchaseFieldAttributedRevenue?: USD;
|
|
102
|
+
recoveredFromShortlinkAttributionTablePurchases?: Count;
|
|
103
|
+
recoveredFromShortlinkAttributionTableRevenue?: USD;
|
|
104
|
+
commerceRecords?: Count;
|
|
105
|
+
accessGrants?: Count;
|
|
106
|
+
freeUpgrades?: Count;
|
|
107
|
+
syntheticPurchases?: Count;
|
|
108
|
+
commerce?: CommerceLaneSummary;
|
|
109
|
+
signals?: AttributionSignalSummary;
|
|
78
110
|
}
|
|
79
111
|
interface TrafficOverview {
|
|
80
112
|
sessions: Count;
|
|
@@ -246,6 +278,110 @@ interface SurveyRevenueCorrelation {
|
|
|
246
278
|
baselineConversionRate: Percentage;
|
|
247
279
|
byQuestion: SurveyConversionByQuestion[];
|
|
248
280
|
}
|
|
281
|
+
/** One survey answer bucket correlated to paid product revenue. */
|
|
282
|
+
interface ProductSurveyRevenueAnswer {
|
|
283
|
+
surveyId: string;
|
|
284
|
+
surveyTitle: string | null;
|
|
285
|
+
surveySlug: string | null;
|
|
286
|
+
questionId: string;
|
|
287
|
+
question: string | null;
|
|
288
|
+
answer: string;
|
|
289
|
+
responses: Count;
|
|
290
|
+
uniqueRespondents: Count;
|
|
291
|
+
paidPurchasers: Count;
|
|
292
|
+
paidRevenue: USD;
|
|
293
|
+
conversionRate: Percentage;
|
|
294
|
+
}
|
|
295
|
+
/** Product-filtered survey revenue report with aggregate counts and answer buckets. */
|
|
296
|
+
interface ProductSurveyRevenueCorrelation {
|
|
297
|
+
productId: string | null;
|
|
298
|
+
surveyId: string | null;
|
|
299
|
+
surveySlug: string | null;
|
|
300
|
+
totalResponses: Count;
|
|
301
|
+
uniqueRespondents: Count;
|
|
302
|
+
respondentsWithUserId: Count;
|
|
303
|
+
paidPurchasers: Count;
|
|
304
|
+
paidRevenue: USD;
|
|
305
|
+
byAnswer: ProductSurveyRevenueAnswer[];
|
|
306
|
+
}
|
|
307
|
+
/** Single-purchase attribution receipt used by operator audits and checkout debugging. */
|
|
308
|
+
interface CheckoutAttributionReceipt {
|
|
309
|
+
purchase: {
|
|
310
|
+
id: string;
|
|
311
|
+
createdAt: Date | string | null;
|
|
312
|
+
productId: string | null;
|
|
313
|
+
productName: string | null;
|
|
314
|
+
totalAmount: USD;
|
|
315
|
+
status: string | null;
|
|
316
|
+
country: string | null;
|
|
317
|
+
} | null;
|
|
318
|
+
checks: {
|
|
319
|
+
purchaseFound: boolean;
|
|
320
|
+
attributionSnapshot: boolean;
|
|
321
|
+
utm: boolean;
|
|
322
|
+
actualUtm: boolean;
|
|
323
|
+
clickId: boolean;
|
|
324
|
+
synthetic: boolean;
|
|
325
|
+
shortlink: boolean;
|
|
326
|
+
selfReportedSource: boolean;
|
|
327
|
+
gaClientId: boolean;
|
|
328
|
+
};
|
|
329
|
+
attribution: Record<string, unknown> | null;
|
|
330
|
+
legacy: Record<string, unknown>;
|
|
331
|
+
fieldKeys: string[];
|
|
332
|
+
}
|
|
333
|
+
/** Checkout survey answer bucket for otherwise dark purchase recovery analysis. */
|
|
334
|
+
interface CheckoutSurveyFallbackAnswer {
|
|
335
|
+
answer: string;
|
|
336
|
+
confidence: 'exact_purchase_link' | 'user_linked';
|
|
337
|
+
purchases: Count;
|
|
338
|
+
revenue: USD;
|
|
339
|
+
}
|
|
340
|
+
/** Report-only fallback from checkout survey responses to dark purchase revenue. */
|
|
341
|
+
interface CheckoutSurveyFallbackReport {
|
|
342
|
+
label: 'recovered_from_checkout_survey_response';
|
|
343
|
+
productId: string | null;
|
|
344
|
+
totalDarkPurchases: Count;
|
|
345
|
+
totalDarkRevenue: USD;
|
|
346
|
+
exactPurchaseLinkedPurchases: Count;
|
|
347
|
+
exactPurchaseLinkedRevenue: USD;
|
|
348
|
+
userLinkedPurchases: Count;
|
|
349
|
+
userLinkedRevenue: USD;
|
|
350
|
+
byAnswer: CheckoutSurveyFallbackAnswer[];
|
|
351
|
+
notes: string[];
|
|
352
|
+
}
|
|
353
|
+
interface ValuePathAnswerBucket {
|
|
354
|
+
key: string;
|
|
355
|
+
step: string;
|
|
356
|
+
optionValue: string;
|
|
357
|
+
count: Count;
|
|
358
|
+
}
|
|
359
|
+
interface ValuePathStepBucket {
|
|
360
|
+
step: string;
|
|
361
|
+
count: Count;
|
|
362
|
+
}
|
|
363
|
+
interface ValuePathTerminalBucket {
|
|
364
|
+
emailResourceId: string;
|
|
365
|
+
count: Count;
|
|
366
|
+
}
|
|
367
|
+
interface ValuePathSummary {
|
|
368
|
+
contacts: Count;
|
|
369
|
+
events: Count;
|
|
370
|
+
intents: Count;
|
|
371
|
+
completedIntents: Count;
|
|
372
|
+
pendingIntents: Count;
|
|
373
|
+
blockedIntents: Count;
|
|
374
|
+
answerEvents: Count;
|
|
375
|
+
dripEvents: Count;
|
|
376
|
+
enteredEvents: Count;
|
|
377
|
+
participantsWithAnswerClicks: Count;
|
|
378
|
+
participantsWithNoAnswerClicks: Count;
|
|
379
|
+
terminalParticipants: Count;
|
|
380
|
+
answerOptions: ValuePathAnswerBucket[];
|
|
381
|
+
answerSteps: ValuePathStepBucket[];
|
|
382
|
+
terminalSteps: ValuePathTerminalBucket[];
|
|
383
|
+
notes: string[];
|
|
384
|
+
}
|
|
249
385
|
interface SurfaceMap {
|
|
250
386
|
summary: RevenueSummary;
|
|
251
387
|
'revenue/daily': RevenueDaily[];
|
|
@@ -258,6 +394,7 @@ interface SurfaceMap {
|
|
|
258
394
|
'attribution/funnel': ConversionFunnel;
|
|
259
395
|
'attribution/content': ContentCorrelation[];
|
|
260
396
|
'attribution/coverage': AttributionCoverage;
|
|
397
|
+
'attribution/commerce-lanes': CommerceLaneSummary;
|
|
261
398
|
traffic: TrafficOverview;
|
|
262
399
|
'traffic/daily': TrafficDaily[];
|
|
263
400
|
'traffic/pages': TopPage[];
|
|
@@ -274,12 +411,23 @@ interface SurfaceMap {
|
|
|
274
411
|
'surveys/questions': SurveyQuestionBreakdown[];
|
|
275
412
|
'surveys/responses': SurveyResponseRow[];
|
|
276
413
|
'correlation/survey-revenue': SurveyRevenueCorrelation;
|
|
414
|
+
'correlation/survey-revenue/product': ProductSurveyRevenueCorrelation;
|
|
277
415
|
'attribution/email-campaigns': EmailRevenueOverview;
|
|
416
|
+
'attribution/email-campaigns/strict': EmailRevenueOverview;
|
|
417
|
+
'attribution/checkout-receipt': CheckoutAttributionReceipt;
|
|
418
|
+
'attribution/checkout-survey-fallback': CheckoutSurveyFallbackReport;
|
|
419
|
+
'value-paths/summary': ValuePathSummary;
|
|
278
420
|
}
|
|
279
421
|
type SurfaceName = keyof SurfaceMap;
|
|
280
422
|
interface QueryOptions {
|
|
281
423
|
range?: AnalyticsRange;
|
|
282
424
|
limit?: number;
|
|
425
|
+
offset?: number;
|
|
426
|
+
productId?: string;
|
|
427
|
+
purchaseId?: string;
|
|
428
|
+
surveyId?: string;
|
|
429
|
+
surveySlug?: string;
|
|
430
|
+
questionId?: string;
|
|
283
431
|
}
|
|
284
432
|
type QueryResult<S extends SurfaceName> = {
|
|
285
433
|
ok: true;
|
|
@@ -300,4 +448,4 @@ type QueryResult<S extends SurfaceName> = {
|
|
|
300
448
|
fix: string;
|
|
301
449
|
};
|
|
302
450
|
|
|
303
|
-
export type { AnalyticsRange, AttributionCount, AttributionCoverage, ContentCorrelation, ConversionFunnel, Count, EmailCampaignFunnel, EmailCampaignKitLink, EmailCampaignShortlink, EmailRevenueOverview, Minutes, Percentage, QueryOptions, QueryResult, RecentPurchase, RevenueByCountry, RevenueByProduct, RevenueBySource, RevenueDaily, RevenueSummary, Seconds, ShortlinkPerformance, SurfaceMap, SurfaceName, SurveyConversionByQuestion, SurveyListItem, SurveyQuestionBreakdown, SurveyResponseRow, SurveyResponsesDaily, SurveyRevenueCorrelation, SurveySummary, TopPage, TrafficDaily, TrafficOverview, TrafficRevenueCorrelation, TrafficSource, USD, YouTubeChannelOverview, YouTubeDaily, YouTubeRevenueCorrelation, YouTubeTrafficSource, YouTubeVideoPerformance };
|
|
451
|
+
export type { AnalyticsRange, AttributionCount, AttributionCoverage, AttributionSignalSummary, CheckoutAttributionReceipt, CheckoutSurveyFallbackAnswer, CheckoutSurveyFallbackReport, CommerceLaneSummary, CommerceRecordKind, ContentCorrelation, ConversionFunnel, Count, EmailCampaignFunnel, EmailCampaignKitLink, EmailCampaignShortlink, EmailRevenueOverview, Minutes, Percentage, ProductSurveyRevenueAnswer, ProductSurveyRevenueCorrelation, QueryOptions, QueryResult, RecentPurchase, RevenueByCountry, RevenueByProduct, RevenueBySource, RevenueDaily, RevenueSummary, Seconds, ShortlinkPerformance, SurfaceMap, SurfaceName, SurveyConversionByQuestion, SurveyListItem, SurveyQuestionBreakdown, SurveyResponseRow, SurveyResponsesDaily, SurveyRevenueCorrelation, SurveySummary, TopPage, TrafficDaily, TrafficOverview, TrafficRevenueCorrelation, TrafficSource, USD, ValuePathAnswerBucket, ValuePathStepBucket, ValuePathSummary, ValuePathTerminalBucket, YouTubeChannelOverview, YouTubeDaily, YouTubeRevenueCorrelation, YouTubeTrafficSource, YouTubeVideoPerformance };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coursebuilder/analytics",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"files": [
|
|
5
5
|
"dist",
|
|
6
6
|
"src"
|
|
@@ -76,13 +76,14 @@
|
|
|
76
76
|
"@types/react": "19.2.7",
|
|
77
77
|
"drizzle-orm": "^0.30.0",
|
|
78
78
|
"lucide-react": "^0.344.0",
|
|
79
|
-
"next": "16.
|
|
79
|
+
"next": "16.2.5",
|
|
80
80
|
"nuqs": "^2.0.0",
|
|
81
81
|
"react": "19.2.3",
|
|
82
82
|
"recharts": "^2.12.0",
|
|
83
83
|
"tsup": "8.0.2",
|
|
84
84
|
"typescript": "5.4.5",
|
|
85
|
-
"
|
|
85
|
+
"vitest": "1.6.0",
|
|
86
|
+
"@coursebuilder/ui": "2.0.12"
|
|
86
87
|
},
|
|
87
88
|
"peerDependencies": {
|
|
88
89
|
"@coursebuilder/ui": "*",
|
|
@@ -96,6 +97,7 @@
|
|
|
96
97
|
"scripts": {
|
|
97
98
|
"build": "tsup",
|
|
98
99
|
"dev": "tsup --watch",
|
|
100
|
+
"test": "vitest run",
|
|
99
101
|
"typecheck": "tsc --noEmit"
|
|
100
102
|
}
|
|
101
103
|
}
|
|
@@ -187,7 +187,7 @@ export function createAnalyticsCatalogHandler(deps: CatalogHandlerDeps) {
|
|
|
187
187
|
{
|
|
188
188
|
ok: true,
|
|
189
189
|
endpoint: endpointPath,
|
|
190
|
-
description: `${appLabel} analytics
|
|
190
|
+
description: `${appLabel} analytics, revenue, attribution, traffic, YouTube, and content correlation`,
|
|
191
191
|
notes: [
|
|
192
192
|
'YouTube surfaces are useful for correlation/content analysis but lag by about 48 hours.',
|
|
193
193
|
],
|
|
@@ -215,6 +215,30 @@ export function createAnalyticsCatalogHandler(deps: CatalogHandlerDeps) {
|
|
|
215
215
|
description:
|
|
216
216
|
'Max rows for surfaces that support it (max 100)',
|
|
217
217
|
},
|
|
218
|
+
productId: {
|
|
219
|
+
required: false,
|
|
220
|
+
description:
|
|
221
|
+
'Optional product filter for product-aware attribution surfaces',
|
|
222
|
+
},
|
|
223
|
+
purchaseId: {
|
|
224
|
+
required: false,
|
|
225
|
+
description: 'Required for attribution/checkout-receipt',
|
|
226
|
+
},
|
|
227
|
+
surveyId: {
|
|
228
|
+
required: false,
|
|
229
|
+
description:
|
|
230
|
+
'Optional survey ID filter for product survey correlation',
|
|
231
|
+
},
|
|
232
|
+
surveySlug: {
|
|
233
|
+
required: false,
|
|
234
|
+
description:
|
|
235
|
+
'Optional survey slug filter for product survey correlation',
|
|
236
|
+
},
|
|
237
|
+
questionId: {
|
|
238
|
+
required: false,
|
|
239
|
+
description:
|
|
240
|
+
'Optional question ID filter for product survey correlation',
|
|
241
|
+
},
|
|
218
242
|
},
|
|
219
243
|
},
|
|
220
244
|
],
|
|
@@ -247,6 +271,11 @@ export function createAnalyticsCatalogHandler(deps: CatalogHandlerDeps) {
|
|
|
247
271
|
const surface = rawSurface
|
|
248
272
|
const range = parseRange(searchParams.get('range'))
|
|
249
273
|
const limit = Math.min(Number(searchParams.get('limit') ?? 20), 100)
|
|
274
|
+
const productId = searchParams.get('productId') ?? undefined
|
|
275
|
+
const purchaseId = searchParams.get('purchaseId') ?? undefined
|
|
276
|
+
const surveyId = searchParams.get('surveyId') ?? undefined
|
|
277
|
+
const surveySlug = searchParams.get('surveySlug') ?? undefined
|
|
278
|
+
const questionId = searchParams.get('questionId') ?? undefined
|
|
250
279
|
|
|
251
280
|
if (logger) {
|
|
252
281
|
logger.info('api.analytics.query', {
|
|
@@ -256,10 +285,23 @@ export function createAnalyticsCatalogHandler(deps: CatalogHandlerDeps) {
|
|
|
256
285
|
surface,
|
|
257
286
|
range,
|
|
258
287
|
limit,
|
|
288
|
+
productId,
|
|
289
|
+
purchaseId,
|
|
290
|
+
surveyId,
|
|
291
|
+
surveySlug,
|
|
292
|
+
questionId,
|
|
259
293
|
})
|
|
260
294
|
}
|
|
261
295
|
|
|
262
|
-
const result = await engine.query(surface, {
|
|
296
|
+
const result = await engine.query(surface, {
|
|
297
|
+
range,
|
|
298
|
+
limit,
|
|
299
|
+
productId,
|
|
300
|
+
purchaseId,
|
|
301
|
+
surveyId,
|
|
302
|
+
surveySlug,
|
|
303
|
+
questionId,
|
|
304
|
+
})
|
|
263
305
|
|
|
264
306
|
if (!result.ok) {
|
|
265
307
|
if (logger) {
|
package/src/api/token-handler.ts
CHANGED
|
@@ -33,8 +33,9 @@ export type { TokenHandlerDeps }
|
|
|
33
33
|
*/
|
|
34
34
|
export function createTokenHandler(deps: TokenHandlerDeps) {
|
|
35
35
|
const { db, deviceAccessToken, checkAccess, logger } = deps
|
|
36
|
-
const ttlHours = deps.ttlHours ?? 24
|
|
37
|
-
const ttlLabel =
|
|
36
|
+
const ttlHours = deps.ttlHours ?? 90 * 24
|
|
37
|
+
const ttlLabel =
|
|
38
|
+
ttlHours % 24 === 0 ? `${ttlHours / 24} days` : `${ttlHours} hours`
|
|
38
39
|
|
|
39
40
|
const POST = async (request: NextRequest) => {
|
|
40
41
|
const access = await checkAccess(request)
|
package/src/catalog.ts
CHANGED
|
@@ -10,6 +10,7 @@ export interface SurfaceEntry {
|
|
|
10
10
|
| 'youtube'
|
|
11
11
|
| 'correlation'
|
|
12
12
|
| 'survey'
|
|
13
|
+
| 'value-path'
|
|
13
14
|
provider: 'database' | 'ga4' | 'youtube' | 'derived' | 'newsletter' | 'survey'
|
|
14
15
|
fn: string
|
|
15
16
|
unavailableFix?: string
|
|
@@ -88,11 +89,19 @@ export const catalog: Record<SurfaceName, SurfaceEntry> = {
|
|
|
88
89
|
},
|
|
89
90
|
'attribution/coverage': {
|
|
90
91
|
name: 'attribution/coverage',
|
|
91
|
-
description: 'Attributed vs dark revenue',
|
|
92
|
+
description: 'Attributed vs dark paid revenue, with commerce lane context',
|
|
92
93
|
category: 'attribution',
|
|
93
94
|
provider: 'database',
|
|
94
95
|
fn: 'getAttributedRevenueSummary',
|
|
95
96
|
},
|
|
97
|
+
'attribution/commerce-lanes': {
|
|
98
|
+
name: 'attribution/commerce-lanes',
|
|
99
|
+
description:
|
|
100
|
+
'Commerce record lanes: paid purchases, access grants, free upgrades, synthetic tests',
|
|
101
|
+
category: 'attribution',
|
|
102
|
+
provider: 'database',
|
|
103
|
+
fn: 'getCommerceLaneSummary',
|
|
104
|
+
},
|
|
96
105
|
traffic: {
|
|
97
106
|
name: 'traffic',
|
|
98
107
|
description: 'GA4 traffic overview',
|
|
@@ -211,6 +220,29 @@ export const catalog: Record<SurfaceName, SurfaceEntry> = {
|
|
|
211
220
|
provider: 'newsletter',
|
|
212
221
|
fn: 'getEmailCampaignAttribution',
|
|
213
222
|
},
|
|
223
|
+
'attribution/email-campaigns/strict': {
|
|
224
|
+
name: 'attribution/email-campaigns/strict',
|
|
225
|
+
description:
|
|
226
|
+
'Kit email broadcasts → shortlink clicks → strict purchase-field purchases and revenue per campaign',
|
|
227
|
+
category: 'attribution',
|
|
228
|
+
provider: 'newsletter',
|
|
229
|
+
fn: 'getEmailCampaignAttributionStrict',
|
|
230
|
+
},
|
|
231
|
+
'attribution/checkout-receipt': {
|
|
232
|
+
name: 'attribution/checkout-receipt',
|
|
233
|
+
description: 'Read-only checkout attribution receipt for one purchase ID',
|
|
234
|
+
category: 'attribution',
|
|
235
|
+
provider: 'database',
|
|
236
|
+
fn: 'getCheckoutAttributionReceipt',
|
|
237
|
+
},
|
|
238
|
+
'attribution/checkout-survey-fallback': {
|
|
239
|
+
name: 'attribution/checkout-survey-fallback',
|
|
240
|
+
description:
|
|
241
|
+
'Report-only dark purchase fallback from checkout survey responses',
|
|
242
|
+
category: 'attribution',
|
|
243
|
+
provider: 'database',
|
|
244
|
+
fn: 'getCheckoutSurveyFallbackReport',
|
|
245
|
+
},
|
|
214
246
|
'correlation/survey-revenue': {
|
|
215
247
|
name: 'correlation/survey-revenue',
|
|
216
248
|
description: 'Survey respondents → purchase conversion by question/answer',
|
|
@@ -218,6 +250,22 @@ export const catalog: Record<SurfaceName, SurfaceEntry> = {
|
|
|
218
250
|
provider: 'derived',
|
|
219
251
|
fn: 'getSurveyRevenueCorrelation',
|
|
220
252
|
},
|
|
253
|
+
'correlation/survey-revenue/product': {
|
|
254
|
+
name: 'correlation/survey-revenue/product',
|
|
255
|
+
description:
|
|
256
|
+
'Product-filtered survey respondents → paid purchase conversion by question/answer',
|
|
257
|
+
category: 'correlation',
|
|
258
|
+
provider: 'derived',
|
|
259
|
+
fn: 'getProductSurveyRevenueCorrelation',
|
|
260
|
+
},
|
|
261
|
+
'value-paths/summary': {
|
|
262
|
+
name: 'value-paths/summary',
|
|
263
|
+
description:
|
|
264
|
+
'Value Path progression, answer selection, drip fallback, and terminal completion',
|
|
265
|
+
category: 'value-path',
|
|
266
|
+
provider: 'database',
|
|
267
|
+
fn: 'getValuePathSummary',
|
|
268
|
+
},
|
|
221
269
|
} as const satisfies Record<SurfaceName, SurfaceEntry>
|
|
222
270
|
|
|
223
271
|
export const ANALYTICS_CATALOG = catalog
|