@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
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
PlayIcon,
|
|
18
18
|
ShoppingCartIcon,
|
|
19
19
|
TrendingUpIcon,
|
|
20
|
+
RouteIcon,
|
|
20
21
|
} from 'lucide-react'
|
|
21
22
|
import { parseAsStringLiteral, useQueryState } from 'nuqs'
|
|
22
23
|
|
|
@@ -125,6 +126,29 @@ export interface DashboardData {
|
|
|
125
126
|
answerDistribution: { answer: string; count: number }[]
|
|
126
127
|
}[]
|
|
127
128
|
| null
|
|
129
|
+
valuePaths?: {
|
|
130
|
+
contacts: number
|
|
131
|
+
events: number
|
|
132
|
+
intents: number
|
|
133
|
+
completedIntents: number
|
|
134
|
+
pendingIntents: number
|
|
135
|
+
blockedIntents: number
|
|
136
|
+
answerEvents: number
|
|
137
|
+
dripEvents: number
|
|
138
|
+
enteredEvents: number
|
|
139
|
+
participantsWithAnswerClicks: number
|
|
140
|
+
participantsWithNoAnswerClicks: number
|
|
141
|
+
terminalParticipants: number
|
|
142
|
+
answerOptions: {
|
|
143
|
+
key: string
|
|
144
|
+
step: string
|
|
145
|
+
optionValue: string
|
|
146
|
+
count: number
|
|
147
|
+
}[]
|
|
148
|
+
answerSteps: { step: string; count: number }[]
|
|
149
|
+
terminalSteps: { emailResourceId: string; count: number }[]
|
|
150
|
+
notes: string[]
|
|
151
|
+
} | null
|
|
128
152
|
surveyCorrelation: {
|
|
129
153
|
totalRespondents: number
|
|
130
154
|
respondentsWhoPurchased: number
|
|
@@ -661,6 +685,7 @@ export function OmnibusDashboard({
|
|
|
661
685
|
const hasSurveys =
|
|
662
686
|
data.surveySegments != null && data.surveySegments.length > 0
|
|
663
687
|
const hasSurveyCorrelation = data.surveyCorrelation != null
|
|
688
|
+
const hasValuePaths = data.valuePaths != null && data.valuePaths.contacts > 0
|
|
664
689
|
const hasShortlinks = data.shortlinks.length > 0
|
|
665
690
|
|
|
666
691
|
return (
|
|
@@ -753,6 +778,14 @@ export function OmnibusDashboard({
|
|
|
753
778
|
icon={MousePointerClickIcon}
|
|
754
779
|
/>
|
|
755
780
|
)}
|
|
781
|
+
{hasValuePaths && (
|
|
782
|
+
<Stat
|
|
783
|
+
label="Value Paths"
|
|
784
|
+
value={`${data.valuePaths!.terminalParticipants}/${data.valuePaths!.contacts}`}
|
|
785
|
+
sub={`${data.valuePaths!.answerEvents} answers · ${data.valuePaths!.dripEvents} drips`}
|
|
786
|
+
icon={RouteIcon}
|
|
787
|
+
/>
|
|
788
|
+
)}
|
|
756
789
|
{hasSurveys && (
|
|
757
790
|
<Stat
|
|
758
791
|
label="Surveys"
|
|
@@ -883,6 +916,136 @@ export function OmnibusDashboard({
|
|
|
883
916
|
</Card>
|
|
884
917
|
)}
|
|
885
918
|
|
|
919
|
+
{/* ── Value Paths ──────────────────────────────────────────── */}
|
|
920
|
+
{hasValuePaths && (
|
|
921
|
+
<Card>
|
|
922
|
+
<CardHeader className="pb-3">
|
|
923
|
+
<div className="flex items-center gap-2.5">
|
|
924
|
+
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-sky-500/10 text-sky-500">
|
|
925
|
+
<RouteIcon className="h-3.5 w-3.5" />
|
|
926
|
+
</div>
|
|
927
|
+
<div>
|
|
928
|
+
<CardTitle className="text-sm font-semibold">
|
|
929
|
+
Value Paths
|
|
930
|
+
</CardTitle>
|
|
931
|
+
<CardDescription className="text-[11px]">
|
|
932
|
+
Progression, answer clicks, drip fallback, and terminal
|
|
933
|
+
completion
|
|
934
|
+
</CardDescription>
|
|
935
|
+
</div>
|
|
936
|
+
</div>
|
|
937
|
+
</CardHeader>
|
|
938
|
+
<CardContent className="space-y-5">
|
|
939
|
+
{(() => {
|
|
940
|
+
const vp = data.valuePaths!
|
|
941
|
+
const completionRate =
|
|
942
|
+
vp.contacts > 0
|
|
943
|
+
? (vp.terminalParticipants / vp.contacts) * 100
|
|
944
|
+
: 0
|
|
945
|
+
const clickRate =
|
|
946
|
+
vp.contacts > 0
|
|
947
|
+
? (vp.participantsWithAnswerClicks / vp.contacts) * 100
|
|
948
|
+
: 0
|
|
949
|
+
return (
|
|
950
|
+
<>
|
|
951
|
+
<div className="grid grid-cols-2 gap-2 sm:grid-cols-5">
|
|
952
|
+
<div className="bg-muted/20 rounded-lg p-3">
|
|
953
|
+
<span className="text-muted-foreground block text-[11px] font-medium uppercase tracking-wider">
|
|
954
|
+
Contacts
|
|
955
|
+
</span>
|
|
956
|
+
<span className="text-foreground mt-1 block text-lg font-bold tabular-nums">
|
|
957
|
+
{vp.contacts.toLocaleString()}
|
|
958
|
+
</span>
|
|
959
|
+
</div>
|
|
960
|
+
<div className="bg-muted/20 rounded-lg p-3">
|
|
961
|
+
<span className="text-muted-foreground block text-[11px] font-medium uppercase tracking-wider">
|
|
962
|
+
Terminal
|
|
963
|
+
</span>
|
|
964
|
+
<span className="text-foreground mt-1 block text-lg font-bold tabular-nums">
|
|
965
|
+
{vp.terminalParticipants.toLocaleString()}
|
|
966
|
+
</span>
|
|
967
|
+
<span className="text-muted-foreground/70 text-[10px]">
|
|
968
|
+
{completionRate.toFixed(0)}% completed
|
|
969
|
+
</span>
|
|
970
|
+
</div>
|
|
971
|
+
<div className="bg-muted/20 rounded-lg p-3">
|
|
972
|
+
<span className="text-muted-foreground block text-[11px] font-medium uppercase tracking-wider">
|
|
973
|
+
Answered
|
|
974
|
+
</span>
|
|
975
|
+
<span className="text-foreground mt-1 block text-lg font-bold tabular-nums">
|
|
976
|
+
{vp.participantsWithAnswerClicks.toLocaleString()}
|
|
977
|
+
</span>
|
|
978
|
+
<span className="text-muted-foreground/70 text-[10px]">
|
|
979
|
+
{clickRate.toFixed(0)}% clicked
|
|
980
|
+
</span>
|
|
981
|
+
</div>
|
|
982
|
+
<div className="bg-muted/20 rounded-lg p-3">
|
|
983
|
+
<span className="text-muted-foreground block text-[11px] font-medium uppercase tracking-wider">
|
|
984
|
+
Drips
|
|
985
|
+
</span>
|
|
986
|
+
<span className="text-foreground mt-1 block text-lg font-bold tabular-nums">
|
|
987
|
+
{vp.dripEvents.toLocaleString()}
|
|
988
|
+
</span>
|
|
989
|
+
</div>
|
|
990
|
+
<div className="bg-muted/20 rounded-lg p-3">
|
|
991
|
+
<span className="text-muted-foreground block text-[11px] font-medium uppercase tracking-wider">
|
|
992
|
+
Blocked
|
|
993
|
+
</span>
|
|
994
|
+
<span className="text-foreground mt-1 block text-lg font-bold tabular-nums">
|
|
995
|
+
{vp.blockedIntents.toLocaleString()}
|
|
996
|
+
</span>
|
|
997
|
+
</div>
|
|
998
|
+
</div>
|
|
999
|
+
|
|
1000
|
+
{vp.answerOptions.length > 0 && (
|
|
1001
|
+
<div className="space-y-2">
|
|
1002
|
+
<p className="text-muted-foreground text-[11px]">
|
|
1003
|
+
Top answer choices by captured answer event
|
|
1004
|
+
</p>
|
|
1005
|
+
<div className="space-y-1.5">
|
|
1006
|
+
{vp.answerOptions.slice(0, 8).map((answer) => {
|
|
1007
|
+
const pct =
|
|
1008
|
+
vp.answerEvents > 0
|
|
1009
|
+
? (answer.count / vp.answerEvents) * 100
|
|
1010
|
+
: 0
|
|
1011
|
+
return (
|
|
1012
|
+
<div
|
|
1013
|
+
key={answer.key}
|
|
1014
|
+
className="flex items-center gap-2.5"
|
|
1015
|
+
>
|
|
1016
|
+
<span
|
|
1017
|
+
className="text-muted-foreground w-[140px] shrink-0 truncate text-right text-[12px]"
|
|
1018
|
+
title={answer.key}
|
|
1019
|
+
>
|
|
1020
|
+
{answer.optionValue}
|
|
1021
|
+
</span>
|
|
1022
|
+
<div className="bg-muted/50 relative h-5 flex-1 overflow-hidden rounded">
|
|
1023
|
+
<div
|
|
1024
|
+
className="absolute inset-y-0 left-0 rounded bg-sky-500/20"
|
|
1025
|
+
style={{ width: `${Math.max(pct, 1)}%` }}
|
|
1026
|
+
/>
|
|
1027
|
+
<div className="relative flex h-full items-center px-2">
|
|
1028
|
+
<span className="text-foreground/70 text-[11px] font-medium tabular-nums">
|
|
1029
|
+
{pct.toFixed(0)}%
|
|
1030
|
+
</span>
|
|
1031
|
+
</div>
|
|
1032
|
+
</div>
|
|
1033
|
+
<span className="text-muted-foreground w-10 shrink-0 text-right text-[11px] tabular-nums">
|
|
1034
|
+
{answer.count.toLocaleString()}
|
|
1035
|
+
</span>
|
|
1036
|
+
</div>
|
|
1037
|
+
)
|
|
1038
|
+
})}
|
|
1039
|
+
</div>
|
|
1040
|
+
</div>
|
|
1041
|
+
)}
|
|
1042
|
+
</>
|
|
1043
|
+
)
|
|
1044
|
+
})()}
|
|
1045
|
+
</CardContent>
|
|
1046
|
+
</Card>
|
|
1047
|
+
)}
|
|
1048
|
+
|
|
886
1049
|
{/* ── Survey Segments ──────────────────────────────────────── */}
|
|
887
1050
|
{hasSurveys && (
|
|
888
1051
|
<Card>
|
package/src/engine.ts
CHANGED
|
@@ -27,9 +27,10 @@ function toYouTubeRange(range: AnalyticsRange): '24h' | '7d' | '30d' | '90d' {
|
|
|
27
27
|
async function invokeSurface<S extends SurfaceName>(
|
|
28
28
|
providers: ProviderMap,
|
|
29
29
|
surface: S,
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
options: Required<Pick<QueryOptions, 'range' | 'limit' | 'offset'>> &
|
|
31
|
+
Omit<QueryOptions, 'range' | 'limit' | 'offset'>,
|
|
32
32
|
): Promise<SurfaceMap[S] | null> {
|
|
33
|
+
const { range, limit, offset } = options
|
|
33
34
|
const entry = catalog[surface]
|
|
34
35
|
const provider = providers[entry.provider]
|
|
35
36
|
|
|
@@ -86,22 +87,71 @@ async function invokeSurface<S extends SurfaceName>(
|
|
|
86
87
|
) => Promise<SurfaceMap[S]>
|
|
87
88
|
return fn(range)
|
|
88
89
|
}
|
|
89
|
-
case 'attribution/
|
|
90
|
+
case 'attribution/sources':
|
|
91
|
+
case 'attribution/coverage':
|
|
92
|
+
case 'attribution/commerce-lanes': {
|
|
93
|
+
const fn = provider[entry.fn] as (
|
|
94
|
+
range: AnalyticsRange,
|
|
95
|
+
filters?: Pick<QueryOptions, 'productId'>,
|
|
96
|
+
) => Promise<SurfaceMap[S]>
|
|
97
|
+
return fn(range, { productId: options.productId })
|
|
98
|
+
}
|
|
99
|
+
case 'attribution/email-campaigns':
|
|
100
|
+
case 'attribution/email-campaigns/strict': {
|
|
90
101
|
const fn = provider[entry.fn] as (
|
|
91
102
|
range: AnalyticsRange,
|
|
92
103
|
limit?: number,
|
|
104
|
+
filters?: Pick<QueryOptions, 'productId'>,
|
|
105
|
+
) => Promise<SurfaceMap[S]>
|
|
106
|
+
return fn(range, limit, { productId: options.productId })
|
|
107
|
+
}
|
|
108
|
+
case 'attribution/checkout-receipt': {
|
|
109
|
+
const fn = provider[entry.fn] as (
|
|
110
|
+
filters: Pick<QueryOptions, 'purchaseId'>,
|
|
111
|
+
) => Promise<SurfaceMap[S]>
|
|
112
|
+
return fn({ purchaseId: options.purchaseId })
|
|
113
|
+
}
|
|
114
|
+
case 'attribution/checkout-survey-fallback': {
|
|
115
|
+
const fn = provider[entry.fn] as (
|
|
116
|
+
range: AnalyticsRange,
|
|
117
|
+
limit?: number,
|
|
118
|
+
filters?: Pick<QueryOptions, 'productId'>,
|
|
119
|
+
) => Promise<SurfaceMap[S]>
|
|
120
|
+
return fn(range, limit, { productId: options.productId })
|
|
121
|
+
}
|
|
122
|
+
case 'surveys/questions': {
|
|
123
|
+
const fn = provider[entry.fn] as (
|
|
124
|
+
range: AnalyticsRange,
|
|
125
|
+
limit: number,
|
|
93
126
|
) => Promise<SurfaceMap[S]>
|
|
94
127
|
return fn(range, limit)
|
|
95
128
|
}
|
|
96
|
-
case 'surveys/questions':
|
|
97
129
|
case 'surveys/responses': {
|
|
98
130
|
const fn = provider[entry.fn] as (
|
|
99
131
|
range: AnalyticsRange,
|
|
100
132
|
limit: number,
|
|
133
|
+
offset: number,
|
|
101
134
|
) => Promise<SurfaceMap[S]>
|
|
102
|
-
return fn(range, limit)
|
|
135
|
+
return fn(range, limit, offset)
|
|
103
136
|
}
|
|
104
137
|
default: {
|
|
138
|
+
if (surface === 'correlation/survey-revenue/product') {
|
|
139
|
+
const productSurveyFn = provider[entry.fn] as (
|
|
140
|
+
range: AnalyticsRange,
|
|
141
|
+
limit?: number,
|
|
142
|
+
filters?: Pick<
|
|
143
|
+
QueryOptions,
|
|
144
|
+
'productId' | 'surveyId' | 'surveySlug' | 'questionId'
|
|
145
|
+
>,
|
|
146
|
+
) => Promise<SurfaceMap[S] | null>
|
|
147
|
+
return productSurveyFn(range, limit, {
|
|
148
|
+
productId: options.productId,
|
|
149
|
+
surveyId: options.surveyId,
|
|
150
|
+
surveySlug: options.surveySlug,
|
|
151
|
+
questionId: options.questionId,
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
|
|
105
155
|
const fn = provider[entry.fn] as (
|
|
106
156
|
range: AnalyticsRange,
|
|
107
157
|
limit?: number,
|
|
@@ -132,11 +182,21 @@ export function createAnalyticsEngine(providers: ProviderMap) {
|
|
|
132
182
|
): Promise<QueryResult<S>> {
|
|
133
183
|
const range = options?.range ?? '30d'
|
|
134
184
|
const limit = options?.limit ?? 20
|
|
185
|
+
const offset = options?.offset ?? 0
|
|
135
186
|
const entry = catalog[surface]
|
|
136
187
|
const startMs = Date.now()
|
|
137
188
|
|
|
138
189
|
try {
|
|
139
|
-
const data = await invokeSurface(providers, surface,
|
|
190
|
+
const data = await invokeSurface(providers, surface, {
|
|
191
|
+
range,
|
|
192
|
+
limit,
|
|
193
|
+
offset,
|
|
194
|
+
productId: options?.productId,
|
|
195
|
+
purchaseId: options?.purchaseId,
|
|
196
|
+
surveyId: options?.surveyId,
|
|
197
|
+
surveySlug: options?.surveySlug,
|
|
198
|
+
questionId: options?.questionId,
|
|
199
|
+
})
|
|
140
200
|
|
|
141
201
|
if (data === null) {
|
|
142
202
|
return {
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
classifyPurchaseAttribution,
|
|
5
|
+
hasExactShortlinkPurchaseAttribution,
|
|
6
|
+
SHORTLINK_RECOVERY_LANE,
|
|
7
|
+
} from './attribution-recovery'
|
|
8
|
+
|
|
9
|
+
describe('attribution recovery classification', () => {
|
|
10
|
+
it('keeps purchase field attribution ahead of exact shortlink recovery', () => {
|
|
11
|
+
expect(
|
|
12
|
+
classifyPurchaseAttribution({
|
|
13
|
+
purchaseId: 'purch_1',
|
|
14
|
+
fields: { attribution: { source: 'email' } },
|
|
15
|
+
shortlinkAttributions: [
|
|
16
|
+
{
|
|
17
|
+
type: 'purchase',
|
|
18
|
+
metadata: JSON.stringify({ purchaseId: 'purch_1' }),
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
}),
|
|
22
|
+
).toBe('purchase_field')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('recovers an otherwise dark purchase from exact shortlink metadata', () => {
|
|
26
|
+
expect(
|
|
27
|
+
classifyPurchaseAttribution({
|
|
28
|
+
purchaseId: 'purch_2',
|
|
29
|
+
fields: {},
|
|
30
|
+
shortlinkAttributions: [
|
|
31
|
+
{
|
|
32
|
+
type: 'purchase',
|
|
33
|
+
metadata: JSON.stringify({ purchaseId: 'purch_2' }),
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
}),
|
|
37
|
+
).toBe(SHORTLINK_RECOVERY_LANE)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('ignores invalid shortlink attribution metadata', () => {
|
|
41
|
+
expect(
|
|
42
|
+
hasExactShortlinkPurchaseAttribution({
|
|
43
|
+
purchaseId: 'purch_3',
|
|
44
|
+
attributions: [{ type: 'purchase', metadata: '{not json' }],
|
|
45
|
+
}),
|
|
46
|
+
).toBe(false)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('ignores non-matching shortlink attribution metadata', () => {
|
|
50
|
+
expect(
|
|
51
|
+
classifyPurchaseAttribution({
|
|
52
|
+
purchaseId: 'purch_4',
|
|
53
|
+
fields: {},
|
|
54
|
+
shortlinkAttributions: [
|
|
55
|
+
{
|
|
56
|
+
type: 'purchase',
|
|
57
|
+
metadata: JSON.stringify({ purchaseId: 'purch_other' }),
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
}),
|
|
61
|
+
).toBe('dark')
|
|
62
|
+
})
|
|
63
|
+
})
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
export const SHORTLINK_RECOVERY_LANE =
|
|
2
|
+
'recovered_from_shortlink_attribution_table' as const
|
|
3
|
+
|
|
4
|
+
export type PurchaseAttributionClass =
|
|
5
|
+
| 'purchase_field'
|
|
6
|
+
| typeof SHORTLINK_RECOVERY_LANE
|
|
7
|
+
| 'dark'
|
|
8
|
+
|
|
9
|
+
export interface ShortlinkAttributionEvidence {
|
|
10
|
+
type?: string | null
|
|
11
|
+
metadata?: unknown
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
15
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function nonEmptyString(value: unknown) {
|
|
19
|
+
return typeof value === 'string' && value.trim().length > 0
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseMetadata(value: unknown): Record<string, unknown> | null {
|
|
23
|
+
if (typeof value === 'string') {
|
|
24
|
+
try {
|
|
25
|
+
const parsed = JSON.parse(value) as unknown
|
|
26
|
+
return isRecord(parsed) ? parsed : null
|
|
27
|
+
} catch {
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return isRecord(value) ? value : null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Detects durable purchase-field attribution on a purchase fields object.
|
|
37
|
+
*
|
|
38
|
+
* @param fields Unknown purchase fields payload.
|
|
39
|
+
* @returns True when the purchase already has attribution, legacy UTM source, or legacy GA client ID evidence.
|
|
40
|
+
* @example
|
|
41
|
+
* hasPurchaseFieldAttribution({ attribution: { source: 'email' } })
|
|
42
|
+
*/
|
|
43
|
+
export function hasPurchaseFieldAttribution(fields: unknown) {
|
|
44
|
+
const record = isRecord(fields) ? fields : {}
|
|
45
|
+
const attribution = record.attribution
|
|
46
|
+
|
|
47
|
+
return Boolean(
|
|
48
|
+
(isRecord(attribution) && Object.keys(attribution).length > 0) ||
|
|
49
|
+
nonEmptyString(record.utmSource) ||
|
|
50
|
+
nonEmptyString(record.gaClientId),
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Detects exact shortlink purchase evidence for one purchase.
|
|
56
|
+
*
|
|
57
|
+
* @param params.purchaseId Purchase ID that must match attribution metadata.purchaseId.
|
|
58
|
+
* @param params.attributions ShortlinkAttributionEvidence rows to inspect.
|
|
59
|
+
* @returns True only for purchase rows with valid JSON metadata and an exact purchase ID match.
|
|
60
|
+
*/
|
|
61
|
+
export function hasExactShortlinkPurchaseAttribution(params: {
|
|
62
|
+
purchaseId: string
|
|
63
|
+
attributions: readonly ShortlinkAttributionEvidence[]
|
|
64
|
+
}) {
|
|
65
|
+
return params.attributions.some((attribution) => {
|
|
66
|
+
if (attribution.type !== 'purchase') return false
|
|
67
|
+
const metadata = parseMetadata(attribution.metadata)
|
|
68
|
+
return metadata?.purchaseId === params.purchaseId
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Classifies purchase attribution with purchase fields taking precedence.
|
|
74
|
+
*
|
|
75
|
+
* @param params.purchaseId Purchase ID being classified.
|
|
76
|
+
* @param params.fields Purchase fields payload.
|
|
77
|
+
* @param params.shortlinkAttributions ShortlinkAttributionEvidence rows used as exact fallback evidence.
|
|
78
|
+
* @returns A PurchaseAttributionClass: 'purchase_field', SHORTLINK_RECOVERY_LANE, or 'dark'.
|
|
79
|
+
* @example
|
|
80
|
+
* classifyPurchaseAttribution({ purchaseId: 'p1', fields: {}, shortlinkAttributions: [] })
|
|
81
|
+
*/
|
|
82
|
+
export function classifyPurchaseAttribution(params: {
|
|
83
|
+
purchaseId: string
|
|
84
|
+
fields?: unknown
|
|
85
|
+
shortlinkAttributions?: readonly ShortlinkAttributionEvidence[]
|
|
86
|
+
}): PurchaseAttributionClass {
|
|
87
|
+
if (hasPurchaseFieldAttribution(params.fields)) return 'purchase_field'
|
|
88
|
+
if (
|
|
89
|
+
hasExactShortlinkPurchaseAttribution({
|
|
90
|
+
purchaseId: params.purchaseId,
|
|
91
|
+
attributions: params.shortlinkAttributions ?? [],
|
|
92
|
+
})
|
|
93
|
+
) {
|
|
94
|
+
return SHORTLINK_RECOVERY_LANE
|
|
95
|
+
}
|
|
96
|
+
return 'dark'
|
|
97
|
+
}
|