@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,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
+ >