@coursebuilder/analytics 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/api/index.d.ts +158 -0
- package/dist/api/index.js +317 -0
- package/dist/api/index.js.map +1 -0
- package/dist/catalog.d.ts +14 -0
- package/dist/catalog.js +209 -0
- package/dist/catalog.js.map +1 -0
- package/dist/components/index.d.ts +172 -0
- package/dist/components/index.js +1258 -0
- package/dist/components/index.js.map +1 -0
- package/dist/engine.d.ts +20 -0
- package/dist/engine.js +350 -0
- package/dist/engine.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +353 -0
- package/dist/index.js.map +1 -0
- package/dist/providers/database.d.ts +79 -0
- package/dist/providers/database.js +533 -0
- package/dist/providers/database.js.map +1 -0
- package/dist/providers/derived.d.ts +45 -0
- package/dist/providers/derived.js +32 -0
- package/dist/providers/derived.js.map +1 -0
- package/dist/providers/ga4.d.ts +43 -0
- package/dist/providers/ga4.js +220 -0
- package/dist/providers/ga4.js.map +1 -0
- package/dist/providers/index.d.ts +8 -0
- package/dist/providers/index.js +1239 -0
- package/dist/providers/index.js.map +1 -0
- package/dist/providers/mux.d.ts +103 -0
- package/dist/providers/mux.js +241 -0
- package/dist/providers/mux.js.map +1 -0
- package/dist/providers/survey.d.ts +102 -0
- package/dist/providers/survey.js +233 -0
- package/dist/providers/survey.js.map +1 -0
- package/dist/types.d.ts +303 -0
- package/dist/types.js +1 -0
- package/dist/types.js.map +1 -0
- package/package.json +101 -0
- package/src/api/catalog-handler.ts +321 -0
- package/src/api/index.ts +4 -0
- package/src/api/token-handler.ts +71 -0
- package/src/catalog.ts +223 -0
- package/src/components/country-chart.tsx +114 -0
- package/src/components/index.ts +5 -0
- package/src/components/omnibus-dashboard.tsx +1460 -0
- package/src/components/revenue-chart.tsx +251 -0
- package/src/components/use-chart-colors.ts +75 -0
- package/src/engine.ts +201 -0
- package/src/index.ts +7 -0
- package/src/providers/database.ts +795 -0
- package/src/providers/derived.ts +79 -0
- package/src/providers/ga4.ts +173 -0
- package/src/providers/index.ts +44 -0
- package/src/providers/mux.ts +438 -0
- package/src/providers/survey.ts +487 -0
- package/src/types.ts +333 -0
|
@@ -0,0 +1,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
|
+
}
|
package/src/api/index.ts
ADDED
|
@@ -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
|