@coursebuilder/analytics 1.1.0 → 1.1.2-canary.0.0c9beb2ae
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/dist/api/index.d.ts +22 -2
- package/dist/api/index.js +40 -5
- package/dist/api/index.js.map +1 -1
- package/dist/catalog.d.ts +1 -1
- package/dist/catalog.js +43 -1
- package/dist/catalog.js.map +1 -1
- package/dist/components/index.d.ts +46 -1
- package/dist/components/index.js +200 -8
- package/dist/components/index.js.map +1 -1
- package/dist/engine.js +94 -6
- package/dist/engine.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +94 -6
- package/dist/index.js.map +1 -1
- package/dist/providers/database.d.ts +174 -2
- package/dist/providers/database.js +711 -20
- package/dist/providers/database.js.map +1 -1
- package/dist/providers/index.js +713 -22
- package/dist/providers/index.js.map +1 -1
- package/dist/providers/survey.d.ts +1 -1
- package/dist/providers/survey.js +2 -2
- package/dist/providers/survey.js.map +1 -1
- package/dist/types.d.ts +189 -3
- package/package.json +7 -5
- package/src/api/catalog-handler.ts +44 -2
- package/src/api/token-handler.ts +3 -2
- package/src/catalog.ts +49 -1
- package/src/components/omnibus-dashboard.tsx +340 -6
- package/src/engine.ts +69 -8
- package/src/providers/attribution-recovery.test.ts +63 -0
- package/src/providers/attribution-recovery.ts +97 -0
- package/src/providers/database.ts +880 -42
- package/src/providers/survey.ts +3 -1
- package/src/types.ts +215 -2
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
sum,
|
|
12
12
|
} from 'drizzle-orm'
|
|
13
13
|
|
|
14
|
+
import { SHORTLINK_RECOVERY_LANE } from './attribution-recovery'
|
|
15
|
+
|
|
14
16
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
15
17
|
|
|
16
18
|
export type AnalyticsTimeRange = '24h' | '7d' | '30d' | '90d' | 'all'
|
|
@@ -52,6 +54,9 @@ export interface DatabaseAnalyticsSchema {
|
|
|
52
54
|
shortlink: any
|
|
53
55
|
shortlinkAttribution: any
|
|
54
56
|
shortlinkClick: any
|
|
57
|
+
questionResponse?: any
|
|
58
|
+
contactEvent?: any
|
|
59
|
+
sideEffectIntent?: any
|
|
55
60
|
}
|
|
56
61
|
|
|
57
62
|
// ─── Factory ─────────────────────────────────────────────────────────────────
|
|
@@ -76,16 +81,97 @@ export function createDatabaseProvider(
|
|
|
76
81
|
shortlink,
|
|
77
82
|
shortlinkAttribution,
|
|
78
83
|
shortlinkClick,
|
|
84
|
+
questionResponse,
|
|
85
|
+
contactEvent,
|
|
86
|
+
sideEffectIntent,
|
|
79
87
|
} = schema
|
|
80
88
|
|
|
81
89
|
// ─── Internal helpers ───────────────────────────────────────────────────
|
|
82
90
|
|
|
83
91
|
const PAID_STATUSES = ['Valid', 'Restricted'] as const
|
|
84
92
|
|
|
85
|
-
function
|
|
93
|
+
function commerceRecord() {
|
|
86
94
|
return inArray(purchases.status, [...PAID_STATUSES])
|
|
87
95
|
}
|
|
88
96
|
|
|
97
|
+
function paidPurchase() {
|
|
98
|
+
return and(commerceRecord(), gt(purchases.totalAmount, 0))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function accessGrant() {
|
|
102
|
+
return and(commerceRecord(), sql`${purchases.totalAmount} = 0`)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function backfillFreeUpgrade() {
|
|
106
|
+
return and(
|
|
107
|
+
accessGrant(),
|
|
108
|
+
sql`JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.backfill')) = 'true'`,
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const syntheticSignalSql = sql`(
|
|
113
|
+
JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.attribution.synthetic')) = 'true'
|
|
114
|
+
OR JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.synthetic')) = 'true'
|
|
115
|
+
OR JSON_SEARCH(JSON_EXTRACT(${purchases.fields}, '$.attribution.clickIds'), 'one', 'TEST_%') IS NOT NULL
|
|
116
|
+
)`
|
|
117
|
+
|
|
118
|
+
function syntheticPurchase() {
|
|
119
|
+
return and(commerceRecord(), syntheticSignalSql)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const purchaseFieldAttributionSignalSql = sql`(
|
|
123
|
+
JSON_EXTRACT(${purchases.fields}, '$.attribution') IS NOT NULL
|
|
124
|
+
OR JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.utmSource')) IS NOT NULL
|
|
125
|
+
OR JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.gaClientId')) IS NOT NULL
|
|
126
|
+
)`
|
|
127
|
+
|
|
128
|
+
const exactShortlinkPurchaseAttributionSignalSql = sql`EXISTS (
|
|
129
|
+
SELECT 1
|
|
130
|
+
FROM ${shortlinkAttribution}
|
|
131
|
+
WHERE ${shortlinkAttribution.type} = 'purchase'
|
|
132
|
+
AND JSON_VALID(${shortlinkAttribution.metadata})
|
|
133
|
+
AND JSON_UNQUOTE(JSON_EXTRACT(${shortlinkAttribution.metadata}, '$.purchaseId')) = ${purchases.id}
|
|
134
|
+
)`
|
|
135
|
+
|
|
136
|
+
const shortlinkRecoveredAttributionSignalSql = sql`(
|
|
137
|
+
NOT ${purchaseFieldAttributionSignalSql}
|
|
138
|
+
AND ${exactShortlinkPurchaseAttributionSignalSql}
|
|
139
|
+
)`
|
|
140
|
+
|
|
141
|
+
const attributionSignalSql = sql`(
|
|
142
|
+
${purchaseFieldAttributionSignalSql}
|
|
143
|
+
OR ${exactShortlinkPurchaseAttributionSignalSql}
|
|
144
|
+
)`
|
|
145
|
+
|
|
146
|
+
const strongAttributionQualitySql = sql`(
|
|
147
|
+
JSON_EXTRACT(${purchases.fields}, '$.attribution.clickIds') IS NOT NULL
|
|
148
|
+
OR JSON_EXTRACT(${purchases.fields}, '$.attribution.utm') IS NOT NULL
|
|
149
|
+
OR JSON_EXTRACT(${purchases.fields}, '$.attribution.shortlink') IS NOT NULL
|
|
150
|
+
OR JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.utmSource')) IS NOT NULL
|
|
151
|
+
OR JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.shortlinkRef')) IS NOT NULL
|
|
152
|
+
)`
|
|
153
|
+
|
|
154
|
+
const mediumAttributionQualitySql = sql`(
|
|
155
|
+
NOT ${strongAttributionQualitySql}
|
|
156
|
+
AND ${exactShortlinkPurchaseAttributionSignalSql}
|
|
157
|
+
)`
|
|
158
|
+
|
|
159
|
+
const weakAttributionQualitySql = sql`(
|
|
160
|
+
NOT ${strongAttributionQualitySql}
|
|
161
|
+
AND NOT ${exactShortlinkPurchaseAttributionSignalSql}
|
|
162
|
+
AND (
|
|
163
|
+
JSON_EXTRACT(${purchases.fields}, '$.attribution.ga.clientId') IS NOT NULL
|
|
164
|
+
OR JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.gaClientId')) IS NOT NULL
|
|
165
|
+
OR JSON_EXTRACT(${purchases.fields}, '$.attribution.selfReportedSource') IS NOT NULL
|
|
166
|
+
)
|
|
167
|
+
)`
|
|
168
|
+
|
|
169
|
+
const unknownAttributionQualitySql = sql`(
|
|
170
|
+
NOT ${strongAttributionQualitySql}
|
|
171
|
+
AND NOT ${mediumAttributionQualitySql}
|
|
172
|
+
AND NOT ${weakAttributionQualitySql}
|
|
173
|
+
)`
|
|
174
|
+
|
|
89
175
|
function rangeToDate(range: AnalyticsTimeRange): Date | null {
|
|
90
176
|
if (range === 'all') return null
|
|
91
177
|
const now = new Date()
|
|
@@ -403,38 +489,88 @@ export function createDatabaseProvider(
|
|
|
403
489
|
|
|
404
490
|
// ─── Revenue Attribution ─────────────────────────────────────────────────
|
|
405
491
|
|
|
406
|
-
async function getRevenueBySource(
|
|
492
|
+
async function getRevenueBySource(
|
|
493
|
+
range: AnalyticsTimeRange = '30d',
|
|
494
|
+
filters: { productId?: string } = {},
|
|
495
|
+
) {
|
|
407
496
|
const since = rangeToDate(range)
|
|
408
|
-
const conditions = [
|
|
497
|
+
const conditions = [commerceRecord()]
|
|
409
498
|
if (since) conditions.push(gte(purchases.createdAt, since))
|
|
499
|
+
if (filters.productId)
|
|
500
|
+
conditions.push(eq(purchases.productId, filters.productId))
|
|
501
|
+
|
|
502
|
+
const kindExpr = sql<string>`CASE
|
|
503
|
+
WHEN ${syntheticSignalSql} THEN 'synthetic'
|
|
504
|
+
WHEN ${purchases.totalAmount} = 0 THEN 'access_grant'
|
|
505
|
+
WHEN ${purchases.totalAmount} > 0 THEN 'paid_conversion'
|
|
506
|
+
ELSE 'unknown'
|
|
507
|
+
END`
|
|
508
|
+
const sourceExpr = sql<string>`CASE
|
|
509
|
+
WHEN JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.backfill')) = 'true'
|
|
510
|
+
THEN 'internal'
|
|
511
|
+
WHEN ${shortlinkRecoveredAttributionSignalSql} THEN ${SHORTLINK_RECOVERY_LANE}
|
|
512
|
+
ELSE COALESCE(
|
|
513
|
+
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.attribution.source')), 'null'),
|
|
514
|
+
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.attribution.utm.source')), 'null'),
|
|
515
|
+
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.utmSource')), 'null'),
|
|
516
|
+
CASE
|
|
517
|
+
WHEN JSON_EXTRACT(${purchases.fields}, '$.attribution.shortlink.slug') IS NOT NULL THEN 'shortlink'
|
|
518
|
+
WHEN JSON_EXTRACT(${purchases.fields}, '$.attribution.selfReportedSource') IS NOT NULL THEN 'self_reported'
|
|
519
|
+
WHEN JSON_EXTRACT(${purchases.fields}, '$.attribution.ga.clientId') IS NOT NULL THEN 'ga4'
|
|
520
|
+
WHEN JSON_EXTRACT(${purchases.fields}, '$.gaClientId') IS NOT NULL THEN 'ga4'
|
|
521
|
+
ELSE NULL
|
|
522
|
+
END
|
|
523
|
+
)
|
|
524
|
+
END`
|
|
525
|
+
const mediumExpr = sql<string>`CASE
|
|
526
|
+
WHEN JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.backfill')) = 'true'
|
|
527
|
+
THEN 'free_upgrade'
|
|
528
|
+
WHEN ${shortlinkRecoveredAttributionSignalSql} THEN 'shortlink'
|
|
529
|
+
ELSE COALESCE(
|
|
530
|
+
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.attribution.medium')), 'null'),
|
|
531
|
+
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.attribution.utm.medium')), 'null'),
|
|
532
|
+
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.utmMedium')), 'null'),
|
|
533
|
+
CASE
|
|
534
|
+
WHEN JSON_EXTRACT(${purchases.fields}, '$.attribution.shortlink.slug') IS NOT NULL THEN 'shortlink'
|
|
535
|
+
WHEN JSON_EXTRACT(${purchases.fields}, '$.attribution.selfReportedSource') IS NOT NULL THEN 'checkout_survey'
|
|
536
|
+
WHEN JSON_EXTRACT(${purchases.fields}, '$.attribution.ga.clientId') IS NOT NULL THEN 'client_id'
|
|
537
|
+
WHEN JSON_EXTRACT(${purchases.fields}, '$.gaClientId') IS NOT NULL THEN 'client_id'
|
|
538
|
+
ELSE NULL
|
|
539
|
+
END
|
|
540
|
+
)
|
|
541
|
+
END`
|
|
542
|
+
const campaignExpr = sql<string>`CASE
|
|
543
|
+
WHEN JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.backfill')) = 'true'
|
|
544
|
+
THEN COALESCE(
|
|
545
|
+
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.migrationBatchId')), 'null'),
|
|
546
|
+
'free_upgrade'
|
|
547
|
+
)
|
|
548
|
+
WHEN ${shortlinkRecoveredAttributionSignalSql} THEN NULL
|
|
549
|
+
ELSE COALESCE(
|
|
550
|
+
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.attribution.campaign')), 'null'),
|
|
551
|
+
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.attribution.utm.campaign')), 'null'),
|
|
552
|
+
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.utmCampaign')), 'null'),
|
|
553
|
+
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.attribution.shortlink.slug')), 'null'),
|
|
554
|
+
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.attribution.selfReportedSource')), 'null')
|
|
555
|
+
)
|
|
556
|
+
END`
|
|
410
557
|
|
|
411
558
|
const rows = await db
|
|
412
559
|
.select({
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
),
|
|
560
|
+
kind: kindExpr.as('kind'),
|
|
561
|
+
source: sourceExpr.as('source'),
|
|
562
|
+
medium: mediumExpr.as('medium'),
|
|
563
|
+
campaign: campaignExpr.as('campaign'),
|
|
425
564
|
revenue: sum(purchases.totalAmount),
|
|
426
565
|
count: count(),
|
|
427
566
|
})
|
|
428
567
|
.from(purchases)
|
|
429
568
|
.where(and(...conditions))
|
|
430
|
-
.groupBy(
|
|
431
|
-
|
|
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)))
|
|
569
|
+
.groupBy(sql`1`, sql`2`, sql`3`, sql`4`)
|
|
570
|
+
.orderBy(desc(sum(purchases.totalAmount)), desc(count()))
|
|
436
571
|
|
|
437
572
|
return rows.map((r: any) => ({
|
|
573
|
+
kind: r.kind ?? 'unknown',
|
|
438
574
|
source: r.source ?? null,
|
|
439
575
|
medium: r.medium ?? null,
|
|
440
576
|
campaign: r.campaign ?? null,
|
|
@@ -463,15 +599,7 @@ export function createDatabaseProvider(
|
|
|
463
599
|
const [attributedCount] = await db
|
|
464
600
|
.select({ total: count() })
|
|
465
601
|
.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
|
-
)
|
|
602
|
+
.where(and(...purchaseConditions, attributionSignalSql))
|
|
475
603
|
|
|
476
604
|
const totalSignups = userCount?.total ?? 0
|
|
477
605
|
const totalPurchases = purchaseCount?.total ?? 0
|
|
@@ -487,12 +615,76 @@ export function createDatabaseProvider(
|
|
|
487
615
|
}
|
|
488
616
|
}
|
|
489
617
|
|
|
618
|
+
async function getCommerceLaneSummary(
|
|
619
|
+
range: AnalyticsTimeRange = '30d',
|
|
620
|
+
filters: { productId?: string } = {},
|
|
621
|
+
) {
|
|
622
|
+
const since = rangeToDate(range)
|
|
623
|
+
const conditions = [commerceRecord()]
|
|
624
|
+
if (since) conditions.push(gte(purchases.createdAt, since))
|
|
625
|
+
if (filters.productId)
|
|
626
|
+
conditions.push(eq(purchases.productId, filters.productId))
|
|
627
|
+
|
|
628
|
+
const [commerce] = await db
|
|
629
|
+
.select({ count: count() })
|
|
630
|
+
.from(purchases)
|
|
631
|
+
.where(and(...conditions))
|
|
632
|
+
const [paid] = await db
|
|
633
|
+
.select({ count: count(), revenue: sum(purchases.totalAmount) })
|
|
634
|
+
.from(purchases)
|
|
635
|
+
.where(and(...conditions, paidPurchase()))
|
|
636
|
+
const [grants] = await db
|
|
637
|
+
.select({ count: count(), revenue: sum(purchases.totalAmount) })
|
|
638
|
+
.from(purchases)
|
|
639
|
+
.where(and(...conditions, accessGrant()))
|
|
640
|
+
const [freeUpgrades] = await db
|
|
641
|
+
.select({ count: count() })
|
|
642
|
+
.from(purchases)
|
|
643
|
+
.where(and(...conditions, backfillFreeUpgrade()))
|
|
644
|
+
const [synthetic] = await db
|
|
645
|
+
.select({ count: count() })
|
|
646
|
+
.from(purchases)
|
|
647
|
+
.where(and(...conditions, syntheticPurchase()))
|
|
648
|
+
const reasonRows = await db
|
|
649
|
+
.select({
|
|
650
|
+
reason: sql<string>`COALESCE(
|
|
651
|
+
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.backfillReason')), 'null'),
|
|
652
|
+
'access_grant'
|
|
653
|
+
)`.as('reason'),
|
|
654
|
+
count: count(),
|
|
655
|
+
})
|
|
656
|
+
.from(purchases)
|
|
657
|
+
.where(and(...conditions, accessGrant()))
|
|
658
|
+
.groupBy(
|
|
659
|
+
sql`COALESCE(
|
|
660
|
+
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.backfillReason')), 'null'),
|
|
661
|
+
'access_grant'
|
|
662
|
+
)`,
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
return {
|
|
666
|
+
commerceRecords: commerce?.count ?? 0,
|
|
667
|
+
paidPurchases: paid?.count ?? 0,
|
|
668
|
+
paidRevenue: Number(paid?.revenue ?? 0),
|
|
669
|
+
accessGrants: grants?.count ?? 0,
|
|
670
|
+
accessGrantRevenue: Number(grants?.revenue ?? 0),
|
|
671
|
+
freeUpgrades: freeUpgrades?.count ?? 0,
|
|
672
|
+
syntheticPurchases: synthetic?.count ?? 0,
|
|
673
|
+
byAccessGrantReason: Object.fromEntries(
|
|
674
|
+
reasonRows.map((row: any) => [row.reason, row.count]),
|
|
675
|
+
),
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
490
679
|
async function getAttributedRevenueSummary(
|
|
491
680
|
range: AnalyticsTimeRange = '30d',
|
|
681
|
+
filters: { productId?: string } = {},
|
|
492
682
|
) {
|
|
493
683
|
const since = rangeToDate(range)
|
|
494
684
|
const conditions = [paidPurchase()]
|
|
495
685
|
if (since) conditions.push(gte(purchases.createdAt, since))
|
|
686
|
+
if (filters.productId)
|
|
687
|
+
conditions.push(eq(purchases.productId, filters.productId))
|
|
496
688
|
|
|
497
689
|
const [totals] = await db
|
|
498
690
|
.select({ total: sum(purchases.totalAmount), count: count() })
|
|
@@ -500,29 +692,146 @@ export function createDatabaseProvider(
|
|
|
500
692
|
.where(and(...conditions))
|
|
501
693
|
|
|
502
694
|
const [attributed] = await db
|
|
503
|
-
.select({ total: sum(purchases.totalAmount) })
|
|
695
|
+
.select({ total: sum(purchases.totalAmount), count: count() })
|
|
504
696
|
.from(purchases)
|
|
505
|
-
.where(
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
)
|
|
697
|
+
.where(and(...conditions, attributionSignalSql))
|
|
698
|
+
|
|
699
|
+
const [purchaseFieldAttributed] = await db
|
|
700
|
+
.select({ total: sum(purchases.totalAmount), count: count() })
|
|
701
|
+
.from(purchases)
|
|
702
|
+
.where(and(...conditions, purchaseFieldAttributionSignalSql))
|
|
703
|
+
|
|
704
|
+
const [recoveredFromShortlinkAttributionTable] = await db
|
|
705
|
+
.select({ total: sum(purchases.totalAmount), count: count() })
|
|
706
|
+
.from(purchases)
|
|
707
|
+
.where(and(...conditions, shortlinkRecoveredAttributionSignalSql))
|
|
514
708
|
|
|
709
|
+
const signalConditions = since
|
|
710
|
+
? [commerceRecord(), gte(purchases.createdAt, since)]
|
|
711
|
+
: [commerceRecord()]
|
|
712
|
+
if (filters.productId) {
|
|
713
|
+
signalConditions.push(eq(purchases.productId, filters.productId))
|
|
714
|
+
}
|
|
715
|
+
const signalCount = async (condition: any) => {
|
|
716
|
+
const [row] = await db
|
|
717
|
+
.select({ count: count() })
|
|
718
|
+
.from(purchases)
|
|
719
|
+
.where(and(...signalConditions, condition))
|
|
720
|
+
return row?.count ?? 0
|
|
721
|
+
}
|
|
722
|
+
const qualityLane = async (
|
|
723
|
+
lane: 'strong' | 'medium' | 'weak' | 'unknown',
|
|
724
|
+
condition: any,
|
|
725
|
+
notes: string[],
|
|
726
|
+
) => {
|
|
727
|
+
const [row] = await db
|
|
728
|
+
.select({ total: sum(purchases.totalAmount), count: count() })
|
|
729
|
+
.from(purchases)
|
|
730
|
+
.where(and(...conditions, condition))
|
|
731
|
+
const revenue = Number(row?.total ?? 0)
|
|
732
|
+
return {
|
|
733
|
+
lane,
|
|
734
|
+
purchases: row?.count ?? 0,
|
|
735
|
+
revenue,
|
|
736
|
+
revenuePercent: totalRevenue > 0 ? revenue / totalRevenue : 0,
|
|
737
|
+
notes,
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
const [
|
|
741
|
+
shortlinkCount,
|
|
742
|
+
utmCount,
|
|
743
|
+
clickIdCount,
|
|
744
|
+
gaClientIdCount,
|
|
745
|
+
selfReportedSourceCount,
|
|
746
|
+
recoveredFromShortlinkAttributionTableCount,
|
|
747
|
+
] = await Promise.all([
|
|
748
|
+
signalCount(
|
|
749
|
+
sql`(
|
|
750
|
+
JSON_EXTRACT(${purchases.fields}, '$.attribution.shortlink.slug') IS NOT NULL
|
|
751
|
+
OR JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.shortlinkRef')) IS NOT NULL
|
|
752
|
+
)`,
|
|
753
|
+
),
|
|
754
|
+
signalCount(
|
|
755
|
+
sql`(
|
|
756
|
+
JSON_EXTRACT(${purchases.fields}, '$.attribution.utm') IS NOT NULL
|
|
757
|
+
OR JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.utmSource')) IS NOT NULL
|
|
758
|
+
)`,
|
|
759
|
+
),
|
|
760
|
+
signalCount(
|
|
761
|
+
sql`JSON_EXTRACT(${purchases.fields}, '$.attribution.clickIds') IS NOT NULL`,
|
|
762
|
+
),
|
|
763
|
+
signalCount(
|
|
764
|
+
sql`(
|
|
765
|
+
JSON_EXTRACT(${purchases.fields}, '$.attribution.ga.clientId') IS NOT NULL
|
|
766
|
+
OR JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.gaClientId')) IS NOT NULL
|
|
767
|
+
)`,
|
|
768
|
+
),
|
|
769
|
+
signalCount(
|
|
770
|
+
sql`JSON_EXTRACT(${purchases.fields}, '$.attribution.selfReportedSource') IS NOT NULL`,
|
|
771
|
+
),
|
|
772
|
+
signalCount(shortlinkRecoveredAttributionSignalSql),
|
|
773
|
+
])
|
|
774
|
+
|
|
775
|
+
const commerce = await getCommerceLaneSummary(range, filters)
|
|
515
776
|
const totalRevenue = Number(totals?.total ?? 0)
|
|
516
777
|
const attributedRevenue = Number(attributed?.total ?? 0)
|
|
778
|
+
const purchaseFieldAttributedRevenue = Number(
|
|
779
|
+
purchaseFieldAttributed?.total ?? 0,
|
|
780
|
+
)
|
|
781
|
+
const recoveredFromShortlinkAttributionTableRevenue = Number(
|
|
782
|
+
recoveredFromShortlinkAttributionTable?.total ?? 0,
|
|
783
|
+
)
|
|
517
784
|
const unattributedRevenue = totalRevenue - attributedRevenue
|
|
518
|
-
const
|
|
785
|
+
const paidPurchases = totals?.count ?? 0
|
|
786
|
+
const [strongQuality, mediumQuality, weakQuality, unknownQuality] =
|
|
787
|
+
await Promise.all([
|
|
788
|
+
qualityLane('strong', strongAttributionQualitySql, [
|
|
789
|
+
'Purchase-field UTM, shortlink, or paid click evidence.',
|
|
790
|
+
]),
|
|
791
|
+
qualityLane('medium', mediumAttributionQualitySql, [
|
|
792
|
+
'Recovered from exact shortlink attribution table purchase match.',
|
|
793
|
+
]),
|
|
794
|
+
qualityLane('weak', weakAttributionQualitySql, [
|
|
795
|
+
'GA client ID or self-reported source without stronger campaign evidence.',
|
|
796
|
+
]),
|
|
797
|
+
qualityLane('unknown', unknownAttributionQualitySql, [
|
|
798
|
+
'No usable attribution evidence for paid revenue.',
|
|
799
|
+
]),
|
|
800
|
+
])
|
|
519
801
|
|
|
520
802
|
return {
|
|
521
803
|
totalRevenue,
|
|
522
804
|
attributedRevenue,
|
|
523
805
|
unattributedRevenue,
|
|
524
806
|
attributionRate: totalRevenue > 0 ? attributedRevenue / totalRevenue : 0,
|
|
525
|
-
totalPurchases,
|
|
807
|
+
totalPurchases: commerce.commerceRecords,
|
|
808
|
+
paidPurchases,
|
|
809
|
+
purchaseFieldAttributedPurchases: purchaseFieldAttributed?.count ?? 0,
|
|
810
|
+
purchaseFieldAttributedRevenue,
|
|
811
|
+
recoveredFromShortlinkAttributionTablePurchases:
|
|
812
|
+
recoveredFromShortlinkAttributionTable?.count ?? 0,
|
|
813
|
+
recoveredFromShortlinkAttributionTableRevenue,
|
|
814
|
+
commerceRecords: commerce.commerceRecords,
|
|
815
|
+
accessGrants: commerce.accessGrants,
|
|
816
|
+
freeUpgrades: commerce.freeUpgrades,
|
|
817
|
+
syntheticPurchases: commerce.syntheticPurchases,
|
|
818
|
+
commerce,
|
|
819
|
+
signals: {
|
|
820
|
+
shortlink: shortlinkCount,
|
|
821
|
+
utm: utmCount,
|
|
822
|
+
paidClickId: clickIdCount,
|
|
823
|
+
gaClientId: gaClientIdCount,
|
|
824
|
+
selfReportedSource: selfReportedSourceCount,
|
|
825
|
+
recoveredFromShortlinkAttributionTable:
|
|
826
|
+
recoveredFromShortlinkAttributionTableCount,
|
|
827
|
+
internalFreeUpgrade: commerce.freeUpgrades,
|
|
828
|
+
},
|
|
829
|
+
quality: {
|
|
830
|
+
strong: strongQuality,
|
|
831
|
+
medium: mediumQuality,
|
|
832
|
+
weak: weakQuality,
|
|
833
|
+
unknown: unknownQuality,
|
|
834
|
+
},
|
|
526
835
|
}
|
|
527
836
|
}
|
|
528
837
|
|
|
@@ -567,6 +876,363 @@ export function createDatabaseProvider(
|
|
|
567
876
|
}))
|
|
568
877
|
}
|
|
569
878
|
|
|
879
|
+
/**
|
|
880
|
+
* Parses an unknown JSON-like value into a plain record.
|
|
881
|
+
*
|
|
882
|
+
* @param value Unknown input value from a JSON database column.
|
|
883
|
+
* @returns A Record<string, any> when the value is a record, otherwise an empty object.
|
|
884
|
+
*/
|
|
885
|
+
function parseJsonRecord(value: unknown): Record<string, any> {
|
|
886
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
887
|
+
? (value as Record<string, any>)
|
|
888
|
+
: {}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Checks whether a record contains at least one non-empty string value.
|
|
893
|
+
*
|
|
894
|
+
* @param value Unknown input value to inspect.
|
|
895
|
+
* @returns True when at least one record value is a non-empty string.
|
|
896
|
+
*/
|
|
897
|
+
function hasRecordValue(value: unknown) {
|
|
898
|
+
return (
|
|
899
|
+
Boolean(value) &&
|
|
900
|
+
typeof value === 'object' &&
|
|
901
|
+
!Array.isArray(value) &&
|
|
902
|
+
Object.values(value as Record<string, unknown>).some(
|
|
903
|
+
(entry) => typeof entry === 'string' && entry.length > 0,
|
|
904
|
+
)
|
|
905
|
+
)
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* Checks whether a click ID record contains a synthetic TEST_ value.
|
|
910
|
+
*
|
|
911
|
+
* @param value Unknown input value to inspect.
|
|
912
|
+
* @returns True when any record value is a string starting with TEST_.
|
|
913
|
+
*/
|
|
914
|
+
function hasSyntheticClickId(value: unknown) {
|
|
915
|
+
return (
|
|
916
|
+
Boolean(value) &&
|
|
917
|
+
typeof value === 'object' &&
|
|
918
|
+
!Array.isArray(value) &&
|
|
919
|
+
Object.values(value as Record<string, unknown>).some(
|
|
920
|
+
(entry) => typeof entry === 'string' && entry.startsWith('TEST_'),
|
|
921
|
+
)
|
|
922
|
+
)
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
async function getCheckoutAttributionReceipt(opts: { purchaseId?: string }) {
|
|
926
|
+
if (!opts.purchaseId) {
|
|
927
|
+
return {
|
|
928
|
+
purchase: null,
|
|
929
|
+
checks: {
|
|
930
|
+
purchaseFound: false,
|
|
931
|
+
attributionSnapshot: false,
|
|
932
|
+
utm: false,
|
|
933
|
+
actualUtm: false,
|
|
934
|
+
clickId: false,
|
|
935
|
+
synthetic: false,
|
|
936
|
+
shortlink: false,
|
|
937
|
+
selfReportedSource: false,
|
|
938
|
+
gaClientId: false,
|
|
939
|
+
},
|
|
940
|
+
attribution: null,
|
|
941
|
+
legacy: {},
|
|
942
|
+
fieldKeys: [],
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const [row] = await db
|
|
947
|
+
.select({
|
|
948
|
+
id: purchases.id,
|
|
949
|
+
createdAt: purchases.createdAt,
|
|
950
|
+
productId: purchases.productId,
|
|
951
|
+
productName: products.name,
|
|
952
|
+
totalAmount: purchases.totalAmount,
|
|
953
|
+
status: purchases.status,
|
|
954
|
+
country: purchases.country,
|
|
955
|
+
fields: purchases.fields,
|
|
956
|
+
})
|
|
957
|
+
.from(purchases)
|
|
958
|
+
.leftJoin(products, eq(purchases.productId, products.id))
|
|
959
|
+
.where(eq(purchases.id, opts.purchaseId))
|
|
960
|
+
.limit(1)
|
|
961
|
+
|
|
962
|
+
if (!row) {
|
|
963
|
+
return {
|
|
964
|
+
purchase: null,
|
|
965
|
+
checks: {
|
|
966
|
+
purchaseFound: false,
|
|
967
|
+
attributionSnapshot: false,
|
|
968
|
+
utm: false,
|
|
969
|
+
actualUtm: false,
|
|
970
|
+
clickId: false,
|
|
971
|
+
synthetic: false,
|
|
972
|
+
shortlink: false,
|
|
973
|
+
selfReportedSource: false,
|
|
974
|
+
gaClientId: false,
|
|
975
|
+
},
|
|
976
|
+
attribution: null,
|
|
977
|
+
legacy: {},
|
|
978
|
+
fieldKeys: [],
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
const fields = parseJsonRecord(row.fields)
|
|
983
|
+
const attribution = parseJsonRecord(fields.attribution)
|
|
984
|
+
const utm = parseJsonRecord(attribution.utm)
|
|
985
|
+
const clickIds = parseJsonRecord(attribution.clickIds)
|
|
986
|
+
const shortlinkSnapshot = parseJsonRecord(attribution.shortlink)
|
|
987
|
+
const ga = parseJsonRecord(attribution.ga)
|
|
988
|
+
const legacy = Object.fromEntries(
|
|
989
|
+
[
|
|
990
|
+
'utmSource',
|
|
991
|
+
'utmMedium',
|
|
992
|
+
'utmCampaign',
|
|
993
|
+
'gaClientId',
|
|
994
|
+
'shortlinkRef',
|
|
995
|
+
'landingPath',
|
|
996
|
+
'referrer',
|
|
997
|
+
'ltUtmSource',
|
|
998
|
+
'ltUtmMedium',
|
|
999
|
+
'ltUtmCampaign',
|
|
1000
|
+
]
|
|
1001
|
+
.map((key) => [key, fields[key]])
|
|
1002
|
+
.filter(([, value]) => value !== undefined && value !== null),
|
|
1003
|
+
)
|
|
1004
|
+
const hasActualUtm = Boolean(
|
|
1005
|
+
utm.source || utm.medium || utm.campaign || fields.utmSource,
|
|
1006
|
+
)
|
|
1007
|
+
const synthetic = Boolean(
|
|
1008
|
+
attribution.synthetic ||
|
|
1009
|
+
fields.synthetic ||
|
|
1010
|
+
hasSyntheticClickId(clickIds),
|
|
1011
|
+
)
|
|
1012
|
+
|
|
1013
|
+
return {
|
|
1014
|
+
purchase: {
|
|
1015
|
+
id: row.id,
|
|
1016
|
+
createdAt: row.createdAt,
|
|
1017
|
+
productId: row.productId,
|
|
1018
|
+
productName: row.productName ?? null,
|
|
1019
|
+
totalAmount: Number(row.totalAmount ?? 0),
|
|
1020
|
+
status: row.status,
|
|
1021
|
+
country: row.country,
|
|
1022
|
+
},
|
|
1023
|
+
checks: {
|
|
1024
|
+
purchaseFound: true,
|
|
1025
|
+
attributionSnapshot: Object.keys(attribution).length > 0,
|
|
1026
|
+
utm: hasActualUtm,
|
|
1027
|
+
actualUtm: hasActualUtm,
|
|
1028
|
+
clickId: hasRecordValue(clickIds),
|
|
1029
|
+
synthetic,
|
|
1030
|
+
shortlink: Boolean(shortlinkSnapshot.slug || fields.shortlinkRef),
|
|
1031
|
+
selfReportedSource: Boolean(attribution.selfReportedSource),
|
|
1032
|
+
gaClientId: Boolean(ga.clientId || fields.gaClientId),
|
|
1033
|
+
},
|
|
1034
|
+
attribution: Object.keys(attribution).length > 0 ? attribution : null,
|
|
1035
|
+
legacy,
|
|
1036
|
+
fieldKeys: Object.keys(fields).sort(),
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
async function getCheckoutSurveyFallbackReport(
|
|
1041
|
+
range: AnalyticsTimeRange = '30d',
|
|
1042
|
+
limit: number = 20,
|
|
1043
|
+
filters: { productId?: string } = {},
|
|
1044
|
+
) {
|
|
1045
|
+
const label = 'recovered_from_checkout_survey_response'
|
|
1046
|
+
if (!questionResponse) {
|
|
1047
|
+
return {
|
|
1048
|
+
label,
|
|
1049
|
+
productId: filters.productId ?? null,
|
|
1050
|
+
totalDarkPurchases: 0,
|
|
1051
|
+
totalDarkRevenue: 0,
|
|
1052
|
+
exactPurchaseLinkedPurchases: 0,
|
|
1053
|
+
exactPurchaseLinkedRevenue: 0,
|
|
1054
|
+
userLinkedPurchases: 0,
|
|
1055
|
+
userLinkedRevenue: 0,
|
|
1056
|
+
byAnswer: [],
|
|
1057
|
+
notes: ['questionResponse table was not provided to this provider'],
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
const since = rangeToDate(range)
|
|
1062
|
+
const purchaseConditions = [
|
|
1063
|
+
paidPurchase(),
|
|
1064
|
+
sql`NOT ${attributionSignalSql}`,
|
|
1065
|
+
]
|
|
1066
|
+
if (since) purchaseConditions.push(gte(purchases.createdAt, since))
|
|
1067
|
+
if (filters.productId) {
|
|
1068
|
+
purchaseConditions.push(eq(purchases.productId, filters.productId))
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
const darkRows = await db
|
|
1072
|
+
.select({
|
|
1073
|
+
id: purchases.id,
|
|
1074
|
+
userId: purchases.userId,
|
|
1075
|
+
revenue: purchases.totalAmount,
|
|
1076
|
+
})
|
|
1077
|
+
.from(purchases)
|
|
1078
|
+
.where(and(...purchaseConditions))
|
|
1079
|
+
|
|
1080
|
+
const totalDarkRevenue = darkRows.reduce(
|
|
1081
|
+
(total: number, row: any) => total + Number(row.revenue ?? 0),
|
|
1082
|
+
0,
|
|
1083
|
+
)
|
|
1084
|
+
|
|
1085
|
+
if (darkRows.length === 0) {
|
|
1086
|
+
return {
|
|
1087
|
+
label,
|
|
1088
|
+
productId: filters.productId ?? null,
|
|
1089
|
+
totalDarkPurchases: 0,
|
|
1090
|
+
totalDarkRevenue: 0,
|
|
1091
|
+
exactPurchaseLinkedPurchases: 0,
|
|
1092
|
+
exactPurchaseLinkedRevenue: 0,
|
|
1093
|
+
userLinkedPurchases: 0,
|
|
1094
|
+
userLinkedRevenue: 0,
|
|
1095
|
+
byAnswer: [],
|
|
1096
|
+
notes: ['No dark paid purchases matched the filters'],
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
const darkById = new Map<string, (typeof darkRows)[number]>(
|
|
1101
|
+
darkRows.map((row: (typeof darkRows)[number]) => [row.id, row]),
|
|
1102
|
+
)
|
|
1103
|
+
const darkByUserId = new Map<string, any[]>()
|
|
1104
|
+
for (const row of darkRows) {
|
|
1105
|
+
if (!row.userId) continue
|
|
1106
|
+
const bucket = darkByUserId.get(row.userId) ?? []
|
|
1107
|
+
bucket.push(row)
|
|
1108
|
+
darkByUserId.set(row.userId, bucket)
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
const responseConditions: any[] = []
|
|
1112
|
+
if (since) responseConditions.push(gte(questionResponse.createdAt, since))
|
|
1113
|
+
const darkIds = [...darkById.keys()]
|
|
1114
|
+
const darkUserIds = [...darkByUserId.keys()]
|
|
1115
|
+
const exactPurchaseCondition = sql`JSON_UNQUOTE(JSON_EXTRACT(${questionResponse.fields}, '$.purchaseId')) IN (${sql.join(
|
|
1116
|
+
darkIds.map((id) => sql`${id}`),
|
|
1117
|
+
sql`, `,
|
|
1118
|
+
)})`
|
|
1119
|
+
const userCondition =
|
|
1120
|
+
darkUserIds.length > 0
|
|
1121
|
+
? sql`${questionResponse.userId} IN (${sql.join(
|
|
1122
|
+
darkUserIds.map((id) => sql`${id}`),
|
|
1123
|
+
sql`, `,
|
|
1124
|
+
)})`
|
|
1125
|
+
: sql`FALSE`
|
|
1126
|
+
responseConditions.push(
|
|
1127
|
+
sql`(${exactPurchaseCondition} OR ${userCondition})`,
|
|
1128
|
+
)
|
|
1129
|
+
|
|
1130
|
+
const responseRows = await db
|
|
1131
|
+
.select({
|
|
1132
|
+
purchaseId: sql<string>`JSON_UNQUOTE(JSON_EXTRACT(${questionResponse.fields}, '$.purchaseId'))`,
|
|
1133
|
+
answer: sql<string>`JSON_UNQUOTE(JSON_EXTRACT(${questionResponse.fields}, '$.answer'))`,
|
|
1134
|
+
userId: questionResponse.userId,
|
|
1135
|
+
createdAt: questionResponse.createdAt,
|
|
1136
|
+
})
|
|
1137
|
+
.from(questionResponse)
|
|
1138
|
+
.where(and(...responseConditions))
|
|
1139
|
+
|
|
1140
|
+
const exactByPurchase = new Map<string, any>()
|
|
1141
|
+
const latestByUser = new Map<string, any>()
|
|
1142
|
+
for (const row of responseRows) {
|
|
1143
|
+
if (row.purchaseId && darkById.has(row.purchaseId)) {
|
|
1144
|
+
exactByPurchase.set(row.purchaseId, row)
|
|
1145
|
+
continue
|
|
1146
|
+
}
|
|
1147
|
+
if (!row.userId) continue
|
|
1148
|
+
const current = latestByUser.get(row.userId)
|
|
1149
|
+
const rowTime = new Date(row.createdAt ?? 0).getTime()
|
|
1150
|
+
const currentTime = current
|
|
1151
|
+
? new Date(current.createdAt ?? 0).getTime()
|
|
1152
|
+
: -1
|
|
1153
|
+
if (!current || rowTime >= currentTime) latestByUser.set(row.userId, row)
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
const classified = new Map<
|
|
1157
|
+
string,
|
|
1158
|
+
{
|
|
1159
|
+
confidence: 'exact_purchase_link' | 'user_linked'
|
|
1160
|
+
answer: string
|
|
1161
|
+
revenue: number
|
|
1162
|
+
}
|
|
1163
|
+
>()
|
|
1164
|
+
for (const [purchaseId, response] of exactByPurchase) {
|
|
1165
|
+
const purchase = darkById.get(purchaseId)
|
|
1166
|
+
classified.set(purchaseId, {
|
|
1167
|
+
confidence: 'exact_purchase_link',
|
|
1168
|
+
answer: response.answer || '(no answer)',
|
|
1169
|
+
revenue: Number(purchase?.revenue ?? 0),
|
|
1170
|
+
})
|
|
1171
|
+
}
|
|
1172
|
+
for (const row of darkRows) {
|
|
1173
|
+
if (classified.has(row.id) || !row.userId) continue
|
|
1174
|
+
const response = latestByUser.get(row.userId)
|
|
1175
|
+
if (!response) continue
|
|
1176
|
+
classified.set(row.id, {
|
|
1177
|
+
confidence: 'user_linked',
|
|
1178
|
+
answer: response.answer || '(no answer)',
|
|
1179
|
+
revenue: Number(row.revenue ?? 0),
|
|
1180
|
+
})
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
const byAnswer = new Map<
|
|
1184
|
+
string,
|
|
1185
|
+
{
|
|
1186
|
+
answer: string
|
|
1187
|
+
confidence: 'exact_purchase_link' | 'user_linked'
|
|
1188
|
+
purchases: number
|
|
1189
|
+
revenue: number
|
|
1190
|
+
}
|
|
1191
|
+
>()
|
|
1192
|
+
let exactPurchaseLinkedPurchases = 0
|
|
1193
|
+
let exactPurchaseLinkedRevenue = 0
|
|
1194
|
+
let userLinkedPurchases = 0
|
|
1195
|
+
let userLinkedRevenue = 0
|
|
1196
|
+
for (const entry of classified.values()) {
|
|
1197
|
+
if (entry.confidence === 'exact_purchase_link') {
|
|
1198
|
+
exactPurchaseLinkedPurchases += 1
|
|
1199
|
+
exactPurchaseLinkedRevenue += entry.revenue
|
|
1200
|
+
} else {
|
|
1201
|
+
userLinkedPurchases += 1
|
|
1202
|
+
userLinkedRevenue += entry.revenue
|
|
1203
|
+
}
|
|
1204
|
+
const key = `${entry.confidence}:${entry.answer}`
|
|
1205
|
+
const current = byAnswer.get(key) ?? {
|
|
1206
|
+
answer: entry.answer,
|
|
1207
|
+
confidence: entry.confidence,
|
|
1208
|
+
purchases: 0,
|
|
1209
|
+
revenue: 0,
|
|
1210
|
+
}
|
|
1211
|
+
current.purchases += 1
|
|
1212
|
+
current.revenue += entry.revenue
|
|
1213
|
+
byAnswer.set(key, current)
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
return {
|
|
1217
|
+
label,
|
|
1218
|
+
productId: filters.productId ?? null,
|
|
1219
|
+
totalDarkPurchases: darkRows.length,
|
|
1220
|
+
totalDarkRevenue,
|
|
1221
|
+
exactPurchaseLinkedPurchases,
|
|
1222
|
+
exactPurchaseLinkedRevenue,
|
|
1223
|
+
userLinkedPurchases,
|
|
1224
|
+
userLinkedRevenue,
|
|
1225
|
+
byAnswer: [...byAnswer.values()]
|
|
1226
|
+
.sort((a, b) => b.revenue - a.revenue || b.purchases - a.purchases)
|
|
1227
|
+
.slice(0, limit),
|
|
1228
|
+
notes: [
|
|
1229
|
+
'Report-only fallback. No purchase fields are mutated.',
|
|
1230
|
+
'Exact purchase-linked responses use QuestionResponse.fields.purchaseId when present.',
|
|
1231
|
+
'User-linked responses are lower-confidence and remain report-only.',
|
|
1232
|
+
],
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
|
|
570
1236
|
// ─── Attribution Trail ───────────────────────────────────────────────────
|
|
571
1237
|
|
|
572
1238
|
/**
|
|
@@ -574,6 +1240,174 @@ export function createDatabaseProvider(
|
|
|
574
1240
|
* Walks: ShortlinkClick → ShortlinkAttribution (signup) →
|
|
575
1241
|
* ResourceProgress → Purchase
|
|
576
1242
|
*/
|
|
1243
|
+
|
|
1244
|
+
async function getValuePathSummary(range: AnalyticsTimeRange = '30d') {
|
|
1245
|
+
if (!contactEvent || !sideEffectIntent) {
|
|
1246
|
+
return {
|
|
1247
|
+
contacts: 0,
|
|
1248
|
+
events: 0,
|
|
1249
|
+
intents: 0,
|
|
1250
|
+
completedIntents: 0,
|
|
1251
|
+
pendingIntents: 0,
|
|
1252
|
+
blockedIntents: 0,
|
|
1253
|
+
answerEvents: 0,
|
|
1254
|
+
dripEvents: 0,
|
|
1255
|
+
enteredEvents: 0,
|
|
1256
|
+
participantsWithAnswerClicks: 0,
|
|
1257
|
+
participantsWithNoAnswerClicks: 0,
|
|
1258
|
+
terminalParticipants: 0,
|
|
1259
|
+
answerOptions: [],
|
|
1260
|
+
answerSteps: [],
|
|
1261
|
+
terminalSteps: [],
|
|
1262
|
+
notes: ['Contact Event and SideEffectIntent tables are unavailable.'],
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
const since = rangeToDate(range)
|
|
1267
|
+
const eventConditions = [sql`${contactEvent.eventType} LIKE 'value-path.%'`]
|
|
1268
|
+
const intentConditions = [
|
|
1269
|
+
eq(sideEffectIntent.type, 'send-value-path-email'),
|
|
1270
|
+
]
|
|
1271
|
+
if (since) {
|
|
1272
|
+
eventConditions.push(gte(contactEvent.occurredAt, since))
|
|
1273
|
+
intentConditions.push(gte(sideEffectIntent.createdAt, since))
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
const [events, intents] = await Promise.all([
|
|
1277
|
+
db
|
|
1278
|
+
.select({
|
|
1279
|
+
contactId: contactEvent.contactId,
|
|
1280
|
+
eventType: contactEvent.eventType,
|
|
1281
|
+
providerEventId: contactEvent.providerEventId,
|
|
1282
|
+
occurredAt: contactEvent.occurredAt,
|
|
1283
|
+
})
|
|
1284
|
+
.from(contactEvent)
|
|
1285
|
+
.where(and(...eventConditions)),
|
|
1286
|
+
db
|
|
1287
|
+
.select({
|
|
1288
|
+
contactId: sideEffectIntent.contactId,
|
|
1289
|
+
status: sideEffectIntent.status,
|
|
1290
|
+
metadata: sideEffectIntent.metadata,
|
|
1291
|
+
createdAt: sideEffectIntent.createdAt,
|
|
1292
|
+
})
|
|
1293
|
+
.from(sideEffectIntent)
|
|
1294
|
+
.where(and(...intentConditions)),
|
|
1295
|
+
])
|
|
1296
|
+
|
|
1297
|
+
const contactIds = new Set<string>()
|
|
1298
|
+
for (const event of events) contactIds.add(event.contactId)
|
|
1299
|
+
for (const intent of intents) contactIds.add(intent.contactId)
|
|
1300
|
+
|
|
1301
|
+
const answerEvents = events.filter(
|
|
1302
|
+
(event: any) => event.eventType === 'value-path.answer-selected',
|
|
1303
|
+
)
|
|
1304
|
+
const dripEvents = events.filter(
|
|
1305
|
+
(event: any) => event.eventType === 'value-path.drip-progressed',
|
|
1306
|
+
)
|
|
1307
|
+
const enteredEvents = events.filter(
|
|
1308
|
+
(event: any) => event.eventType === 'value-path.entered',
|
|
1309
|
+
)
|
|
1310
|
+
|
|
1311
|
+
const answersByContact = new Map<string, number>()
|
|
1312
|
+
for (const event of answerEvents) {
|
|
1313
|
+
answersByContact.set(
|
|
1314
|
+
event.contactId,
|
|
1315
|
+
(answersByContact.get(event.contactId) ?? 0) + 1,
|
|
1316
|
+
)
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
const answerKeyCounts = new Map<string, number>()
|
|
1320
|
+
const answerStepCounts = new Map<string, number>()
|
|
1321
|
+
for (const event of answerEvents) {
|
|
1322
|
+
const key = parseValuePathAnswerKey(event.providerEventId)
|
|
1323
|
+
answerKeyCounts.set(key, (answerKeyCounts.get(key) ?? 0) + 1)
|
|
1324
|
+
const step = answerStepFromKey(key)
|
|
1325
|
+
answerStepCounts.set(step, (answerStepCounts.get(step) ?? 0) + 1)
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
const latestIntentByContact = new Map<string, any>()
|
|
1329
|
+
for (const intent of [...intents].sort(
|
|
1330
|
+
(a: any, b: any) =>
|
|
1331
|
+
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
|
1332
|
+
)) {
|
|
1333
|
+
latestIntentByContact.set(intent.contactId, intent)
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
const terminalCounts = new Map<string, number>()
|
|
1337
|
+
for (const intent of latestIntentByContact.values()) {
|
|
1338
|
+
const emailResourceId = String(intent.metadata?.emailResourceId ?? '')
|
|
1339
|
+
if (!isTerminalValuePathEmail(emailResourceId)) continue
|
|
1340
|
+
terminalCounts.set(
|
|
1341
|
+
emailResourceId,
|
|
1342
|
+
(terminalCounts.get(emailResourceId) ?? 0) + 1,
|
|
1343
|
+
)
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
return {
|
|
1347
|
+
contacts: contactIds.size,
|
|
1348
|
+
events: events.length,
|
|
1349
|
+
intents: intents.length,
|
|
1350
|
+
completedIntents: intents.filter(
|
|
1351
|
+
(intent: any) => intent.status === 'completed',
|
|
1352
|
+
).length,
|
|
1353
|
+
pendingIntents: intents.filter(
|
|
1354
|
+
(intent: any) => intent.status === 'pending',
|
|
1355
|
+
).length,
|
|
1356
|
+
blockedIntents: intents.filter(
|
|
1357
|
+
(intent: any) => intent.status === 'blocked',
|
|
1358
|
+
).length,
|
|
1359
|
+
answerEvents: answerEvents.length,
|
|
1360
|
+
dripEvents: dripEvents.length,
|
|
1361
|
+
enteredEvents: enteredEvents.length,
|
|
1362
|
+
participantsWithAnswerClicks: answersByContact.size,
|
|
1363
|
+
participantsWithNoAnswerClicks: Math.max(
|
|
1364
|
+
0,
|
|
1365
|
+
contactIds.size - answersByContact.size,
|
|
1366
|
+
),
|
|
1367
|
+
terminalParticipants: [...terminalCounts.values()].reduce(
|
|
1368
|
+
(total, count) => total + count,
|
|
1369
|
+
0,
|
|
1370
|
+
),
|
|
1371
|
+
answerOptions: [...answerKeyCounts.entries()]
|
|
1372
|
+
.map(([key, count]) => ({
|
|
1373
|
+
key,
|
|
1374
|
+
step: answerStepFromKey(key),
|
|
1375
|
+
optionValue: answerOptionFromKey(key),
|
|
1376
|
+
count,
|
|
1377
|
+
}))
|
|
1378
|
+
.sort((a, b) => b.count - a.count),
|
|
1379
|
+
answerSteps: [...answerStepCounts.entries()]
|
|
1380
|
+
.map(([step, count]) => ({ step, count }))
|
|
1381
|
+
.sort((a, b) => b.count - a.count),
|
|
1382
|
+
terminalSteps: [...terminalCounts.entries()]
|
|
1383
|
+
.map(([emailResourceId, count]) => ({ emailResourceId, count }))
|
|
1384
|
+
.sort((a, b) => b.count - a.count),
|
|
1385
|
+
notes: [
|
|
1386
|
+
'Counts are based on Contact Events and SideEffectIntents.',
|
|
1387
|
+
'Kit-native opens, unsubscribes, complaints, and raw Kit link clicks are not included.',
|
|
1388
|
+
],
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
function parseValuePathAnswerKey(providerEventId: string | null | undefined) {
|
|
1393
|
+
const match = String(providerEventId ?? '').match(/:answer:(.+)$/)
|
|
1394
|
+
return match?.[1] ?? 'unknown'
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
function answerStepFromKey(key: string) {
|
|
1398
|
+
const index = key.lastIndexOf('.')
|
|
1399
|
+
return index > -1 ? key.slice(0, index) : key
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
function answerOptionFromKey(key: string) {
|
|
1403
|
+
const index = key.lastIndexOf('.')
|
|
1404
|
+
return index > -1 ? key.slice(index + 1) : 'unknown'
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
function isTerminalValuePathEmail(emailResourceId: string) {
|
|
1408
|
+
return /(?:^|\.)(?:email-6|team-email-6)$/.test(emailResourceId)
|
|
1409
|
+
}
|
|
1410
|
+
|
|
577
1411
|
async function traceAttribution(opts: {
|
|
578
1412
|
email?: string
|
|
579
1413
|
purchaseId?: string
|
|
@@ -784,8 +1618,12 @@ export function createDatabaseProvider(
|
|
|
784
1618
|
getShortlinkPerformance,
|
|
785
1619
|
getRevenueBySource,
|
|
786
1620
|
getConversionFunnel,
|
|
1621
|
+
getCommerceLaneSummary,
|
|
787
1622
|
getAttributedRevenueSummary,
|
|
788
1623
|
getContentPurchaseCorrelation,
|
|
1624
|
+
getCheckoutAttributionReceipt,
|
|
1625
|
+
getCheckoutSurveyFallbackReport,
|
|
1626
|
+
getValuePathSummary,
|
|
789
1627
|
traceAttribution,
|
|
790
1628
|
}
|
|
791
1629
|
}
|