@coursebuilder/analytics 1.1.0 → 1.1.1
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 +29 -0
- package/dist/components/index.js +91 -2
- 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 +144 -2
- package/dist/providers/database.js +652 -20
- package/dist/providers/database.js.map +1 -1
- package/dist/providers/index.js +654 -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 +151 -3
- package/package.json +5 -3
- 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 +163 -0
- package/src/engine.ts +66 -6
- package/src/providers/attribution-recovery.test.ts +63 -0
- package/src/providers/attribution-recovery.ts +97 -0
- package/src/providers/database.ts +812 -42
- package/src/providers/survey.ts +3 -1
- package/src/types.ts +166 -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,68 @@ 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
|
+
|
|
89
146
|
function rangeToDate(range: AnalyticsTimeRange): Date | null {
|
|
90
147
|
if (range === 'all') return null
|
|
91
148
|
const now = new Date()
|
|
@@ -403,38 +460,88 @@ export function createDatabaseProvider(
|
|
|
403
460
|
|
|
404
461
|
// ─── Revenue Attribution ─────────────────────────────────────────────────
|
|
405
462
|
|
|
406
|
-
async function getRevenueBySource(
|
|
463
|
+
async function getRevenueBySource(
|
|
464
|
+
range: AnalyticsTimeRange = '30d',
|
|
465
|
+
filters: { productId?: string } = {},
|
|
466
|
+
) {
|
|
407
467
|
const since = rangeToDate(range)
|
|
408
|
-
const conditions = [
|
|
468
|
+
const conditions = [commerceRecord()]
|
|
409
469
|
if (since) conditions.push(gte(purchases.createdAt, since))
|
|
470
|
+
if (filters.productId)
|
|
471
|
+
conditions.push(eq(purchases.productId, filters.productId))
|
|
472
|
+
|
|
473
|
+
const kindExpr = sql<string>`CASE
|
|
474
|
+
WHEN ${syntheticSignalSql} THEN 'synthetic'
|
|
475
|
+
WHEN ${purchases.totalAmount} = 0 THEN 'access_grant'
|
|
476
|
+
WHEN ${purchases.totalAmount} > 0 THEN 'paid_conversion'
|
|
477
|
+
ELSE 'unknown'
|
|
478
|
+
END`
|
|
479
|
+
const sourceExpr = sql<string>`CASE
|
|
480
|
+
WHEN JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.backfill')) = 'true'
|
|
481
|
+
THEN 'internal'
|
|
482
|
+
WHEN ${shortlinkRecoveredAttributionSignalSql} THEN ${SHORTLINK_RECOVERY_LANE}
|
|
483
|
+
ELSE COALESCE(
|
|
484
|
+
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.attribution.source')), 'null'),
|
|
485
|
+
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.attribution.utm.source')), 'null'),
|
|
486
|
+
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.utmSource')), 'null'),
|
|
487
|
+
CASE
|
|
488
|
+
WHEN JSON_EXTRACT(${purchases.fields}, '$.attribution.shortlink.slug') IS NOT NULL THEN 'shortlink'
|
|
489
|
+
WHEN JSON_EXTRACT(${purchases.fields}, '$.attribution.selfReportedSource') IS NOT NULL THEN 'self_reported'
|
|
490
|
+
WHEN JSON_EXTRACT(${purchases.fields}, '$.attribution.ga.clientId') IS NOT NULL THEN 'ga4'
|
|
491
|
+
WHEN JSON_EXTRACT(${purchases.fields}, '$.gaClientId') IS NOT NULL THEN 'ga4'
|
|
492
|
+
ELSE NULL
|
|
493
|
+
END
|
|
494
|
+
)
|
|
495
|
+
END`
|
|
496
|
+
const mediumExpr = sql<string>`CASE
|
|
497
|
+
WHEN JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.backfill')) = 'true'
|
|
498
|
+
THEN 'free_upgrade'
|
|
499
|
+
WHEN ${shortlinkRecoveredAttributionSignalSql} THEN 'shortlink'
|
|
500
|
+
ELSE COALESCE(
|
|
501
|
+
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.attribution.medium')), 'null'),
|
|
502
|
+
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.attribution.utm.medium')), 'null'),
|
|
503
|
+
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.utmMedium')), 'null'),
|
|
504
|
+
CASE
|
|
505
|
+
WHEN JSON_EXTRACT(${purchases.fields}, '$.attribution.shortlink.slug') IS NOT NULL THEN 'shortlink'
|
|
506
|
+
WHEN JSON_EXTRACT(${purchases.fields}, '$.attribution.selfReportedSource') IS NOT NULL THEN 'checkout_survey'
|
|
507
|
+
WHEN JSON_EXTRACT(${purchases.fields}, '$.attribution.ga.clientId') IS NOT NULL THEN 'client_id'
|
|
508
|
+
WHEN JSON_EXTRACT(${purchases.fields}, '$.gaClientId') IS NOT NULL THEN 'client_id'
|
|
509
|
+
ELSE NULL
|
|
510
|
+
END
|
|
511
|
+
)
|
|
512
|
+
END`
|
|
513
|
+
const campaignExpr = sql<string>`CASE
|
|
514
|
+
WHEN JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.backfill')) = 'true'
|
|
515
|
+
THEN COALESCE(
|
|
516
|
+
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.migrationBatchId')), 'null'),
|
|
517
|
+
'free_upgrade'
|
|
518
|
+
)
|
|
519
|
+
WHEN ${shortlinkRecoveredAttributionSignalSql} THEN NULL
|
|
520
|
+
ELSE COALESCE(
|
|
521
|
+
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.attribution.campaign')), 'null'),
|
|
522
|
+
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.attribution.utm.campaign')), 'null'),
|
|
523
|
+
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.utmCampaign')), 'null'),
|
|
524
|
+
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.attribution.shortlink.slug')), 'null'),
|
|
525
|
+
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.attribution.selfReportedSource')), 'null')
|
|
526
|
+
)
|
|
527
|
+
END`
|
|
410
528
|
|
|
411
529
|
const rows = await db
|
|
412
530
|
.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
|
-
),
|
|
531
|
+
kind: kindExpr.as('kind'),
|
|
532
|
+
source: sourceExpr.as('source'),
|
|
533
|
+
medium: mediumExpr.as('medium'),
|
|
534
|
+
campaign: campaignExpr.as('campaign'),
|
|
425
535
|
revenue: sum(purchases.totalAmount),
|
|
426
536
|
count: count(),
|
|
427
537
|
})
|
|
428
538
|
.from(purchases)
|
|
429
539
|
.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)))
|
|
540
|
+
.groupBy(sql`1`, sql`2`, sql`3`, sql`4`)
|
|
541
|
+
.orderBy(desc(sum(purchases.totalAmount)), desc(count()))
|
|
436
542
|
|
|
437
543
|
return rows.map((r: any) => ({
|
|
544
|
+
kind: r.kind ?? 'unknown',
|
|
438
545
|
source: r.source ?? null,
|
|
439
546
|
medium: r.medium ?? null,
|
|
440
547
|
campaign: r.campaign ?? null,
|
|
@@ -463,15 +570,7 @@ export function createDatabaseProvider(
|
|
|
463
570
|
const [attributedCount] = await db
|
|
464
571
|
.select({ total: count() })
|
|
465
572
|
.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
|
-
)
|
|
573
|
+
.where(and(...purchaseConditions, attributionSignalSql))
|
|
475
574
|
|
|
476
575
|
const totalSignups = userCount?.total ?? 0
|
|
477
576
|
const totalPurchases = purchaseCount?.total ?? 0
|
|
@@ -487,12 +586,76 @@ export function createDatabaseProvider(
|
|
|
487
586
|
}
|
|
488
587
|
}
|
|
489
588
|
|
|
589
|
+
async function getCommerceLaneSummary(
|
|
590
|
+
range: AnalyticsTimeRange = '30d',
|
|
591
|
+
filters: { productId?: string } = {},
|
|
592
|
+
) {
|
|
593
|
+
const since = rangeToDate(range)
|
|
594
|
+
const conditions = [commerceRecord()]
|
|
595
|
+
if (since) conditions.push(gte(purchases.createdAt, since))
|
|
596
|
+
if (filters.productId)
|
|
597
|
+
conditions.push(eq(purchases.productId, filters.productId))
|
|
598
|
+
|
|
599
|
+
const [commerce] = await db
|
|
600
|
+
.select({ count: count() })
|
|
601
|
+
.from(purchases)
|
|
602
|
+
.where(and(...conditions))
|
|
603
|
+
const [paid] = await db
|
|
604
|
+
.select({ count: count(), revenue: sum(purchases.totalAmount) })
|
|
605
|
+
.from(purchases)
|
|
606
|
+
.where(and(...conditions, paidPurchase()))
|
|
607
|
+
const [grants] = await db
|
|
608
|
+
.select({ count: count(), revenue: sum(purchases.totalAmount) })
|
|
609
|
+
.from(purchases)
|
|
610
|
+
.where(and(...conditions, accessGrant()))
|
|
611
|
+
const [freeUpgrades] = await db
|
|
612
|
+
.select({ count: count() })
|
|
613
|
+
.from(purchases)
|
|
614
|
+
.where(and(...conditions, backfillFreeUpgrade()))
|
|
615
|
+
const [synthetic] = await db
|
|
616
|
+
.select({ count: count() })
|
|
617
|
+
.from(purchases)
|
|
618
|
+
.where(and(...conditions, syntheticPurchase()))
|
|
619
|
+
const reasonRows = await db
|
|
620
|
+
.select({
|
|
621
|
+
reason: sql<string>`COALESCE(
|
|
622
|
+
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.backfillReason')), 'null'),
|
|
623
|
+
'access_grant'
|
|
624
|
+
)`.as('reason'),
|
|
625
|
+
count: count(),
|
|
626
|
+
})
|
|
627
|
+
.from(purchases)
|
|
628
|
+
.where(and(...conditions, accessGrant()))
|
|
629
|
+
.groupBy(
|
|
630
|
+
sql`COALESCE(
|
|
631
|
+
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.backfillReason')), 'null'),
|
|
632
|
+
'access_grant'
|
|
633
|
+
)`,
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
return {
|
|
637
|
+
commerceRecords: commerce?.count ?? 0,
|
|
638
|
+
paidPurchases: paid?.count ?? 0,
|
|
639
|
+
paidRevenue: Number(paid?.revenue ?? 0),
|
|
640
|
+
accessGrants: grants?.count ?? 0,
|
|
641
|
+
accessGrantRevenue: Number(grants?.revenue ?? 0),
|
|
642
|
+
freeUpgrades: freeUpgrades?.count ?? 0,
|
|
643
|
+
syntheticPurchases: synthetic?.count ?? 0,
|
|
644
|
+
byAccessGrantReason: Object.fromEntries(
|
|
645
|
+
reasonRows.map((row: any) => [row.reason, row.count]),
|
|
646
|
+
),
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
490
650
|
async function getAttributedRevenueSummary(
|
|
491
651
|
range: AnalyticsTimeRange = '30d',
|
|
652
|
+
filters: { productId?: string } = {},
|
|
492
653
|
) {
|
|
493
654
|
const since = rangeToDate(range)
|
|
494
655
|
const conditions = [paidPurchase()]
|
|
495
656
|
if (since) conditions.push(gte(purchases.createdAt, since))
|
|
657
|
+
if (filters.productId)
|
|
658
|
+
conditions.push(eq(purchases.productId, filters.productId))
|
|
496
659
|
|
|
497
660
|
const [totals] = await db
|
|
498
661
|
.select({ total: sum(purchases.totalAmount), count: count() })
|
|
@@ -500,29 +663,107 @@ export function createDatabaseProvider(
|
|
|
500
663
|
.where(and(...conditions))
|
|
501
664
|
|
|
502
665
|
const [attributed] = await db
|
|
503
|
-
.select({ total: sum(purchases.totalAmount) })
|
|
666
|
+
.select({ total: sum(purchases.totalAmount), count: count() })
|
|
504
667
|
.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
|
-
)
|
|
668
|
+
.where(and(...conditions, attributionSignalSql))
|
|
514
669
|
|
|
670
|
+
const [purchaseFieldAttributed] = await db
|
|
671
|
+
.select({ total: sum(purchases.totalAmount), count: count() })
|
|
672
|
+
.from(purchases)
|
|
673
|
+
.where(and(...conditions, purchaseFieldAttributionSignalSql))
|
|
674
|
+
|
|
675
|
+
const [recoveredFromShortlinkAttributionTable] = await db
|
|
676
|
+
.select({ total: sum(purchases.totalAmount), count: count() })
|
|
677
|
+
.from(purchases)
|
|
678
|
+
.where(and(...conditions, shortlinkRecoveredAttributionSignalSql))
|
|
679
|
+
|
|
680
|
+
const signalConditions = since
|
|
681
|
+
? [commerceRecord(), gte(purchases.createdAt, since)]
|
|
682
|
+
: [commerceRecord()]
|
|
683
|
+
if (filters.productId) {
|
|
684
|
+
signalConditions.push(eq(purchases.productId, filters.productId))
|
|
685
|
+
}
|
|
686
|
+
const signalCount = async (condition: any) => {
|
|
687
|
+
const [row] = await db
|
|
688
|
+
.select({ count: count() })
|
|
689
|
+
.from(purchases)
|
|
690
|
+
.where(and(...signalConditions, condition))
|
|
691
|
+
return row?.count ?? 0
|
|
692
|
+
}
|
|
693
|
+
const [
|
|
694
|
+
shortlinkCount,
|
|
695
|
+
utmCount,
|
|
696
|
+
clickIdCount,
|
|
697
|
+
gaClientIdCount,
|
|
698
|
+
selfReportedSourceCount,
|
|
699
|
+
recoveredFromShortlinkAttributionTableCount,
|
|
700
|
+
] = await Promise.all([
|
|
701
|
+
signalCount(
|
|
702
|
+
sql`(
|
|
703
|
+
JSON_EXTRACT(${purchases.fields}, '$.attribution.shortlink.slug') IS NOT NULL
|
|
704
|
+
OR JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.shortlinkRef')) IS NOT NULL
|
|
705
|
+
)`,
|
|
706
|
+
),
|
|
707
|
+
signalCount(
|
|
708
|
+
sql`(
|
|
709
|
+
JSON_EXTRACT(${purchases.fields}, '$.attribution.utm') IS NOT NULL
|
|
710
|
+
OR JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.utmSource')) IS NOT NULL
|
|
711
|
+
)`,
|
|
712
|
+
),
|
|
713
|
+
signalCount(
|
|
714
|
+
sql`JSON_EXTRACT(${purchases.fields}, '$.attribution.clickIds') IS NOT NULL`,
|
|
715
|
+
),
|
|
716
|
+
signalCount(
|
|
717
|
+
sql`(
|
|
718
|
+
JSON_EXTRACT(${purchases.fields}, '$.attribution.ga.clientId') IS NOT NULL
|
|
719
|
+
OR JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.gaClientId')) IS NOT NULL
|
|
720
|
+
)`,
|
|
721
|
+
),
|
|
722
|
+
signalCount(
|
|
723
|
+
sql`JSON_EXTRACT(${purchases.fields}, '$.attribution.selfReportedSource') IS NOT NULL`,
|
|
724
|
+
),
|
|
725
|
+
signalCount(shortlinkRecoveredAttributionSignalSql),
|
|
726
|
+
])
|
|
727
|
+
|
|
728
|
+
const commerce = await getCommerceLaneSummary(range, filters)
|
|
515
729
|
const totalRevenue = Number(totals?.total ?? 0)
|
|
516
730
|
const attributedRevenue = Number(attributed?.total ?? 0)
|
|
731
|
+
const purchaseFieldAttributedRevenue = Number(
|
|
732
|
+
purchaseFieldAttributed?.total ?? 0,
|
|
733
|
+
)
|
|
734
|
+
const recoveredFromShortlinkAttributionTableRevenue = Number(
|
|
735
|
+
recoveredFromShortlinkAttributionTable?.total ?? 0,
|
|
736
|
+
)
|
|
517
737
|
const unattributedRevenue = totalRevenue - attributedRevenue
|
|
518
|
-
const
|
|
738
|
+
const paidPurchases = totals?.count ?? 0
|
|
519
739
|
|
|
520
740
|
return {
|
|
521
741
|
totalRevenue,
|
|
522
742
|
attributedRevenue,
|
|
523
743
|
unattributedRevenue,
|
|
524
744
|
attributionRate: totalRevenue > 0 ? attributedRevenue / totalRevenue : 0,
|
|
525
|
-
totalPurchases,
|
|
745
|
+
totalPurchases: commerce.commerceRecords,
|
|
746
|
+
paidPurchases,
|
|
747
|
+
purchaseFieldAttributedPurchases: purchaseFieldAttributed?.count ?? 0,
|
|
748
|
+
purchaseFieldAttributedRevenue,
|
|
749
|
+
recoveredFromShortlinkAttributionTablePurchases:
|
|
750
|
+
recoveredFromShortlinkAttributionTable?.count ?? 0,
|
|
751
|
+
recoveredFromShortlinkAttributionTableRevenue,
|
|
752
|
+
commerceRecords: commerce.commerceRecords,
|
|
753
|
+
accessGrants: commerce.accessGrants,
|
|
754
|
+
freeUpgrades: commerce.freeUpgrades,
|
|
755
|
+
syntheticPurchases: commerce.syntheticPurchases,
|
|
756
|
+
commerce,
|
|
757
|
+
signals: {
|
|
758
|
+
shortlink: shortlinkCount,
|
|
759
|
+
utm: utmCount,
|
|
760
|
+
paidClickId: clickIdCount,
|
|
761
|
+
gaClientId: gaClientIdCount,
|
|
762
|
+
selfReportedSource: selfReportedSourceCount,
|
|
763
|
+
recoveredFromShortlinkAttributionTable:
|
|
764
|
+
recoveredFromShortlinkAttributionTableCount,
|
|
765
|
+
internalFreeUpgrade: commerce.freeUpgrades,
|
|
766
|
+
},
|
|
526
767
|
}
|
|
527
768
|
}
|
|
528
769
|
|
|
@@ -567,6 +808,363 @@ export function createDatabaseProvider(
|
|
|
567
808
|
}))
|
|
568
809
|
}
|
|
569
810
|
|
|
811
|
+
/**
|
|
812
|
+
* Parses an unknown JSON-like value into a plain record.
|
|
813
|
+
*
|
|
814
|
+
* @param value Unknown input value from a JSON database column.
|
|
815
|
+
* @returns A Record<string, any> when the value is a record, otherwise an empty object.
|
|
816
|
+
*/
|
|
817
|
+
function parseJsonRecord(value: unknown): Record<string, any> {
|
|
818
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
819
|
+
? (value as Record<string, any>)
|
|
820
|
+
: {}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Checks whether a record contains at least one non-empty string value.
|
|
825
|
+
*
|
|
826
|
+
* @param value Unknown input value to inspect.
|
|
827
|
+
* @returns True when at least one record value is a non-empty string.
|
|
828
|
+
*/
|
|
829
|
+
function hasRecordValue(value: unknown) {
|
|
830
|
+
return (
|
|
831
|
+
Boolean(value) &&
|
|
832
|
+
typeof value === 'object' &&
|
|
833
|
+
!Array.isArray(value) &&
|
|
834
|
+
Object.values(value as Record<string, unknown>).some(
|
|
835
|
+
(entry) => typeof entry === 'string' && entry.length > 0,
|
|
836
|
+
)
|
|
837
|
+
)
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* Checks whether a click ID record contains a synthetic TEST_ value.
|
|
842
|
+
*
|
|
843
|
+
* @param value Unknown input value to inspect.
|
|
844
|
+
* @returns True when any record value is a string starting with TEST_.
|
|
845
|
+
*/
|
|
846
|
+
function hasSyntheticClickId(value: unknown) {
|
|
847
|
+
return (
|
|
848
|
+
Boolean(value) &&
|
|
849
|
+
typeof value === 'object' &&
|
|
850
|
+
!Array.isArray(value) &&
|
|
851
|
+
Object.values(value as Record<string, unknown>).some(
|
|
852
|
+
(entry) => typeof entry === 'string' && entry.startsWith('TEST_'),
|
|
853
|
+
)
|
|
854
|
+
)
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
async function getCheckoutAttributionReceipt(opts: { purchaseId?: string }) {
|
|
858
|
+
if (!opts.purchaseId) {
|
|
859
|
+
return {
|
|
860
|
+
purchase: null,
|
|
861
|
+
checks: {
|
|
862
|
+
purchaseFound: false,
|
|
863
|
+
attributionSnapshot: false,
|
|
864
|
+
utm: false,
|
|
865
|
+
actualUtm: false,
|
|
866
|
+
clickId: false,
|
|
867
|
+
synthetic: false,
|
|
868
|
+
shortlink: false,
|
|
869
|
+
selfReportedSource: false,
|
|
870
|
+
gaClientId: false,
|
|
871
|
+
},
|
|
872
|
+
attribution: null,
|
|
873
|
+
legacy: {},
|
|
874
|
+
fieldKeys: [],
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
const [row] = await db
|
|
879
|
+
.select({
|
|
880
|
+
id: purchases.id,
|
|
881
|
+
createdAt: purchases.createdAt,
|
|
882
|
+
productId: purchases.productId,
|
|
883
|
+
productName: products.name,
|
|
884
|
+
totalAmount: purchases.totalAmount,
|
|
885
|
+
status: purchases.status,
|
|
886
|
+
country: purchases.country,
|
|
887
|
+
fields: purchases.fields,
|
|
888
|
+
})
|
|
889
|
+
.from(purchases)
|
|
890
|
+
.leftJoin(products, eq(purchases.productId, products.id))
|
|
891
|
+
.where(eq(purchases.id, opts.purchaseId))
|
|
892
|
+
.limit(1)
|
|
893
|
+
|
|
894
|
+
if (!row) {
|
|
895
|
+
return {
|
|
896
|
+
purchase: null,
|
|
897
|
+
checks: {
|
|
898
|
+
purchaseFound: false,
|
|
899
|
+
attributionSnapshot: false,
|
|
900
|
+
utm: false,
|
|
901
|
+
actualUtm: false,
|
|
902
|
+
clickId: false,
|
|
903
|
+
synthetic: false,
|
|
904
|
+
shortlink: false,
|
|
905
|
+
selfReportedSource: false,
|
|
906
|
+
gaClientId: false,
|
|
907
|
+
},
|
|
908
|
+
attribution: null,
|
|
909
|
+
legacy: {},
|
|
910
|
+
fieldKeys: [],
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
const fields = parseJsonRecord(row.fields)
|
|
915
|
+
const attribution = parseJsonRecord(fields.attribution)
|
|
916
|
+
const utm = parseJsonRecord(attribution.utm)
|
|
917
|
+
const clickIds = parseJsonRecord(attribution.clickIds)
|
|
918
|
+
const shortlinkSnapshot = parseJsonRecord(attribution.shortlink)
|
|
919
|
+
const ga = parseJsonRecord(attribution.ga)
|
|
920
|
+
const legacy = Object.fromEntries(
|
|
921
|
+
[
|
|
922
|
+
'utmSource',
|
|
923
|
+
'utmMedium',
|
|
924
|
+
'utmCampaign',
|
|
925
|
+
'gaClientId',
|
|
926
|
+
'shortlinkRef',
|
|
927
|
+
'landingPath',
|
|
928
|
+
'referrer',
|
|
929
|
+
'ltUtmSource',
|
|
930
|
+
'ltUtmMedium',
|
|
931
|
+
'ltUtmCampaign',
|
|
932
|
+
]
|
|
933
|
+
.map((key) => [key, fields[key]])
|
|
934
|
+
.filter(([, value]) => value !== undefined && value !== null),
|
|
935
|
+
)
|
|
936
|
+
const hasActualUtm = Boolean(
|
|
937
|
+
utm.source || utm.medium || utm.campaign || fields.utmSource,
|
|
938
|
+
)
|
|
939
|
+
const synthetic = Boolean(
|
|
940
|
+
attribution.synthetic ||
|
|
941
|
+
fields.synthetic ||
|
|
942
|
+
hasSyntheticClickId(clickIds),
|
|
943
|
+
)
|
|
944
|
+
|
|
945
|
+
return {
|
|
946
|
+
purchase: {
|
|
947
|
+
id: row.id,
|
|
948
|
+
createdAt: row.createdAt,
|
|
949
|
+
productId: row.productId,
|
|
950
|
+
productName: row.productName ?? null,
|
|
951
|
+
totalAmount: Number(row.totalAmount ?? 0),
|
|
952
|
+
status: row.status,
|
|
953
|
+
country: row.country,
|
|
954
|
+
},
|
|
955
|
+
checks: {
|
|
956
|
+
purchaseFound: true,
|
|
957
|
+
attributionSnapshot: Object.keys(attribution).length > 0,
|
|
958
|
+
utm: hasActualUtm,
|
|
959
|
+
actualUtm: hasActualUtm,
|
|
960
|
+
clickId: hasRecordValue(clickIds),
|
|
961
|
+
synthetic,
|
|
962
|
+
shortlink: Boolean(shortlinkSnapshot.slug || fields.shortlinkRef),
|
|
963
|
+
selfReportedSource: Boolean(attribution.selfReportedSource),
|
|
964
|
+
gaClientId: Boolean(ga.clientId || fields.gaClientId),
|
|
965
|
+
},
|
|
966
|
+
attribution: Object.keys(attribution).length > 0 ? attribution : null,
|
|
967
|
+
legacy,
|
|
968
|
+
fieldKeys: Object.keys(fields).sort(),
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
async function getCheckoutSurveyFallbackReport(
|
|
973
|
+
range: AnalyticsTimeRange = '30d',
|
|
974
|
+
limit: number = 20,
|
|
975
|
+
filters: { productId?: string } = {},
|
|
976
|
+
) {
|
|
977
|
+
const label = 'recovered_from_checkout_survey_response'
|
|
978
|
+
if (!questionResponse) {
|
|
979
|
+
return {
|
|
980
|
+
label,
|
|
981
|
+
productId: filters.productId ?? null,
|
|
982
|
+
totalDarkPurchases: 0,
|
|
983
|
+
totalDarkRevenue: 0,
|
|
984
|
+
exactPurchaseLinkedPurchases: 0,
|
|
985
|
+
exactPurchaseLinkedRevenue: 0,
|
|
986
|
+
userLinkedPurchases: 0,
|
|
987
|
+
userLinkedRevenue: 0,
|
|
988
|
+
byAnswer: [],
|
|
989
|
+
notes: ['questionResponse table was not provided to this provider'],
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
const since = rangeToDate(range)
|
|
994
|
+
const purchaseConditions = [
|
|
995
|
+
paidPurchase(),
|
|
996
|
+
sql`NOT ${attributionSignalSql}`,
|
|
997
|
+
]
|
|
998
|
+
if (since) purchaseConditions.push(gte(purchases.createdAt, since))
|
|
999
|
+
if (filters.productId) {
|
|
1000
|
+
purchaseConditions.push(eq(purchases.productId, filters.productId))
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
const darkRows = await db
|
|
1004
|
+
.select({
|
|
1005
|
+
id: purchases.id,
|
|
1006
|
+
userId: purchases.userId,
|
|
1007
|
+
revenue: purchases.totalAmount,
|
|
1008
|
+
})
|
|
1009
|
+
.from(purchases)
|
|
1010
|
+
.where(and(...purchaseConditions))
|
|
1011
|
+
|
|
1012
|
+
const totalDarkRevenue = darkRows.reduce(
|
|
1013
|
+
(total: number, row: any) => total + Number(row.revenue ?? 0),
|
|
1014
|
+
0,
|
|
1015
|
+
)
|
|
1016
|
+
|
|
1017
|
+
if (darkRows.length === 0) {
|
|
1018
|
+
return {
|
|
1019
|
+
label,
|
|
1020
|
+
productId: filters.productId ?? null,
|
|
1021
|
+
totalDarkPurchases: 0,
|
|
1022
|
+
totalDarkRevenue: 0,
|
|
1023
|
+
exactPurchaseLinkedPurchases: 0,
|
|
1024
|
+
exactPurchaseLinkedRevenue: 0,
|
|
1025
|
+
userLinkedPurchases: 0,
|
|
1026
|
+
userLinkedRevenue: 0,
|
|
1027
|
+
byAnswer: [],
|
|
1028
|
+
notes: ['No dark paid purchases matched the filters'],
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
const darkById = new Map<string, (typeof darkRows)[number]>(
|
|
1033
|
+
darkRows.map((row: (typeof darkRows)[number]) => [row.id, row]),
|
|
1034
|
+
)
|
|
1035
|
+
const darkByUserId = new Map<string, any[]>()
|
|
1036
|
+
for (const row of darkRows) {
|
|
1037
|
+
if (!row.userId) continue
|
|
1038
|
+
const bucket = darkByUserId.get(row.userId) ?? []
|
|
1039
|
+
bucket.push(row)
|
|
1040
|
+
darkByUserId.set(row.userId, bucket)
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
const responseConditions: any[] = []
|
|
1044
|
+
if (since) responseConditions.push(gte(questionResponse.createdAt, since))
|
|
1045
|
+
const darkIds = [...darkById.keys()]
|
|
1046
|
+
const darkUserIds = [...darkByUserId.keys()]
|
|
1047
|
+
const exactPurchaseCondition = sql`JSON_UNQUOTE(JSON_EXTRACT(${questionResponse.fields}, '$.purchaseId')) IN (${sql.join(
|
|
1048
|
+
darkIds.map((id) => sql`${id}`),
|
|
1049
|
+
sql`, `,
|
|
1050
|
+
)})`
|
|
1051
|
+
const userCondition =
|
|
1052
|
+
darkUserIds.length > 0
|
|
1053
|
+
? sql`${questionResponse.userId} IN (${sql.join(
|
|
1054
|
+
darkUserIds.map((id) => sql`${id}`),
|
|
1055
|
+
sql`, `,
|
|
1056
|
+
)})`
|
|
1057
|
+
: sql`FALSE`
|
|
1058
|
+
responseConditions.push(
|
|
1059
|
+
sql`(${exactPurchaseCondition} OR ${userCondition})`,
|
|
1060
|
+
)
|
|
1061
|
+
|
|
1062
|
+
const responseRows = await db
|
|
1063
|
+
.select({
|
|
1064
|
+
purchaseId: sql<string>`JSON_UNQUOTE(JSON_EXTRACT(${questionResponse.fields}, '$.purchaseId'))`,
|
|
1065
|
+
answer: sql<string>`JSON_UNQUOTE(JSON_EXTRACT(${questionResponse.fields}, '$.answer'))`,
|
|
1066
|
+
userId: questionResponse.userId,
|
|
1067
|
+
createdAt: questionResponse.createdAt,
|
|
1068
|
+
})
|
|
1069
|
+
.from(questionResponse)
|
|
1070
|
+
.where(and(...responseConditions))
|
|
1071
|
+
|
|
1072
|
+
const exactByPurchase = new Map<string, any>()
|
|
1073
|
+
const latestByUser = new Map<string, any>()
|
|
1074
|
+
for (const row of responseRows) {
|
|
1075
|
+
if (row.purchaseId && darkById.has(row.purchaseId)) {
|
|
1076
|
+
exactByPurchase.set(row.purchaseId, row)
|
|
1077
|
+
continue
|
|
1078
|
+
}
|
|
1079
|
+
if (!row.userId) continue
|
|
1080
|
+
const current = latestByUser.get(row.userId)
|
|
1081
|
+
const rowTime = new Date(row.createdAt ?? 0).getTime()
|
|
1082
|
+
const currentTime = current
|
|
1083
|
+
? new Date(current.createdAt ?? 0).getTime()
|
|
1084
|
+
: -1
|
|
1085
|
+
if (!current || rowTime >= currentTime) latestByUser.set(row.userId, row)
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
const classified = new Map<
|
|
1089
|
+
string,
|
|
1090
|
+
{
|
|
1091
|
+
confidence: 'exact_purchase_link' | 'user_linked'
|
|
1092
|
+
answer: string
|
|
1093
|
+
revenue: number
|
|
1094
|
+
}
|
|
1095
|
+
>()
|
|
1096
|
+
for (const [purchaseId, response] of exactByPurchase) {
|
|
1097
|
+
const purchase = darkById.get(purchaseId)
|
|
1098
|
+
classified.set(purchaseId, {
|
|
1099
|
+
confidence: 'exact_purchase_link',
|
|
1100
|
+
answer: response.answer || '(no answer)',
|
|
1101
|
+
revenue: Number(purchase?.revenue ?? 0),
|
|
1102
|
+
})
|
|
1103
|
+
}
|
|
1104
|
+
for (const row of darkRows) {
|
|
1105
|
+
if (classified.has(row.id) || !row.userId) continue
|
|
1106
|
+
const response = latestByUser.get(row.userId)
|
|
1107
|
+
if (!response) continue
|
|
1108
|
+
classified.set(row.id, {
|
|
1109
|
+
confidence: 'user_linked',
|
|
1110
|
+
answer: response.answer || '(no answer)',
|
|
1111
|
+
revenue: Number(row.revenue ?? 0),
|
|
1112
|
+
})
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
const byAnswer = new Map<
|
|
1116
|
+
string,
|
|
1117
|
+
{
|
|
1118
|
+
answer: string
|
|
1119
|
+
confidence: 'exact_purchase_link' | 'user_linked'
|
|
1120
|
+
purchases: number
|
|
1121
|
+
revenue: number
|
|
1122
|
+
}
|
|
1123
|
+
>()
|
|
1124
|
+
let exactPurchaseLinkedPurchases = 0
|
|
1125
|
+
let exactPurchaseLinkedRevenue = 0
|
|
1126
|
+
let userLinkedPurchases = 0
|
|
1127
|
+
let userLinkedRevenue = 0
|
|
1128
|
+
for (const entry of classified.values()) {
|
|
1129
|
+
if (entry.confidence === 'exact_purchase_link') {
|
|
1130
|
+
exactPurchaseLinkedPurchases += 1
|
|
1131
|
+
exactPurchaseLinkedRevenue += entry.revenue
|
|
1132
|
+
} else {
|
|
1133
|
+
userLinkedPurchases += 1
|
|
1134
|
+
userLinkedRevenue += entry.revenue
|
|
1135
|
+
}
|
|
1136
|
+
const key = `${entry.confidence}:${entry.answer}`
|
|
1137
|
+
const current = byAnswer.get(key) ?? {
|
|
1138
|
+
answer: entry.answer,
|
|
1139
|
+
confidence: entry.confidence,
|
|
1140
|
+
purchases: 0,
|
|
1141
|
+
revenue: 0,
|
|
1142
|
+
}
|
|
1143
|
+
current.purchases += 1
|
|
1144
|
+
current.revenue += entry.revenue
|
|
1145
|
+
byAnswer.set(key, current)
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
return {
|
|
1149
|
+
label,
|
|
1150
|
+
productId: filters.productId ?? null,
|
|
1151
|
+
totalDarkPurchases: darkRows.length,
|
|
1152
|
+
totalDarkRevenue,
|
|
1153
|
+
exactPurchaseLinkedPurchases,
|
|
1154
|
+
exactPurchaseLinkedRevenue,
|
|
1155
|
+
userLinkedPurchases,
|
|
1156
|
+
userLinkedRevenue,
|
|
1157
|
+
byAnswer: [...byAnswer.values()]
|
|
1158
|
+
.sort((a, b) => b.revenue - a.revenue || b.purchases - a.purchases)
|
|
1159
|
+
.slice(0, limit),
|
|
1160
|
+
notes: [
|
|
1161
|
+
'Report-only fallback. No purchase fields are mutated.',
|
|
1162
|
+
'Exact purchase-linked responses use QuestionResponse.fields.purchaseId when present.',
|
|
1163
|
+
'User-linked responses are lower-confidence and remain report-only.',
|
|
1164
|
+
],
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
570
1168
|
// ─── Attribution Trail ───────────────────────────────────────────────────
|
|
571
1169
|
|
|
572
1170
|
/**
|
|
@@ -574,6 +1172,174 @@ export function createDatabaseProvider(
|
|
|
574
1172
|
* Walks: ShortlinkClick → ShortlinkAttribution (signup) →
|
|
575
1173
|
* ResourceProgress → Purchase
|
|
576
1174
|
*/
|
|
1175
|
+
|
|
1176
|
+
async function getValuePathSummary(range: AnalyticsTimeRange = '30d') {
|
|
1177
|
+
if (!contactEvent || !sideEffectIntent) {
|
|
1178
|
+
return {
|
|
1179
|
+
contacts: 0,
|
|
1180
|
+
events: 0,
|
|
1181
|
+
intents: 0,
|
|
1182
|
+
completedIntents: 0,
|
|
1183
|
+
pendingIntents: 0,
|
|
1184
|
+
blockedIntents: 0,
|
|
1185
|
+
answerEvents: 0,
|
|
1186
|
+
dripEvents: 0,
|
|
1187
|
+
enteredEvents: 0,
|
|
1188
|
+
participantsWithAnswerClicks: 0,
|
|
1189
|
+
participantsWithNoAnswerClicks: 0,
|
|
1190
|
+
terminalParticipants: 0,
|
|
1191
|
+
answerOptions: [],
|
|
1192
|
+
answerSteps: [],
|
|
1193
|
+
terminalSteps: [],
|
|
1194
|
+
notes: ['Contact Event and SideEffectIntent tables are unavailable.'],
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
const since = rangeToDate(range)
|
|
1199
|
+
const eventConditions = [sql`${contactEvent.eventType} LIKE 'value-path.%'`]
|
|
1200
|
+
const intentConditions = [
|
|
1201
|
+
eq(sideEffectIntent.type, 'send-value-path-email'),
|
|
1202
|
+
]
|
|
1203
|
+
if (since) {
|
|
1204
|
+
eventConditions.push(gte(contactEvent.occurredAt, since))
|
|
1205
|
+
intentConditions.push(gte(sideEffectIntent.createdAt, since))
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
const [events, intents] = await Promise.all([
|
|
1209
|
+
db
|
|
1210
|
+
.select({
|
|
1211
|
+
contactId: contactEvent.contactId,
|
|
1212
|
+
eventType: contactEvent.eventType,
|
|
1213
|
+
providerEventId: contactEvent.providerEventId,
|
|
1214
|
+
occurredAt: contactEvent.occurredAt,
|
|
1215
|
+
})
|
|
1216
|
+
.from(contactEvent)
|
|
1217
|
+
.where(and(...eventConditions)),
|
|
1218
|
+
db
|
|
1219
|
+
.select({
|
|
1220
|
+
contactId: sideEffectIntent.contactId,
|
|
1221
|
+
status: sideEffectIntent.status,
|
|
1222
|
+
metadata: sideEffectIntent.metadata,
|
|
1223
|
+
createdAt: sideEffectIntent.createdAt,
|
|
1224
|
+
})
|
|
1225
|
+
.from(sideEffectIntent)
|
|
1226
|
+
.where(and(...intentConditions)),
|
|
1227
|
+
])
|
|
1228
|
+
|
|
1229
|
+
const contactIds = new Set<string>()
|
|
1230
|
+
for (const event of events) contactIds.add(event.contactId)
|
|
1231
|
+
for (const intent of intents) contactIds.add(intent.contactId)
|
|
1232
|
+
|
|
1233
|
+
const answerEvents = events.filter(
|
|
1234
|
+
(event: any) => event.eventType === 'value-path.answer-selected',
|
|
1235
|
+
)
|
|
1236
|
+
const dripEvents = events.filter(
|
|
1237
|
+
(event: any) => event.eventType === 'value-path.drip-progressed',
|
|
1238
|
+
)
|
|
1239
|
+
const enteredEvents = events.filter(
|
|
1240
|
+
(event: any) => event.eventType === 'value-path.entered',
|
|
1241
|
+
)
|
|
1242
|
+
|
|
1243
|
+
const answersByContact = new Map<string, number>()
|
|
1244
|
+
for (const event of answerEvents) {
|
|
1245
|
+
answersByContact.set(
|
|
1246
|
+
event.contactId,
|
|
1247
|
+
(answersByContact.get(event.contactId) ?? 0) + 1,
|
|
1248
|
+
)
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
const answerKeyCounts = new Map<string, number>()
|
|
1252
|
+
const answerStepCounts = new Map<string, number>()
|
|
1253
|
+
for (const event of answerEvents) {
|
|
1254
|
+
const key = parseValuePathAnswerKey(event.providerEventId)
|
|
1255
|
+
answerKeyCounts.set(key, (answerKeyCounts.get(key) ?? 0) + 1)
|
|
1256
|
+
const step = answerStepFromKey(key)
|
|
1257
|
+
answerStepCounts.set(step, (answerStepCounts.get(step) ?? 0) + 1)
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
const latestIntentByContact = new Map<string, any>()
|
|
1261
|
+
for (const intent of [...intents].sort(
|
|
1262
|
+
(a: any, b: any) =>
|
|
1263
|
+
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
|
1264
|
+
)) {
|
|
1265
|
+
latestIntentByContact.set(intent.contactId, intent)
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
const terminalCounts = new Map<string, number>()
|
|
1269
|
+
for (const intent of latestIntentByContact.values()) {
|
|
1270
|
+
const emailResourceId = String(intent.metadata?.emailResourceId ?? '')
|
|
1271
|
+
if (!isTerminalValuePathEmail(emailResourceId)) continue
|
|
1272
|
+
terminalCounts.set(
|
|
1273
|
+
emailResourceId,
|
|
1274
|
+
(terminalCounts.get(emailResourceId) ?? 0) + 1,
|
|
1275
|
+
)
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
return {
|
|
1279
|
+
contacts: contactIds.size,
|
|
1280
|
+
events: events.length,
|
|
1281
|
+
intents: intents.length,
|
|
1282
|
+
completedIntents: intents.filter(
|
|
1283
|
+
(intent: any) => intent.status === 'completed',
|
|
1284
|
+
).length,
|
|
1285
|
+
pendingIntents: intents.filter(
|
|
1286
|
+
(intent: any) => intent.status === 'pending',
|
|
1287
|
+
).length,
|
|
1288
|
+
blockedIntents: intents.filter(
|
|
1289
|
+
(intent: any) => intent.status === 'blocked',
|
|
1290
|
+
).length,
|
|
1291
|
+
answerEvents: answerEvents.length,
|
|
1292
|
+
dripEvents: dripEvents.length,
|
|
1293
|
+
enteredEvents: enteredEvents.length,
|
|
1294
|
+
participantsWithAnswerClicks: answersByContact.size,
|
|
1295
|
+
participantsWithNoAnswerClicks: Math.max(
|
|
1296
|
+
0,
|
|
1297
|
+
contactIds.size - answersByContact.size,
|
|
1298
|
+
),
|
|
1299
|
+
terminalParticipants: [...terminalCounts.values()].reduce(
|
|
1300
|
+
(total, count) => total + count,
|
|
1301
|
+
0,
|
|
1302
|
+
),
|
|
1303
|
+
answerOptions: [...answerKeyCounts.entries()]
|
|
1304
|
+
.map(([key, count]) => ({
|
|
1305
|
+
key,
|
|
1306
|
+
step: answerStepFromKey(key),
|
|
1307
|
+
optionValue: answerOptionFromKey(key),
|
|
1308
|
+
count,
|
|
1309
|
+
}))
|
|
1310
|
+
.sort((a, b) => b.count - a.count),
|
|
1311
|
+
answerSteps: [...answerStepCounts.entries()]
|
|
1312
|
+
.map(([step, count]) => ({ step, count }))
|
|
1313
|
+
.sort((a, b) => b.count - a.count),
|
|
1314
|
+
terminalSteps: [...terminalCounts.entries()]
|
|
1315
|
+
.map(([emailResourceId, count]) => ({ emailResourceId, count }))
|
|
1316
|
+
.sort((a, b) => b.count - a.count),
|
|
1317
|
+
notes: [
|
|
1318
|
+
'Counts are based on Contact Events and SideEffectIntents.',
|
|
1319
|
+
'Kit-native opens, unsubscribes, complaints, and raw Kit link clicks are not included.',
|
|
1320
|
+
],
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
function parseValuePathAnswerKey(providerEventId: string | null | undefined) {
|
|
1325
|
+
const match = String(providerEventId ?? '').match(/:answer:(.+)$/)
|
|
1326
|
+
return match?.[1] ?? 'unknown'
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
function answerStepFromKey(key: string) {
|
|
1330
|
+
const index = key.lastIndexOf('.')
|
|
1331
|
+
return index > -1 ? key.slice(0, index) : key
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
function answerOptionFromKey(key: string) {
|
|
1335
|
+
const index = key.lastIndexOf('.')
|
|
1336
|
+
return index > -1 ? key.slice(index + 1) : 'unknown'
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
function isTerminalValuePathEmail(emailResourceId: string) {
|
|
1340
|
+
return /(?:^|\.)(?:email-6|team-email-6)$/.test(emailResourceId)
|
|
1341
|
+
}
|
|
1342
|
+
|
|
577
1343
|
async function traceAttribution(opts: {
|
|
578
1344
|
email?: string
|
|
579
1345
|
purchaseId?: string
|
|
@@ -784,8 +1550,12 @@ export function createDatabaseProvider(
|
|
|
784
1550
|
getShortlinkPerformance,
|
|
785
1551
|
getRevenueBySource,
|
|
786
1552
|
getConversionFunnel,
|
|
1553
|
+
getCommerceLaneSummary,
|
|
787
1554
|
getAttributedRevenueSummary,
|
|
788
1555
|
getContentPurchaseCorrelation,
|
|
1556
|
+
getCheckoutAttributionReceipt,
|
|
1557
|
+
getCheckoutSurveyFallbackReport,
|
|
1558
|
+
getValuePathSummary,
|
|
789
1559
|
traceAttribution,
|
|
790
1560
|
}
|
|
791
1561
|
}
|