@coursebuilder/analytics 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/dist/api/index.d.ts +158 -0
  3. package/dist/api/index.js +317 -0
  4. package/dist/api/index.js.map +1 -0
  5. package/dist/catalog.d.ts +14 -0
  6. package/dist/catalog.js +209 -0
  7. package/dist/catalog.js.map +1 -0
  8. package/dist/components/index.d.ts +172 -0
  9. package/dist/components/index.js +1258 -0
  10. package/dist/components/index.js.map +1 -0
  11. package/dist/engine.d.ts +20 -0
  12. package/dist/engine.js +350 -0
  13. package/dist/engine.js.map +1 -0
  14. package/dist/index.d.ts +3 -0
  15. package/dist/index.js +353 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/providers/database.d.ts +79 -0
  18. package/dist/providers/database.js +533 -0
  19. package/dist/providers/database.js.map +1 -0
  20. package/dist/providers/derived.d.ts +45 -0
  21. package/dist/providers/derived.js +32 -0
  22. package/dist/providers/derived.js.map +1 -0
  23. package/dist/providers/ga4.d.ts +43 -0
  24. package/dist/providers/ga4.js +220 -0
  25. package/dist/providers/ga4.js.map +1 -0
  26. package/dist/providers/index.d.ts +8 -0
  27. package/dist/providers/index.js +1239 -0
  28. package/dist/providers/index.js.map +1 -0
  29. package/dist/providers/mux.d.ts +103 -0
  30. package/dist/providers/mux.js +241 -0
  31. package/dist/providers/mux.js.map +1 -0
  32. package/dist/providers/survey.d.ts +102 -0
  33. package/dist/providers/survey.js +233 -0
  34. package/dist/providers/survey.js.map +1 -0
  35. package/dist/types.d.ts +303 -0
  36. package/dist/types.js +1 -0
  37. package/dist/types.js.map +1 -0
  38. package/package.json +101 -0
  39. package/src/api/catalog-handler.ts +321 -0
  40. package/src/api/index.ts +4 -0
  41. package/src/api/token-handler.ts +71 -0
  42. package/src/catalog.ts +223 -0
  43. package/src/components/country-chart.tsx +114 -0
  44. package/src/components/index.ts +5 -0
  45. package/src/components/omnibus-dashboard.tsx +1460 -0
  46. package/src/components/revenue-chart.tsx +251 -0
  47. package/src/components/use-chart-colors.ts +75 -0
  48. package/src/engine.ts +201 -0
  49. package/src/index.ts +7 -0
  50. package/src/providers/database.ts +795 -0
  51. package/src/providers/derived.ts +79 -0
  52. package/src/providers/ga4.ts +173 -0
  53. package/src/providers/index.ts +44 -0
  54. package/src/providers/mux.ts +438 -0
  55. package/src/providers/survey.ts +487 -0
  56. package/src/types.ts +333 -0
@@ -0,0 +1,321 @@
1
+ import { NextRequest, NextResponse } from 'next/server'
2
+
3
+ import type { SurfaceEntry } from '../catalog'
4
+ import type { QueryOptions, QueryResult, SurfaceName } from '../types'
5
+
6
+ type AnalyticsRange = '24h' | '7d' | '30d' | '90d' | 'all'
7
+
8
+ interface CatalogHandlerDeps {
9
+ engine: {
10
+ query: (
11
+ surface: string,
12
+ options?: QueryOptions,
13
+ ) => Promise<QueryResult<SurfaceName>>
14
+ getCatalog: () => SurfaceEntry[]
15
+ }
16
+ checkAccess: (request: Request) => Promise<{
17
+ authorized: boolean
18
+ user?: Record<string, unknown> | null
19
+ authMethod?: string
20
+ }>
21
+ logger?: {
22
+ info: (message: string, data?: Record<string, unknown>) => unknown
23
+ warn: (message: string, data?: Record<string, unknown>) => unknown
24
+ error: (message: string, data?: Record<string, unknown>) => unknown
25
+ }
26
+ appName?: string
27
+ baseUrl?: string
28
+ }
29
+
30
+ export type { CatalogHandlerDeps }
31
+
32
+ const VALID_RANGES = new Set<AnalyticsRange>(['24h', '7d', '30d', '90d', 'all'])
33
+ const RANGE_OPTIONS: AnalyticsRange[] = ['24h', '7d', '30d', '90d', 'all']
34
+
35
+ const CATEGORY_SUGGESTIONS: Record<string, string[]> = {
36
+ revenue: [
37
+ 'revenue/daily',
38
+ 'revenue/products',
39
+ 'attribution/sources',
40
+ 'correlation/traffic-revenue',
41
+ ],
42
+ attribution: [
43
+ 'attribution/funnel',
44
+ 'attribution/sources',
45
+ 'attribution/coverage',
46
+ 'correlation/traffic-revenue',
47
+ ],
48
+ traffic: [
49
+ 'traffic/daily',
50
+ 'traffic/sources',
51
+ 'correlation/traffic-revenue',
52
+ 'correlation/youtube-revenue',
53
+ ],
54
+ youtube: [
55
+ 'youtube/videos',
56
+ 'youtube/daily',
57
+ 'youtube/sources',
58
+ 'correlation/youtube-revenue',
59
+ ],
60
+ correlation: [
61
+ 'summary',
62
+ 'attribution/funnel',
63
+ 'youtube',
64
+ 'correlation/survey-revenue',
65
+ ],
66
+ survey: [
67
+ 'surveys',
68
+ 'surveys/list',
69
+ 'surveys/daily',
70
+ 'surveys/questions',
71
+ 'surveys/responses',
72
+ ],
73
+ }
74
+
75
+ const corsHeaders = {
76
+ 'Access-Control-Allow-Origin': '*',
77
+ 'Access-Control-Allow-Methods': 'GET, OPTIONS',
78
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
79
+ }
80
+
81
+ function parseRange(raw?: string | null): AnalyticsRange {
82
+ if (raw && VALID_RANGES.has(raw as AnalyticsRange)) {
83
+ return raw as AnalyticsRange
84
+ }
85
+
86
+ return '30d'
87
+ }
88
+
89
+ function getMeta(data: unknown, queryTimeMs: number, truncated: boolean) {
90
+ return {
91
+ totalRows: Array.isArray(data) ? data.length : 1,
92
+ truncated,
93
+ queryTimeMs,
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Creates a Next.js App Router GET handler that serves a HATEOAS-style
99
+ * analytics catalog and surface query API.
100
+ *
101
+ * @param deps - Handler dependencies including engine, access check, and
102
+ * optional logger and app metadata
103
+ * @returns An object with `GET` and `OPTIONS` Next.js route handlers
104
+ */
105
+ export function createAnalyticsCatalogHandler(deps: CatalogHandlerDeps) {
106
+ const { engine, checkAccess, logger, appName, baseUrl } = deps
107
+
108
+ const catalog = engine.getCatalog()
109
+ const catalogByName = Object.fromEntries(
110
+ catalog.map((entry) => [entry.name, entry]),
111
+ ) as Record<string, SurfaceEntry>
112
+
113
+ function buildContextualNextActions(
114
+ surface: string,
115
+ range: AnalyticsRange,
116
+ endpointPath: string,
117
+ ) {
118
+ const entry = catalogByName[surface]
119
+ if (!entry) return []
120
+ const suggestions = CATEGORY_SUGGESTIONS[entry.category] ?? []
121
+
122
+ return suggestions
123
+ .filter((name) => name !== surface)
124
+ .slice(0, 4)
125
+ .map((name) => ({
126
+ command: `GET ${endpointPath}?surface=${name}&range=<range>`,
127
+ description: catalogByName[name]?.description ?? name,
128
+ params: {
129
+ range: {
130
+ value: range,
131
+ enum: RANGE_OPTIONS,
132
+ },
133
+ },
134
+ }))
135
+ }
136
+
137
+ const OPTIONS = () => NextResponse.json({}, { headers: corsHeaders })
138
+
139
+ const GET = async (request: NextRequest) => {
140
+ const requestUrl = new URL(request.url)
141
+ const resolvedBase = baseUrl ?? `${requestUrl.protocol}//${requestUrl.host}`
142
+ const endpointPath = `${requestUrl.pathname}`
143
+ const appLabel = appName ?? 'Analytics'
144
+
145
+ const access = await checkAccess(request)
146
+
147
+ if (!access.authorized) {
148
+ if (logger) {
149
+ void logger.warn('api.analytics.access-denied', {
150
+ userId: access.user?.id ?? null,
151
+ email: access.user?.email ?? null,
152
+ authMethod: access.authMethod ?? 'unknown',
153
+ hasAuthorization: false,
154
+ })
155
+ }
156
+
157
+ return NextResponse.json(
158
+ {
159
+ ok: false,
160
+ endpoint: endpointPath,
161
+ error: {
162
+ message: 'Unauthorized',
163
+ code: 'AUTH_REQUIRED',
164
+ },
165
+ fix: 'Authenticate with an admin device token or an admin session cookie.',
166
+ next_actions: [
167
+ {
168
+ command: 'GET /api/coursebuilder/devices',
169
+ description:
170
+ 'Start device verification flow to obtain a Bearer token',
171
+ },
172
+ {
173
+ command: 'GET /login',
174
+ description: 'Log in as an admin to use session-based auth',
175
+ },
176
+ ],
177
+ },
178
+ { status: 401, headers: corsHeaders },
179
+ )
180
+ }
181
+
182
+ const { searchParams } = requestUrl
183
+ const rawSurface = searchParams.get('surface')
184
+
185
+ if (!rawSurface) {
186
+ return NextResponse.json(
187
+ {
188
+ ok: true,
189
+ endpoint: endpointPath,
190
+ description: `${appLabel} analytics — revenue, attribution, traffic, YouTube, and content correlation`,
191
+ notes: [
192
+ 'YouTube surfaces are useful for correlation/content analysis but lag by about 48 hours.',
193
+ ],
194
+ surfaces: catalog,
195
+ _links: {
196
+ self: { href: `${resolvedBase}${endpointPath}` },
197
+ },
198
+ next_actions: [
199
+ {
200
+ command: `GET ${endpointPath}?surface=<surface>&range=<range>&limit=<limit>`,
201
+ description: 'Query a specific analytics surface',
202
+ params: {
203
+ surface: {
204
+ required: true,
205
+ enum: catalog.map((entry) => entry.name),
206
+ description: 'Analytics surface to query',
207
+ },
208
+ range: {
209
+ default: '30d',
210
+ enum: RANGE_OPTIONS,
211
+ description: 'Time range',
212
+ },
213
+ limit: {
214
+ default: '20',
215
+ description:
216
+ 'Max rows for surfaces that support it (max 100)',
217
+ },
218
+ },
219
+ },
220
+ ],
221
+ },
222
+ { headers: corsHeaders },
223
+ )
224
+ }
225
+
226
+ if (!(rawSurface in catalogByName)) {
227
+ return NextResponse.json(
228
+ {
229
+ ok: false,
230
+ endpoint: endpointPath,
231
+ error: {
232
+ message: `Unknown surface: ${rawSurface}`,
233
+ code: 'INVALID_SURFACE',
234
+ },
235
+ fix: `Hit GET ${endpointPath} with no params for the full surface catalog.`,
236
+ next_actions: [
237
+ {
238
+ command: `GET ${endpointPath}`,
239
+ description: 'Browse the full analytics surface catalog',
240
+ },
241
+ ],
242
+ },
243
+ { status: 400, headers: corsHeaders },
244
+ )
245
+ }
246
+
247
+ const surface = rawSurface
248
+ const range = parseRange(searchParams.get('range'))
249
+ const limit = Math.min(Number(searchParams.get('limit') ?? 20), 100)
250
+
251
+ if (logger) {
252
+ logger.info('api.analytics.query', {
253
+ userId: access.user?.id ?? null,
254
+ email: access.user?.email ?? null,
255
+ authMethod: access.authMethod ?? 'unknown',
256
+ surface,
257
+ range,
258
+ limit,
259
+ })
260
+ }
261
+
262
+ const result = await engine.query(surface, { range, limit })
263
+
264
+ if (!result.ok) {
265
+ if (logger) {
266
+ logger.error('api.analytics.error', {
267
+ userId: access.user?.id ?? null,
268
+ surface,
269
+ range,
270
+ code: result.error.code,
271
+ error: result.error.message,
272
+ })
273
+ }
274
+
275
+ return NextResponse.json(
276
+ {
277
+ ok: false,
278
+ endpoint: endpointPath,
279
+ surface,
280
+ error: result.error,
281
+ fix: result.fix,
282
+ next_actions: buildContextualNextActions(
283
+ surface,
284
+ range,
285
+ endpointPath,
286
+ ),
287
+ },
288
+ {
289
+ status: result.error.code.endsWith('_UNAVAILABLE') ? 503 : 500,
290
+ headers: corsHeaders,
291
+ },
292
+ )
293
+ }
294
+
295
+ return NextResponse.json(
296
+ {
297
+ ok: true,
298
+ endpoint: endpointPath,
299
+ surface,
300
+ range: result.range,
301
+ description: catalogByName[surface]?.description,
302
+ data: result.data,
303
+ meta: getMeta(
304
+ result.data,
305
+ result.meta.queryTimeMs,
306
+ result.meta.truncated,
307
+ ),
308
+ _links: {
309
+ self: {
310
+ href: `${resolvedBase}${endpointPath}?surface=${surface}&range=${range}`,
311
+ },
312
+ catalog: { href: `${resolvedBase}${endpointPath}` },
313
+ },
314
+ next_actions: buildContextualNextActions(surface, range, endpointPath),
315
+ },
316
+ { headers: corsHeaders },
317
+ )
318
+ }
319
+
320
+ return { GET, OPTIONS }
321
+ }
@@ -0,0 +1,4 @@
1
+ export { createAnalyticsCatalogHandler } from './catalog-handler'
2
+ export type { CatalogHandlerDeps } from './catalog-handler'
3
+ export { createTokenHandler } from './token-handler'
4
+ export type { TokenHandlerDeps } from './token-handler'
@@ -0,0 +1,71 @@
1
+ import { NextRequest, NextResponse } from 'next/server'
2
+
3
+ interface TokenHandlerDeps {
4
+ db: { insert: (table: any) => { values: (data: any) => Promise<any> } }
5
+ deviceAccessToken: unknown
6
+ checkAccess: (request: Request) => Promise<{
7
+ authorized: boolean
8
+ userId?: string
9
+ user?: { email?: string; [key: string]: unknown } | null
10
+ }>
11
+ ttlHours?: number
12
+ logger?: {
13
+ info: (message: string, data?: Record<string, unknown>) => unknown
14
+ warn: (message: string, data?: Record<string, unknown>) => unknown
15
+ error: (message: string, data?: Record<string, unknown>) => unknown
16
+ }
17
+ }
18
+
19
+ export type { TokenHandlerDeps }
20
+
21
+ /**
22
+ * Creates a Next.js App Router POST handler that generates short-lived
23
+ * device access tokens for analytics API authentication.
24
+ *
25
+ * Tokens are stored with only `token` and `userId`. Expiry is **not**
26
+ * stored in the database — it is enforced at lookup time by comparing
27
+ * the row's `createdAt` timestamp against the configured TTL. Each
28
+ * app's `getUserAbilityForRequest` must enforce this check.
29
+ *
30
+ * @param deps - Handler dependencies including db, deviceAccessToken schema
31
+ * table, an access-check function, and optional TTL and logger overrides
32
+ * @returns An object with a `POST` Next.js route handler
33
+ */
34
+ export function createTokenHandler(deps: TokenHandlerDeps) {
35
+ const { db, deviceAccessToken, checkAccess, logger } = deps
36
+ const ttlHours = deps.ttlHours ?? 24
37
+ const ttlLabel = `${ttlHours} hours`
38
+
39
+ const POST = async (request: NextRequest) => {
40
+ const access = await checkAccess(request)
41
+
42
+ if (!access.authorized || !access.userId) {
43
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
44
+ }
45
+
46
+ const userId = access.userId
47
+ const token = crypto.randomUUID()
48
+
49
+ await db.insert(deviceAccessToken).values({
50
+ token,
51
+ userId,
52
+ })
53
+
54
+ if (logger) {
55
+ void logger.info('api.analytics.token-generated', {
56
+ userId,
57
+ email: access.user?.email ?? null,
58
+ ttlHours,
59
+ })
60
+ }
61
+
62
+ return NextResponse.json({
63
+ token,
64
+ ttl: `${ttlHours}h`,
65
+ ttlLabel,
66
+ expiresAt: new Date(Date.now() + ttlHours * 60 * 60 * 1000).toISOString(),
67
+ })
68
+ }
69
+
70
+ return { POST }
71
+ }
package/src/catalog.ts ADDED
@@ -0,0 +1,223 @@
1
+ import type { SurfaceName } from './types'
2
+
3
+ export interface SurfaceEntry {
4
+ name: SurfaceName
5
+ description: string
6
+ category:
7
+ | 'revenue'
8
+ | 'attribution'
9
+ | 'traffic'
10
+ | 'youtube'
11
+ | 'correlation'
12
+ | 'survey'
13
+ provider: 'database' | 'ga4' | 'youtube' | 'derived' | 'newsletter' | 'survey'
14
+ fn: string
15
+ unavailableFix?: string
16
+ }
17
+
18
+ export const catalog: Record<SurfaceName, SurfaceEntry> = {
19
+ summary: {
20
+ name: 'summary',
21
+ description: 'Revenue overview: total, purchase count, AOV',
22
+ category: 'revenue',
23
+ provider: 'database',
24
+ fn: 'getRevenueSummary',
25
+ },
26
+ 'revenue/daily': {
27
+ name: 'revenue/daily',
28
+ description: 'Revenue and purchase count per day',
29
+ category: 'revenue',
30
+ provider: 'database',
31
+ fn: 'getRevenueByDay',
32
+ },
33
+ 'revenue/products': {
34
+ name: 'revenue/products',
35
+ description: 'Revenue grouped by product',
36
+ category: 'revenue',
37
+ provider: 'database',
38
+ fn: 'getRevenueByProduct',
39
+ },
40
+ 'revenue/countries': {
41
+ name: 'revenue/countries',
42
+ description: 'Revenue grouped by country',
43
+ category: 'revenue',
44
+ provider: 'database',
45
+ fn: 'getRevenueByCountry',
46
+ },
47
+ 'purchases/recent': {
48
+ name: 'purchases/recent',
49
+ description: 'Last N purchases',
50
+ category: 'revenue',
51
+ provider: 'database',
52
+ fn: 'getRecentPurchases',
53
+ },
54
+ attribution: {
55
+ name: 'attribution',
56
+ description: 'Attribution event counts by type',
57
+ category: 'attribution',
58
+ provider: 'database',
59
+ fn: 'getAttributionSummary',
60
+ },
61
+ 'attribution/shortlinks': {
62
+ name: 'attribution/shortlinks',
63
+ description: 'Per-shortlink click performance',
64
+ category: 'attribution',
65
+ provider: 'database',
66
+ fn: 'getShortlinkPerformance',
67
+ },
68
+ 'attribution/sources': {
69
+ name: 'attribution/sources',
70
+ description: 'Revenue by first-touch source/medium/campaign',
71
+ category: 'attribution',
72
+ provider: 'database',
73
+ fn: 'getRevenueBySource',
74
+ },
75
+ 'attribution/funnel': {
76
+ name: 'attribution/funnel',
77
+ description: 'Signup → purchase conversion funnel',
78
+ category: 'attribution',
79
+ provider: 'database',
80
+ fn: 'getConversionFunnel',
81
+ },
82
+ 'attribution/content': {
83
+ name: 'attribution/content',
84
+ description: 'Content consumed by purchasers',
85
+ category: 'attribution',
86
+ provider: 'database',
87
+ fn: 'getContentPurchaseCorrelation',
88
+ },
89
+ 'attribution/coverage': {
90
+ name: 'attribution/coverage',
91
+ description: 'Attributed vs dark revenue',
92
+ category: 'attribution',
93
+ provider: 'database',
94
+ fn: 'getAttributedRevenueSummary',
95
+ },
96
+ traffic: {
97
+ name: 'traffic',
98
+ description: 'GA4 traffic overview',
99
+ category: 'traffic',
100
+ provider: 'ga4',
101
+ fn: 'getTrafficOverview',
102
+ },
103
+ 'traffic/daily': {
104
+ name: 'traffic/daily',
105
+ description: 'GA4 daily sessions',
106
+ category: 'traffic',
107
+ provider: 'ga4',
108
+ fn: 'getSessionsByDay',
109
+ },
110
+ 'traffic/pages': {
111
+ name: 'traffic/pages',
112
+ description: 'Top pages by pageviews',
113
+ category: 'traffic',
114
+ provider: 'ga4',
115
+ fn: 'getTopPages',
116
+ },
117
+ 'traffic/sources': {
118
+ name: 'traffic/sources',
119
+ description: 'Traffic sources',
120
+ category: 'traffic',
121
+ provider: 'ga4',
122
+ fn: 'getTrafficSources',
123
+ },
124
+ youtube: {
125
+ name: 'youtube',
126
+ description: 'Channel overview (≈48h lag)',
127
+ category: 'youtube',
128
+ provider: 'youtube',
129
+ fn: 'getChannelOverview',
130
+ unavailableFix: 'Complete OAuth at /api/analytics/youtube-auth',
131
+ },
132
+ 'youtube/videos': {
133
+ name: 'youtube/videos',
134
+ description: 'Per-video performance (≈48h lag)',
135
+ category: 'youtube',
136
+ provider: 'youtube',
137
+ fn: 'getVideoPerformance',
138
+ unavailableFix: 'Complete OAuth at /api/analytics/youtube-auth',
139
+ },
140
+ 'youtube/daily': {
141
+ name: 'youtube/daily',
142
+ description: 'Daily views + watch minutes (≈48h lag)',
143
+ category: 'youtube',
144
+ provider: 'youtube',
145
+ fn: 'getChannelTimeseries',
146
+ unavailableFix: 'Complete OAuth at /api/analytics/youtube-auth',
147
+ },
148
+ 'youtube/sources': {
149
+ name: 'youtube/sources',
150
+ description: 'YouTube traffic sources (≈48h lag)',
151
+ category: 'youtube',
152
+ provider: 'youtube',
153
+ fn: 'getYouTubeTrafficSources',
154
+ unavailableFix: 'Complete OAuth at /api/analytics/youtube-auth',
155
+ },
156
+ 'correlation/traffic-revenue': {
157
+ name: 'correlation/traffic-revenue',
158
+ description: 'GA4 sessions + revenue by day',
159
+ category: 'correlation',
160
+ provider: 'derived',
161
+ fn: 'getTrafficRevenueCorrelation',
162
+ },
163
+ 'correlation/youtube-revenue': {
164
+ name: 'correlation/youtube-revenue',
165
+ description: 'YouTube (≈48h lag) + GA4 + revenue overlay',
166
+ category: 'correlation',
167
+ provider: 'derived',
168
+ fn: 'getYouTubeRevenueCorrelation',
169
+ },
170
+ surveys: {
171
+ name: 'surveys',
172
+ description: 'Survey overview: total surveys, responses, respondents',
173
+ category: 'survey',
174
+ provider: 'survey',
175
+ fn: 'getSurveySummary',
176
+ },
177
+ 'surveys/list': {
178
+ name: 'surveys/list',
179
+ description: 'All surveys with response counts',
180
+ category: 'survey',
181
+ provider: 'survey',
182
+ fn: 'getSurveyList',
183
+ },
184
+ 'surveys/daily': {
185
+ name: 'surveys/daily',
186
+ description: 'Daily survey response volume',
187
+ category: 'survey',
188
+ provider: 'survey',
189
+ fn: 'getSurveyResponsesByDay',
190
+ },
191
+ 'surveys/questions': {
192
+ name: 'surveys/questions',
193
+ description: 'Top questions by response count with answer distribution',
194
+ category: 'survey',
195
+ provider: 'survey',
196
+ fn: 'getSurveyQuestionBreakdown',
197
+ },
198
+ 'surveys/responses': {
199
+ name: 'surveys/responses',
200
+ description:
201
+ 'Individual survey responses as flat rows (multi-choice and open-ended)',
202
+ category: 'survey',
203
+ provider: 'survey',
204
+ fn: 'getSurveyResponses',
205
+ },
206
+ 'attribution/email-campaigns': {
207
+ name: 'attribution/email-campaigns',
208
+ description:
209
+ 'Kit email broadcasts → shortlink clicks → signups → purchases → revenue per campaign',
210
+ category: 'attribution',
211
+ provider: 'newsletter',
212
+ fn: 'getEmailCampaignAttribution',
213
+ },
214
+ 'correlation/survey-revenue': {
215
+ name: 'correlation/survey-revenue',
216
+ description: 'Survey respondents → purchase conversion by question/answer',
217
+ category: 'correlation',
218
+ provider: 'derived',
219
+ fn: 'getSurveyRevenueCorrelation',
220
+ },
221
+ } as const satisfies Record<SurfaceName, SurfaceEntry>
222
+
223
+ export const ANALYTICS_CATALOG = catalog