@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.
@@ -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;
@@ -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(0, limit).map((row) => ({
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
- source: string;
55
- medium: string;
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.0",
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.0.10",
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
- "@coursebuilder/ui": "2.0.10"
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 revenue, attribution, traffic, YouTube, and content correlation`,
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, { range, limit })
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) {
@@ -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 = `${ttlHours} hours`
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