@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,795 @@
|
|
|
1
|
+
import {
|
|
2
|
+
and,
|
|
3
|
+
count,
|
|
4
|
+
desc,
|
|
5
|
+
eq,
|
|
6
|
+
gt,
|
|
7
|
+
gte,
|
|
8
|
+
inArray,
|
|
9
|
+
lte,
|
|
10
|
+
sql,
|
|
11
|
+
sum,
|
|
12
|
+
} from 'drizzle-orm'
|
|
13
|
+
|
|
14
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export type AnalyticsTimeRange = '24h' | '7d' | '30d' | '90d' | 'all'
|
|
17
|
+
|
|
18
|
+
export interface AttributionTrailEvent {
|
|
19
|
+
type: 'click' | 'signup' | 'progress' | 'purchase'
|
|
20
|
+
timestamp: Date
|
|
21
|
+
detail: Record<string, any>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AttributionTrail {
|
|
25
|
+
user: {
|
|
26
|
+
id: string
|
|
27
|
+
email: string | null
|
|
28
|
+
name: string | null
|
|
29
|
+
createdAt: Date
|
|
30
|
+
} | null
|
|
31
|
+
events: AttributionTrailEvent[]
|
|
32
|
+
purchases: {
|
|
33
|
+
id: string
|
|
34
|
+
totalAmount: number
|
|
35
|
+
productName: string
|
|
36
|
+
createdAt: Date
|
|
37
|
+
country: string | null
|
|
38
|
+
utmSource: string | null
|
|
39
|
+
utmMedium: string | null
|
|
40
|
+
utmCampaign: string | null
|
|
41
|
+
}[]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ─── Schema type ─────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
export interface DatabaseAnalyticsSchema {
|
|
47
|
+
purchases: any
|
|
48
|
+
products: any
|
|
49
|
+
users: any
|
|
50
|
+
coupon: any
|
|
51
|
+
resourceProgress: any
|
|
52
|
+
shortlink: any
|
|
53
|
+
shortlinkAttribution: any
|
|
54
|
+
shortlinkClick: any
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─── Factory ─────────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Creates a database analytics provider that wraps all analytics query
|
|
61
|
+
* functions with an injected drizzle db instance and schema tables.
|
|
62
|
+
*
|
|
63
|
+
* @param db - Drizzle database instance
|
|
64
|
+
* @param schema - Object containing the required table references
|
|
65
|
+
*/
|
|
66
|
+
export function createDatabaseProvider(
|
|
67
|
+
db: any,
|
|
68
|
+
schema: DatabaseAnalyticsSchema,
|
|
69
|
+
) {
|
|
70
|
+
const {
|
|
71
|
+
purchases,
|
|
72
|
+
products,
|
|
73
|
+
users,
|
|
74
|
+
coupon,
|
|
75
|
+
resourceProgress,
|
|
76
|
+
shortlink,
|
|
77
|
+
shortlinkAttribution,
|
|
78
|
+
shortlinkClick,
|
|
79
|
+
} = schema
|
|
80
|
+
|
|
81
|
+
// ─── Internal helpers ───────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
const PAID_STATUSES = ['Valid', 'Restricted'] as const
|
|
84
|
+
|
|
85
|
+
function paidPurchase() {
|
|
86
|
+
return inArray(purchases.status, [...PAID_STATUSES])
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function rangeToDate(range: AnalyticsTimeRange): Date | null {
|
|
90
|
+
if (range === 'all') return null
|
|
91
|
+
const now = new Date()
|
|
92
|
+
const hours: Record<string, number> = {
|
|
93
|
+
'24h': 24,
|
|
94
|
+
'7d': 7 * 24,
|
|
95
|
+
'30d': 30 * 24,
|
|
96
|
+
'90d': 90 * 24,
|
|
97
|
+
}
|
|
98
|
+
return new Date(now.getTime() - (hours[range] ?? 30 * 24) * 60 * 60 * 1000)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ─── Revenue ───────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
async function getRevenueSummary(range: AnalyticsTimeRange = '30d') {
|
|
104
|
+
const since = rangeToDate(range)
|
|
105
|
+
const conditions = [paidPurchase()]
|
|
106
|
+
if (since) conditions.push(gte(purchases.createdAt, since))
|
|
107
|
+
|
|
108
|
+
const [totals] = await db
|
|
109
|
+
.select({
|
|
110
|
+
totalRevenue: sum(purchases.totalAmount),
|
|
111
|
+
purchaseCount: count(),
|
|
112
|
+
})
|
|
113
|
+
.from(purchases)
|
|
114
|
+
.where(and(...conditions))
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
totalRevenue: Number(totals?.totalRevenue ?? 0),
|
|
118
|
+
purchaseCount: totals?.purchaseCount ?? 0,
|
|
119
|
+
avgOrderValue:
|
|
120
|
+
totals?.purchaseCount && totals.purchaseCount > 0
|
|
121
|
+
? Number(totals.totalRevenue ?? 0) / totals.purchaseCount
|
|
122
|
+
: 0,
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function getRevenueByDay(range: AnalyticsTimeRange = '30d') {
|
|
127
|
+
const since = rangeToDate(range)
|
|
128
|
+
const conditions = [paidPurchase()]
|
|
129
|
+
if (since) conditions.push(gte(purchases.createdAt, since))
|
|
130
|
+
|
|
131
|
+
const rows = await db
|
|
132
|
+
.select({
|
|
133
|
+
date: sql<string>`DATE(${purchases.createdAt})`.as('date'),
|
|
134
|
+
revenue: sum(purchases.totalAmount),
|
|
135
|
+
count: count(),
|
|
136
|
+
})
|
|
137
|
+
.from(purchases)
|
|
138
|
+
.where(and(...conditions))
|
|
139
|
+
.groupBy(sql`DATE(${purchases.createdAt})`)
|
|
140
|
+
.orderBy(sql`DATE(${purchases.createdAt})`)
|
|
141
|
+
|
|
142
|
+
return rows.map((r: any) => ({
|
|
143
|
+
date: r.date,
|
|
144
|
+
revenue: Number(r.revenue ?? 0),
|
|
145
|
+
count: r.count,
|
|
146
|
+
}))
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Revenue by day for the previous period of equal length.
|
|
151
|
+
* E.g., if range = '30d', returns the 30 days before those 30 days.
|
|
152
|
+
* Returns data with a `dayOffset` (0 = start of period) for overlay
|
|
153
|
+
* alignment.
|
|
154
|
+
*/
|
|
155
|
+
async function getPreviousPeriodRevenueByDay(
|
|
156
|
+
range: AnalyticsTimeRange = '30d',
|
|
157
|
+
) {
|
|
158
|
+
if (range === 'all') return []
|
|
159
|
+
|
|
160
|
+
const hours: Record<string, number> = {
|
|
161
|
+
'24h': 24,
|
|
162
|
+
'7d': 7 * 24,
|
|
163
|
+
'30d': 30 * 24,
|
|
164
|
+
'90d': 90 * 24,
|
|
165
|
+
}
|
|
166
|
+
const periodMs = (hours[range] ?? 30 * 24) * 60 * 60 * 1000
|
|
167
|
+
const now = new Date()
|
|
168
|
+
const periodStart = new Date(now.getTime() - periodMs)
|
|
169
|
+
const prevStart = new Date(periodStart.getTime() - periodMs)
|
|
170
|
+
|
|
171
|
+
const rows = await db
|
|
172
|
+
.select({
|
|
173
|
+
date: sql<string>`DATE(${purchases.createdAt})`.as('date'),
|
|
174
|
+
revenue: sum(purchases.totalAmount),
|
|
175
|
+
count: count(),
|
|
176
|
+
})
|
|
177
|
+
.from(purchases)
|
|
178
|
+
.where(
|
|
179
|
+
and(
|
|
180
|
+
paidPurchase(),
|
|
181
|
+
gte(purchases.createdAt, prevStart),
|
|
182
|
+
lte(purchases.createdAt, periodStart),
|
|
183
|
+
),
|
|
184
|
+
)
|
|
185
|
+
.groupBy(sql`DATE(${purchases.createdAt})`)
|
|
186
|
+
.orderBy(sql`DATE(${purchases.createdAt})`)
|
|
187
|
+
|
|
188
|
+
return rows.map((r: any) => ({
|
|
189
|
+
date: r.date,
|
|
190
|
+
revenue: Number(r.revenue ?? 0),
|
|
191
|
+
count: r.count,
|
|
192
|
+
}))
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function getRevenueByProduct(range: AnalyticsTimeRange = '30d') {
|
|
196
|
+
const since = rangeToDate(range)
|
|
197
|
+
const conditions = [paidPurchase()]
|
|
198
|
+
if (since) conditions.push(gte(purchases.createdAt, since))
|
|
199
|
+
|
|
200
|
+
const rows = await db
|
|
201
|
+
.select({
|
|
202
|
+
productId: purchases.productId,
|
|
203
|
+
productName: products.name,
|
|
204
|
+
revenue: sum(purchases.totalAmount),
|
|
205
|
+
count: count(),
|
|
206
|
+
})
|
|
207
|
+
.from(purchases)
|
|
208
|
+
.leftJoin(products, eq(purchases.productId, products.id))
|
|
209
|
+
.where(and(...conditions))
|
|
210
|
+
.groupBy(purchases.productId, products.name)
|
|
211
|
+
.orderBy(desc(sum(purchases.totalAmount)))
|
|
212
|
+
|
|
213
|
+
return rows.map((r: any) => ({
|
|
214
|
+
productId: r.productId,
|
|
215
|
+
productName: r.productName ?? '(unknown)',
|
|
216
|
+
revenue: Number(r.revenue ?? 0),
|
|
217
|
+
count: r.count,
|
|
218
|
+
}))
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function getRevenueByCountry(range: AnalyticsTimeRange = '30d') {
|
|
222
|
+
const since = rangeToDate(range)
|
|
223
|
+
const conditions = [paidPurchase()]
|
|
224
|
+
if (since) conditions.push(gte(purchases.createdAt, since))
|
|
225
|
+
|
|
226
|
+
const rows = await db
|
|
227
|
+
.select({
|
|
228
|
+
country: purchases.country,
|
|
229
|
+
revenue: sum(purchases.totalAmount),
|
|
230
|
+
count: count(),
|
|
231
|
+
})
|
|
232
|
+
.from(purchases)
|
|
233
|
+
.where(and(...conditions))
|
|
234
|
+
.groupBy(purchases.country)
|
|
235
|
+
.orderBy(desc(sum(purchases.totalAmount)))
|
|
236
|
+
.limit(20)
|
|
237
|
+
|
|
238
|
+
return rows.map((r: any) => ({
|
|
239
|
+
country: r.country ?? '(unknown)',
|
|
240
|
+
revenue: Number(r.revenue ?? 0),
|
|
241
|
+
count: r.count,
|
|
242
|
+
}))
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function getRecentPurchases(
|
|
246
|
+
limit: number = 20,
|
|
247
|
+
filter: 'all' | 'team' | 'individual' = 'all',
|
|
248
|
+
range: AnalyticsTimeRange = 'all',
|
|
249
|
+
) {
|
|
250
|
+
const since = rangeToDate(range)
|
|
251
|
+
const conditions = [paidPurchase()]
|
|
252
|
+
if (since) conditions.push(gte(purchases.createdAt, since))
|
|
253
|
+
|
|
254
|
+
if (filter === 'team') {
|
|
255
|
+
// Multi-seat purchases: join coupon to filter seats > 1, sort by amount
|
|
256
|
+
conditions.push(sql`${purchases.bulkCouponId} IS NOT NULL`)
|
|
257
|
+
|
|
258
|
+
const rows = await db
|
|
259
|
+
.select({
|
|
260
|
+
id: purchases.id,
|
|
261
|
+
createdAt: purchases.createdAt,
|
|
262
|
+
totalAmount: purchases.totalAmount,
|
|
263
|
+
productName: products.name,
|
|
264
|
+
productId: purchases.productId,
|
|
265
|
+
country: purchases.country,
|
|
266
|
+
couponId: purchases.couponId,
|
|
267
|
+
userId: purchases.userId,
|
|
268
|
+
userName: users.name,
|
|
269
|
+
userEmail: users.email,
|
|
270
|
+
organizationId: purchases.organizationId,
|
|
271
|
+
seats: coupon.maxUses,
|
|
272
|
+
})
|
|
273
|
+
.from(purchases)
|
|
274
|
+
.leftJoin(products, eq(purchases.productId, products.id))
|
|
275
|
+
.leftJoin(users, eq(purchases.userId, users.id))
|
|
276
|
+
.leftJoin(coupon, eq(purchases.bulkCouponId, coupon.id))
|
|
277
|
+
.where(and(...conditions, gt(coupon.maxUses, 1)))
|
|
278
|
+
.orderBy(desc(purchases.totalAmount))
|
|
279
|
+
.limit(limit)
|
|
280
|
+
|
|
281
|
+
return rows.map((r: any) => ({
|
|
282
|
+
id: r.id,
|
|
283
|
+
createdAt: r.createdAt,
|
|
284
|
+
totalAmount: Number(r.totalAmount),
|
|
285
|
+
productName: r.productName ?? '(unknown)',
|
|
286
|
+
productId: r.productId,
|
|
287
|
+
country: r.country,
|
|
288
|
+
couponId: r.couponId,
|
|
289
|
+
userName: r.userName ?? null,
|
|
290
|
+
userEmail: r.userEmail ?? null,
|
|
291
|
+
isTeam: true,
|
|
292
|
+
seats: r.seats ?? null,
|
|
293
|
+
}))
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (filter === 'individual') {
|
|
297
|
+
conditions.push(sql`${purchases.bulkCouponId} IS NULL`)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const rows = await db.query.purchases.findMany({
|
|
301
|
+
where: and(...conditions),
|
|
302
|
+
orderBy: [desc(purchases.totalAmount)],
|
|
303
|
+
limit,
|
|
304
|
+
with: {
|
|
305
|
+
product: true,
|
|
306
|
+
user: true,
|
|
307
|
+
},
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
return rows.map((r: any) => ({
|
|
311
|
+
id: r.id,
|
|
312
|
+
createdAt: r.createdAt,
|
|
313
|
+
totalAmount: Number(r.totalAmount),
|
|
314
|
+
productName: r.product?.name ?? '(unknown)',
|
|
315
|
+
productId: r.productId,
|
|
316
|
+
country: r.country,
|
|
317
|
+
couponId: r.couponId,
|
|
318
|
+
userName: r.user?.name ?? null,
|
|
319
|
+
userEmail: r.user?.email ?? null,
|
|
320
|
+
isTeam: r.organizationId != null,
|
|
321
|
+
seats: null as number | null,
|
|
322
|
+
}))
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ─── Attribution ────────────────────────────────────────────────────────
|
|
326
|
+
|
|
327
|
+
async function getAttributionSummary(range: AnalyticsTimeRange = '30d') {
|
|
328
|
+
const since = rangeToDate(range)
|
|
329
|
+
const conditions: any[] = []
|
|
330
|
+
if (since) conditions.push(gte(shortlinkAttribution.createdAt, since))
|
|
331
|
+
|
|
332
|
+
const rows = await db
|
|
333
|
+
.select({
|
|
334
|
+
type: shortlinkAttribution.type,
|
|
335
|
+
count: count(),
|
|
336
|
+
})
|
|
337
|
+
.from(shortlinkAttribution)
|
|
338
|
+
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
|
339
|
+
.groupBy(shortlinkAttribution.type)
|
|
340
|
+
|
|
341
|
+
return rows.map((r: any) => ({
|
|
342
|
+
type: r.type,
|
|
343
|
+
count: r.count,
|
|
344
|
+
}))
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function getShortlinkPerformance(range: AnalyticsTimeRange = '30d') {
|
|
348
|
+
const since = rangeToDate(range)
|
|
349
|
+
const clickConditions: any[] = []
|
|
350
|
+
if (since) clickConditions.push(gte(shortlinkClick.timestamp, since))
|
|
351
|
+
|
|
352
|
+
const rows = await db
|
|
353
|
+
.select({
|
|
354
|
+
shortlinkId: shortlinkClick.shortlinkId,
|
|
355
|
+
slug: shortlink.slug,
|
|
356
|
+
url: shortlink.url,
|
|
357
|
+
clicks: count(),
|
|
358
|
+
})
|
|
359
|
+
.from(shortlinkClick)
|
|
360
|
+
.innerJoin(shortlink, eq(shortlinkClick.shortlinkId, shortlink.id))
|
|
361
|
+
.where(clickConditions.length > 0 ? and(...clickConditions) : undefined)
|
|
362
|
+
.groupBy(shortlinkClick.shortlinkId, shortlink.slug, shortlink.url)
|
|
363
|
+
.orderBy(desc(count()))
|
|
364
|
+
.limit(20)
|
|
365
|
+
|
|
366
|
+
// Get attribution counts per shortlink
|
|
367
|
+
const attrConditions: any[] = []
|
|
368
|
+
if (since) attrConditions.push(gte(shortlinkAttribution.createdAt, since))
|
|
369
|
+
|
|
370
|
+
const attrRows = await db
|
|
371
|
+
.select({
|
|
372
|
+
shortlinkId: shortlinkAttribution.shortlinkId,
|
|
373
|
+
type: shortlinkAttribution.type,
|
|
374
|
+
count: count(),
|
|
375
|
+
})
|
|
376
|
+
.from(shortlinkAttribution)
|
|
377
|
+
.where(attrConditions.length > 0 ? and(...attrConditions) : undefined)
|
|
378
|
+
.groupBy(shortlinkAttribution.shortlinkId, shortlinkAttribution.type)
|
|
379
|
+
|
|
380
|
+
const attrMap = new Map<string, { signups: number; purchases: number }>()
|
|
381
|
+
for (const a of attrRows) {
|
|
382
|
+
const existing = attrMap.get(a.shortlinkId) ?? {
|
|
383
|
+
signups: 0,
|
|
384
|
+
purchases: 0,
|
|
385
|
+
}
|
|
386
|
+
if (a.type === 'signup') existing.signups = a.count
|
|
387
|
+
if (a.type === 'purchase') existing.purchases = a.count
|
|
388
|
+
attrMap.set(a.shortlinkId, existing)
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return rows.map((r: any) => {
|
|
392
|
+
const attr = attrMap.get(r.shortlinkId)
|
|
393
|
+
return {
|
|
394
|
+
shortlinkId: r.shortlinkId,
|
|
395
|
+
slug: r.slug,
|
|
396
|
+
url: r.url,
|
|
397
|
+
clicks: r.clicks,
|
|
398
|
+
signups: attr?.signups ?? 0,
|
|
399
|
+
purchases: attr?.purchases ?? 0,
|
|
400
|
+
}
|
|
401
|
+
})
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ─── Revenue Attribution ─────────────────────────────────────────────────
|
|
405
|
+
|
|
406
|
+
async function getRevenueBySource(range: AnalyticsTimeRange = '30d') {
|
|
407
|
+
const since = rangeToDate(range)
|
|
408
|
+
const conditions = [paidPurchase()]
|
|
409
|
+
if (since) conditions.push(gte(purchases.createdAt, since))
|
|
410
|
+
|
|
411
|
+
const rows = await db
|
|
412
|
+
.select({
|
|
413
|
+
source:
|
|
414
|
+
sql<string>`JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.utmSource'))`.as(
|
|
415
|
+
'source',
|
|
416
|
+
),
|
|
417
|
+
medium:
|
|
418
|
+
sql<string>`JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.utmMedium'))`.as(
|
|
419
|
+
'medium',
|
|
420
|
+
),
|
|
421
|
+
campaign:
|
|
422
|
+
sql<string>`JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.utmCampaign'))`.as(
|
|
423
|
+
'campaign',
|
|
424
|
+
),
|
|
425
|
+
revenue: sum(purchases.totalAmount),
|
|
426
|
+
count: count(),
|
|
427
|
+
})
|
|
428
|
+
.from(purchases)
|
|
429
|
+
.where(and(...conditions))
|
|
430
|
+
.groupBy(
|
|
431
|
+
sql`JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.utmSource'))`,
|
|
432
|
+
sql`JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.utmMedium'))`,
|
|
433
|
+
sql`JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.utmCampaign'))`,
|
|
434
|
+
)
|
|
435
|
+
.orderBy(desc(sum(purchases.totalAmount)))
|
|
436
|
+
|
|
437
|
+
return rows.map((r: any) => ({
|
|
438
|
+
source: r.source ?? null,
|
|
439
|
+
medium: r.medium ?? null,
|
|
440
|
+
campaign: r.campaign ?? null,
|
|
441
|
+
revenue: Number(r.revenue ?? 0),
|
|
442
|
+
count: r.count,
|
|
443
|
+
}))
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async function getConversionFunnel(range: AnalyticsTimeRange = '30d') {
|
|
447
|
+
const since = rangeToDate(range)
|
|
448
|
+
|
|
449
|
+
const userConditions = since ? [gte(users.createdAt, since)] : []
|
|
450
|
+
const purchaseConditions = [paidPurchase()]
|
|
451
|
+
if (since) purchaseConditions.push(gte(purchases.createdAt, since))
|
|
452
|
+
|
|
453
|
+
const [userCount] = await db
|
|
454
|
+
.select({ total: count() })
|
|
455
|
+
.from(users)
|
|
456
|
+
.where(userConditions.length > 0 ? and(...userConditions) : undefined)
|
|
457
|
+
|
|
458
|
+
const [purchaseCount] = await db
|
|
459
|
+
.select({ total: count() })
|
|
460
|
+
.from(purchases)
|
|
461
|
+
.where(and(...purchaseConditions))
|
|
462
|
+
|
|
463
|
+
const [attributedCount] = await db
|
|
464
|
+
.select({ total: count() })
|
|
465
|
+
.from(purchases)
|
|
466
|
+
.where(
|
|
467
|
+
and(
|
|
468
|
+
...purchaseConditions,
|
|
469
|
+
sql`(
|
|
470
|
+
JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.utmSource')) IS NOT NULL
|
|
471
|
+
OR JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.gaClientId')) IS NOT NULL
|
|
472
|
+
)`,
|
|
473
|
+
),
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
const totalSignups = userCount?.total ?? 0
|
|
477
|
+
const totalPurchases = purchaseCount?.total ?? 0
|
|
478
|
+
const attributedPurchases = attributedCount?.total ?? 0
|
|
479
|
+
|
|
480
|
+
return {
|
|
481
|
+
totalSignups,
|
|
482
|
+
totalPurchases,
|
|
483
|
+
attributedPurchases,
|
|
484
|
+
conversionRate: totalSignups > 0 ? totalPurchases / totalSignups : 0,
|
|
485
|
+
attributionCoverage:
|
|
486
|
+
totalPurchases > 0 ? attributedPurchases / totalPurchases : 0,
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async function getAttributedRevenueSummary(
|
|
491
|
+
range: AnalyticsTimeRange = '30d',
|
|
492
|
+
) {
|
|
493
|
+
const since = rangeToDate(range)
|
|
494
|
+
const conditions = [paidPurchase()]
|
|
495
|
+
if (since) conditions.push(gte(purchases.createdAt, since))
|
|
496
|
+
|
|
497
|
+
const [totals] = await db
|
|
498
|
+
.select({ total: sum(purchases.totalAmount), count: count() })
|
|
499
|
+
.from(purchases)
|
|
500
|
+
.where(and(...conditions))
|
|
501
|
+
|
|
502
|
+
const [attributed] = await db
|
|
503
|
+
.select({ total: sum(purchases.totalAmount) })
|
|
504
|
+
.from(purchases)
|
|
505
|
+
.where(
|
|
506
|
+
and(
|
|
507
|
+
...conditions,
|
|
508
|
+
sql`(
|
|
509
|
+
JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.utmSource')) IS NOT NULL
|
|
510
|
+
OR JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.gaClientId')) IS NOT NULL
|
|
511
|
+
)`,
|
|
512
|
+
),
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
const totalRevenue = Number(totals?.total ?? 0)
|
|
516
|
+
const attributedRevenue = Number(attributed?.total ?? 0)
|
|
517
|
+
const unattributedRevenue = totalRevenue - attributedRevenue
|
|
518
|
+
const totalPurchases = totals?.count ?? 0
|
|
519
|
+
|
|
520
|
+
return {
|
|
521
|
+
totalRevenue,
|
|
522
|
+
attributedRevenue,
|
|
523
|
+
unattributedRevenue,
|
|
524
|
+
attributionRate: totalRevenue > 0 ? attributedRevenue / totalRevenue : 0,
|
|
525
|
+
totalPurchases,
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async function getContentPurchaseCorrelation(
|
|
530
|
+
range: AnalyticsTimeRange = '30d',
|
|
531
|
+
limit: number = 20,
|
|
532
|
+
) {
|
|
533
|
+
const since = rangeToDate(range)
|
|
534
|
+
const purchaseConditions = [paidPurchase()]
|
|
535
|
+
if (since) purchaseConditions.push(gte(purchases.createdAt, since))
|
|
536
|
+
|
|
537
|
+
const purchaserRows = await db
|
|
538
|
+
.selectDistinct({ userId: purchases.userId })
|
|
539
|
+
.from(purchases)
|
|
540
|
+
.where(and(...purchaseConditions))
|
|
541
|
+
|
|
542
|
+
const purchaserIds = purchaserRows
|
|
543
|
+
.map((r: any) => r.userId)
|
|
544
|
+
.filter((id: any): id is string => id !== null)
|
|
545
|
+
|
|
546
|
+
if (purchaserIds.length === 0) return []
|
|
547
|
+
|
|
548
|
+
const rows = await db
|
|
549
|
+
.select({
|
|
550
|
+
resourceId: resourceProgress.resourceId,
|
|
551
|
+
purchaserCount: count(),
|
|
552
|
+
})
|
|
553
|
+
.from(resourceProgress)
|
|
554
|
+
.where(
|
|
555
|
+
sql`${resourceProgress.userId} IN (${sql.join(
|
|
556
|
+
purchaserIds.map((id: string) => sql`${id}`),
|
|
557
|
+
sql`, `,
|
|
558
|
+
)})`,
|
|
559
|
+
)
|
|
560
|
+
.groupBy(resourceProgress.resourceId)
|
|
561
|
+
.orderBy(desc(count()))
|
|
562
|
+
.limit(limit)
|
|
563
|
+
|
|
564
|
+
return rows.map((r: any) => ({
|
|
565
|
+
resourceId: r.resourceId,
|
|
566
|
+
purchaserCount: r.purchaserCount,
|
|
567
|
+
}))
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// ─── Attribution Trail ───────────────────────────────────────────────────
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Trace the full attribution journey for a user by email or purchaseId.
|
|
574
|
+
* Walks: ShortlinkClick → ShortlinkAttribution (signup) →
|
|
575
|
+
* ResourceProgress → Purchase
|
|
576
|
+
*/
|
|
577
|
+
async function traceAttribution(opts: {
|
|
578
|
+
email?: string
|
|
579
|
+
purchaseId?: string
|
|
580
|
+
}): Promise<AttributionTrail> {
|
|
581
|
+
const events: AttributionTrailEvent[] = []
|
|
582
|
+
|
|
583
|
+
// Resolve user
|
|
584
|
+
let userId: string | null = null
|
|
585
|
+
let userEmail: string | null = opts.email ?? null
|
|
586
|
+
let userRecord: AttributionTrail['user'] = null
|
|
587
|
+
|
|
588
|
+
if (opts.purchaseId) {
|
|
589
|
+
const [purchase] = await db
|
|
590
|
+
.select({
|
|
591
|
+
userId: purchases.userId,
|
|
592
|
+
email: users.email,
|
|
593
|
+
})
|
|
594
|
+
.from(purchases)
|
|
595
|
+
.leftJoin(users, eq(purchases.userId, users.id))
|
|
596
|
+
.where(eq(purchases.id, opts.purchaseId))
|
|
597
|
+
.limit(1)
|
|
598
|
+
userId = purchase?.userId ?? null
|
|
599
|
+
userEmail = purchase?.email ?? userEmail
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (userEmail && !userId) {
|
|
603
|
+
const [u] = await db
|
|
604
|
+
.select({ id: users.id })
|
|
605
|
+
.from(users)
|
|
606
|
+
.where(eq(users.email, userEmail))
|
|
607
|
+
.limit(1)
|
|
608
|
+
userId = u?.id ?? null
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
if (userId) {
|
|
612
|
+
const [u] = await db
|
|
613
|
+
.select({
|
|
614
|
+
id: users.id,
|
|
615
|
+
email: users.email,
|
|
616
|
+
name: users.name,
|
|
617
|
+
createdAt: users.createdAt,
|
|
618
|
+
})
|
|
619
|
+
.from(users)
|
|
620
|
+
.where(eq(users.id, userId))
|
|
621
|
+
.limit(1)
|
|
622
|
+
userRecord = u
|
|
623
|
+
? {
|
|
624
|
+
id: u.id,
|
|
625
|
+
email: u.email,
|
|
626
|
+
name: u.name ?? null,
|
|
627
|
+
createdAt: u.createdAt!,
|
|
628
|
+
}
|
|
629
|
+
: null
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Find shortlink attributions for this user (by userId or email)
|
|
633
|
+
const attrConditions: any[] = []
|
|
634
|
+
if (userId) attrConditions.push(eq(shortlinkAttribution.userId, userId))
|
|
635
|
+
if (userEmail)
|
|
636
|
+
attrConditions.push(eq(shortlinkAttribution.email, userEmail))
|
|
637
|
+
|
|
638
|
+
if (attrConditions.length > 0) {
|
|
639
|
+
const attrs = await db
|
|
640
|
+
.select({
|
|
641
|
+
type: shortlinkAttribution.type,
|
|
642
|
+
createdAt: shortlinkAttribution.createdAt,
|
|
643
|
+
metadata: shortlinkAttribution.metadata,
|
|
644
|
+
shortlinkId: shortlinkAttribution.shortlinkId,
|
|
645
|
+
slug: shortlink.slug,
|
|
646
|
+
url: shortlink.url,
|
|
647
|
+
})
|
|
648
|
+
.from(shortlinkAttribution)
|
|
649
|
+
.leftJoin(shortlink, eq(shortlinkAttribution.shortlinkId, shortlink.id))
|
|
650
|
+
.where(sql`(${sql.join(attrConditions, sql` OR `)})`)
|
|
651
|
+
.orderBy(shortlinkAttribution.createdAt)
|
|
652
|
+
|
|
653
|
+
for (const attr of attrs) {
|
|
654
|
+
// Find clicks on this shortlink before the attribution event
|
|
655
|
+
const clicks = await db
|
|
656
|
+
.select({
|
|
657
|
+
timestamp: shortlinkClick.timestamp,
|
|
658
|
+
referrer: shortlinkClick.referrer,
|
|
659
|
+
country: shortlinkClick.country,
|
|
660
|
+
device: shortlinkClick.device,
|
|
661
|
+
})
|
|
662
|
+
.from(shortlinkClick)
|
|
663
|
+
.where(
|
|
664
|
+
and(
|
|
665
|
+
eq(shortlinkClick.shortlinkId, attr.shortlinkId),
|
|
666
|
+
lte(shortlinkClick.timestamp, attr.createdAt),
|
|
667
|
+
),
|
|
668
|
+
)
|
|
669
|
+
.orderBy(desc(shortlinkClick.timestamp))
|
|
670
|
+
.limit(3) // last 3 clicks before attribution
|
|
671
|
+
|
|
672
|
+
for (const click of clicks) {
|
|
673
|
+
events.push({
|
|
674
|
+
type: 'click',
|
|
675
|
+
timestamp: click.timestamp,
|
|
676
|
+
detail: {
|
|
677
|
+
shortlink: `/s/${attr.slug}`,
|
|
678
|
+
destination: attr.url,
|
|
679
|
+
referrer: click.referrer,
|
|
680
|
+
country: click.country,
|
|
681
|
+
device: click.device,
|
|
682
|
+
},
|
|
683
|
+
})
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
events.push({
|
|
687
|
+
type: attr.type === 'purchase' ? 'purchase' : 'signup',
|
|
688
|
+
timestamp: attr.createdAt,
|
|
689
|
+
detail: {
|
|
690
|
+
shortlink: `/s/${attr.slug}`,
|
|
691
|
+
metadata: attr.metadata ? JSON.parse(attr.metadata) : null,
|
|
692
|
+
},
|
|
693
|
+
})
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// Find resource progress for this user
|
|
698
|
+
if (userId) {
|
|
699
|
+
const progress = await db
|
|
700
|
+
.select({
|
|
701
|
+
resourceId: resourceProgress.resourceId,
|
|
702
|
+
completedAt: resourceProgress.completedAt,
|
|
703
|
+
createdAt: resourceProgress.createdAt,
|
|
704
|
+
})
|
|
705
|
+
.from(resourceProgress)
|
|
706
|
+
.where(eq(resourceProgress.userId, userId))
|
|
707
|
+
.orderBy(resourceProgress.createdAt)
|
|
708
|
+
.limit(20) // cap at 20 most recent
|
|
709
|
+
|
|
710
|
+
for (const p of progress) {
|
|
711
|
+
events.push({
|
|
712
|
+
type: 'progress',
|
|
713
|
+
timestamp: p.completedAt ?? p.createdAt,
|
|
714
|
+
detail: { resourceId: p.resourceId },
|
|
715
|
+
})
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// Find purchases
|
|
720
|
+
const purchaseConditions = [paidPurchase()]
|
|
721
|
+
if (opts.purchaseId) {
|
|
722
|
+
purchaseConditions.push(eq(purchases.id, opts.purchaseId))
|
|
723
|
+
} else if (userId) {
|
|
724
|
+
purchaseConditions.push(eq(purchases.userId, userId))
|
|
725
|
+
} else {
|
|
726
|
+
// No user found, return empty
|
|
727
|
+
return { user: userRecord, events: [], purchases: [] }
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const purchaseRows = await db
|
|
731
|
+
.select({
|
|
732
|
+
id: purchases.id,
|
|
733
|
+
totalAmount: purchases.totalAmount,
|
|
734
|
+
productName: products.name,
|
|
735
|
+
createdAt: purchases.createdAt,
|
|
736
|
+
country: purchases.country,
|
|
737
|
+
fields: purchases.fields,
|
|
738
|
+
})
|
|
739
|
+
.from(purchases)
|
|
740
|
+
.leftJoin(products, eq(purchases.productId, products.id))
|
|
741
|
+
.where(and(...purchaseConditions))
|
|
742
|
+
.orderBy(purchases.createdAt)
|
|
743
|
+
|
|
744
|
+
const purchaseResults = purchaseRows.map((p: any) => {
|
|
745
|
+
const fields = (p.fields as Record<string, any>) ?? {}
|
|
746
|
+
events.push({
|
|
747
|
+
type: 'purchase',
|
|
748
|
+
timestamp: p.createdAt,
|
|
749
|
+
detail: {
|
|
750
|
+
purchaseId: p.id,
|
|
751
|
+
amount: Number(p.totalAmount),
|
|
752
|
+
product: p.productName,
|
|
753
|
+
},
|
|
754
|
+
})
|
|
755
|
+
return {
|
|
756
|
+
id: p.id,
|
|
757
|
+
totalAmount: Number(p.totalAmount),
|
|
758
|
+
productName: p.productName ?? 'Unknown',
|
|
759
|
+
createdAt: p.createdAt,
|
|
760
|
+
country: p.country,
|
|
761
|
+
utmSource: fields.utmSource ?? null,
|
|
762
|
+
utmMedium: fields.utmMedium ?? null,
|
|
763
|
+
utmCampaign: fields.utmCampaign ?? null,
|
|
764
|
+
}
|
|
765
|
+
})
|
|
766
|
+
|
|
767
|
+
// Sort all events by timestamp
|
|
768
|
+
events.sort(
|
|
769
|
+
(a, b) =>
|
|
770
|
+
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
|
771
|
+
)
|
|
772
|
+
|
|
773
|
+
return { user: userRecord, events, purchases: purchaseResults }
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
return {
|
|
777
|
+
getRevenueSummary,
|
|
778
|
+
getRevenueByDay,
|
|
779
|
+
getPreviousPeriodRevenueByDay,
|
|
780
|
+
getRevenueByProduct,
|
|
781
|
+
getRevenueByCountry,
|
|
782
|
+
getRecentPurchases,
|
|
783
|
+
getAttributionSummary,
|
|
784
|
+
getShortlinkPerformance,
|
|
785
|
+
getRevenueBySource,
|
|
786
|
+
getConversionFunnel,
|
|
787
|
+
getAttributedRevenueSummary,
|
|
788
|
+
getContentPurchaseCorrelation,
|
|
789
|
+
traceAttribution,
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
export type DatabaseAnalyticsProvider = ReturnType<
|
|
794
|
+
typeof createDatabaseProvider
|
|
795
|
+
>
|