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