@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.
@@ -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 paidPurchase() {
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(range: AnalyticsTimeRange = '30d') {
492
+ async function getRevenueBySource(
493
+ range: AnalyticsTimeRange = '30d',
494
+ filters: { productId?: string } = {},
495
+ ) {
407
496
  const since = rangeToDate(range)
408
- const conditions = [paidPurchase()]
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
- 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
- ),
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
- 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)))
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
- 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
- )
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 totalPurchases = totals?.count ?? 0
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
  }