@stacksjs/ts-analytics 0.1.6
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/CHANGELOG.md +68 -0
- package/LICENSE.md +21 -0
- package/README.md +361 -0
- package/bin/cli.ts +169 -0
- package/dist/Analytics.d.ts +558 -0
- package/dist/api.d.ts +109 -0
- package/dist/batching.d.ts +93 -0
- package/dist/chunk-2mx7fq49.js +4 -0
- package/dist/chunk-3z29508k.js +204 -0
- package/dist/chunk-deephkz6.js +56 -0
- package/dist/chunk-j261vgyp.js +305 -0
- package/dist/chunk-xga17tz7.js +207 -0
- package/dist/config.d.ts +132 -0
- package/dist/dashboard/components/index.d.ts +80 -0
- package/dist/dashboard/composables/useAnalytics.d.ts +99 -0
- package/dist/dashboard/index.d.ts +110 -0
- package/dist/dashboard/types/index.d.ts +144 -0
- package/dist/dashboard/utils/index.d.ts +81 -0
- package/dist/dynamodb.d.ts +86 -0
- package/dist/funnels.d.ts +104 -0
- package/dist/geolocation.d.ts +96 -0
- package/dist/index.d.ts +371 -0
- package/dist/index.js +10211 -0
- package/dist/infrastructure/cdk.d.ts +60 -0
- package/dist/infrastructure/cloudformation.d.ts +44 -0
- package/dist/infrastructure/index.d.ts +45 -0
- package/dist/infrastructure/setup.d.ts +133 -0
- package/dist/integrations/cloudflare.d.ts +76 -0
- package/dist/integrations/hono.d.ts +60 -0
- package/dist/integrations/index.d.ts +24 -0
- package/dist/integrations/nuxt.d.ts +7 -0
- package/dist/integrations/nuxt.js +42 -0
- package/dist/integrations/runtime/use-ts-analytics.d.ts +18 -0
- package/dist/integrations/runtime/use-ts-analytics.js +17 -0
- package/dist/integrations/stx.d.ts +88 -0
- package/dist/integrations/stx.js +18 -0
- package/dist/lib/crypto-random.d.ts +4 -0
- package/dist/lib/salt.d.ts +16 -0
- package/dist/local.d.ts +56 -0
- package/dist/model-connector.d.ts +145 -0
- package/dist/models/AggregatedStats.d.ts +9 -0
- package/dist/models/CampaignStats.d.ts +9 -0
- package/dist/models/Conversion.d.ts +11 -0
- package/dist/models/CustomEvent.d.ts +11 -0
- package/dist/models/DeviceStats.d.ts +9 -0
- package/dist/models/EventStats.d.ts +9 -0
- package/dist/models/GeoStats.d.ts +9 -0
- package/dist/models/Goal.d.ts +9 -0
- package/dist/models/GoalStats.d.ts +9 -0
- package/dist/models/PageStats.d.ts +11 -0
- package/dist/models/PageView.d.ts +13 -0
- package/dist/models/RealtimeStats.d.ts +9 -0
- package/dist/models/ReferrerStats.d.ts +9 -0
- package/dist/models/Session.d.ts +11 -0
- package/dist/models/Site.d.ts +11 -0
- package/dist/models/index.d.ts +28 -0
- package/dist/models/types.d.ts +60 -0
- package/dist/sqs-buffering.d.ts +243 -0
- package/dist/stacks-integration.d.ts +159 -0
- package/dist/tracking-script.d.ts +71 -0
- package/dist/tracking.d.ts +17 -0
- package/dist/tracking.js +26 -0
- package/dist/types.d.ts +595 -0
- package/dist/utils/geolocation.d.ts +111 -0
- package/dist/utils/user-agent.d.ts +17 -0
- package/dist/version.d.ts +7 -0
- package/package.json +119 -0
- package/src/Analytics.ts +3349 -0
- package/src/api.ts +1286 -0
- package/src/assets/crosswind.css +2220 -0
- package/src/batching.ts +452 -0
- package/src/components/dashboard/index.ts +18 -0
- package/src/config.ts +456 -0
- package/src/dashboard/Dashboard.stx +1517 -0
- package/src/dashboard/components/AlertCard.stx +177 -0
- package/src/dashboard/components/AnalyticsDashboard.stx +354 -0
- package/src/dashboard/components/AnimatedNumber.stx +86 -0
- package/src/dashboard/components/BarChart.stx +220 -0
- package/src/dashboard/components/BrowserBreakdown.stx +98 -0
- package/src/dashboard/components/BrowsersTable.stx +125 -0
- package/src/dashboard/components/CampaignBreakdown.stx +163 -0
- package/src/dashboard/components/CampaignTable.stx +238 -0
- package/src/dashboard/components/CountryList.stx +101 -0
- package/src/dashboard/components/DataTable.stx +226 -0
- package/src/dashboard/components/DateRangePicker.stx +77 -0
- package/src/dashboard/components/DeviceBreakdown.stx +94 -0
- package/src/dashboard/components/DevicesTable.stx +163 -0
- package/src/dashboard/components/DonutChart.stories.ts +55 -0
- package/src/dashboard/components/DonutChart.stx +157 -0
- package/src/dashboard/components/EmptyState.stx +90 -0
- package/src/dashboard/components/EngagementMetrics.stx +183 -0
- package/src/dashboard/components/EventsSection.stx +192 -0
- package/src/dashboard/components/FilterBar.stx +104 -0
- package/src/dashboard/components/FullAnalyticsDashboard.stx +455 -0
- package/src/dashboard/components/FunnelChart.stx +142 -0
- package/src/dashboard/components/GeoTable.stx +337 -0
- package/src/dashboard/components/GoalsPanel.stx +109 -0
- package/src/dashboard/components/Header.stx +39 -0
- package/src/dashboard/components/HeatmapChart.stx +140 -0
- package/src/dashboard/components/LiveActivityFeed.stx +172 -0
- package/src/dashboard/components/MetricComparison.stx +106 -0
- package/src/dashboard/components/MiniStats.stx +96 -0
- package/src/dashboard/components/OSBreakdown.stx +124 -0
- package/src/dashboard/components/PageDetailCard.stx +102 -0
- package/src/dashboard/components/PagesTable.stx +127 -0
- package/src/dashboard/components/ProgressRing.stx +89 -0
- package/src/dashboard/components/RealtimeCounter.stories.ts +45 -0
- package/src/dashboard/components/RealtimeCounter.stx +46 -0
- package/src/dashboard/components/ReferrersTable.stx +180 -0
- package/src/dashboard/components/SparklineChart.stx +160 -0
- package/src/dashboard/components/StatCard.stories.ts +58 -0
- package/src/dashboard/components/StatCard.stx +90 -0
- package/src/dashboard/components/SummaryStats.stx +81 -0
- package/src/dashboard/components/TabNav.stx +66 -0
- package/src/dashboard/components/ThemeSwitcher.stx +124 -0
- package/src/dashboard/components/TimeSeriesChart.stx +106 -0
- package/src/dashboard/components/TopList.stories.ts +58 -0
- package/src/dashboard/components/TopList.stx +74 -0
- package/src/dashboard/components/TrendIndicator.stx +84 -0
- package/src/dashboard/components/heatmap/ClickHeatmap.stx +264 -0
- package/src/dashboard/components/heatmap/ElementClickList.stx +125 -0
- package/src/dashboard/components/heatmap/HeatmapControls.stx +125 -0
- package/src/dashboard/components/heatmap/HeatmapLegend.stx +69 -0
- package/src/dashboard/components/heatmap/PageHeatmap.stx +264 -0
- package/src/dashboard/components/heatmap/ScrollHeatmap.stx +127 -0
- package/src/dashboard/components/heatmap/index.ts +12 -0
- package/src/dashboard/components/index.ts +86 -0
- package/src/dashboard/composables/useAnalytics.ts +465 -0
- package/src/dashboard/demo/DemoApp.stx +370 -0
- package/src/dashboard/demo/index.ts +8 -0
- package/src/dashboard/demo/mockData.ts +234 -0
- package/src/dashboard/index.ts +117 -0
- package/src/dashboard/stx-shim.d.ts +4 -0
- package/src/dashboard/types/index.ts +203 -0
- package/src/dashboard/utils/index.ts +426 -0
- package/src/dynamodb.ts +344 -0
- package/src/funnels.ts +534 -0
- package/src/geolocation.ts +515 -0
- package/src/handlers/alerts.ts +328 -0
- package/src/handlers/annotations.ts +128 -0
- package/src/handlers/api-keys.ts +240 -0
- package/src/handlers/auth.ts +1020 -0
- package/src/handlers/authz.ts +137 -0
- package/src/handlers/collect.ts +724 -0
- package/src/handlers/data.ts +625 -0
- package/src/handlers/experiments.ts +138 -0
- package/src/handlers/funnels.ts +216 -0
- package/src/handlers/goals.ts +218 -0
- package/src/handlers/heatmaps.ts +272 -0
- package/src/handlers/index.ts +56 -0
- package/src/handlers/lib/read-cache.ts +63 -0
- package/src/handlers/misc.ts +486 -0
- package/src/handlers/oauth.ts +233 -0
- package/src/handlers/performance.ts +388 -0
- package/src/handlers/sessions.ts +413 -0
- package/src/handlers/sharing.ts +198 -0
- package/src/handlers/stats.ts +1368 -0
- package/src/handlers/team.ts +161 -0
- package/src/handlers/uptime.ts +283 -0
- package/src/handlers/views.ts +390 -0
- package/src/handlers/webhooks.ts +226 -0
- package/src/heatmap/index.ts +32 -0
- package/src/heatmap/tracking-script.ts +452 -0
- package/src/heatmap/types.ts +79 -0
- package/src/index.ts +387 -0
- package/src/infrastructure/cdk.ts +496 -0
- package/src/infrastructure/cloudformation.ts +595 -0
- package/src/infrastructure/index.ts +48 -0
- package/src/infrastructure/setup.ts +611 -0
- package/src/integrations/cloudflare.ts +732 -0
- package/src/integrations/hono.ts +589 -0
- package/src/integrations/index.ts +27 -0
- package/src/integrations/nuxt.ts +78 -0
- package/src/integrations/runtime/use-ts-analytics.ts +32 -0
- package/src/integrations/stx.ts +138 -0
- package/src/jobs/index.ts +127 -0
- package/src/lib/crypto-random.ts +41 -0
- package/src/lib/ddb-errors.ts +20 -0
- package/src/lib/dynamodb.ts +216 -0
- package/src/lib/email.ts +38 -0
- package/src/lib/ga-import.ts +471 -0
- package/src/lib/ga4-api.ts +244 -0
- package/src/lib/goals.ts +205 -0
- package/src/lib/index.ts +6 -0
- package/src/lib/ingest-counters.ts +123 -0
- package/src/lib/log.ts +36 -0
- package/src/lib/membership.ts +98 -0
- package/src/lib/plans.ts +90 -0
- package/src/lib/quota.ts +46 -0
- package/src/lib/rate-limit.ts +27 -0
- package/src/lib/rollups.ts +472 -0
- package/src/lib/salt.ts +97 -0
- package/src/lib/scheduler.ts +97 -0
- package/src/lib/significance.ts +66 -0
- package/src/lib/site-retention.ts +56 -0
- package/src/local.ts +360 -0
- package/src/model-connector.ts +616 -0
- package/src/models/AggregatedStats.ts +162 -0
- package/src/models/CampaignStats.ts +141 -0
- package/src/models/Conversion.ts +135 -0
- package/src/models/CustomEvent.ts +123 -0
- package/src/models/DeviceStats.ts +105 -0
- package/src/models/EventStats.ts +108 -0
- package/src/models/GeoStats.ts +112 -0
- package/src/models/Goal.ts +105 -0
- package/src/models/GoalStats.ts +106 -0
- package/src/models/PageStats.ts +152 -0
- package/src/models/PageView.ts +277 -0
- package/src/models/RealtimeStats.ts +96 -0
- package/src/models/ReferrerStats.ts +115 -0
- package/src/models/Session.ts +235 -0
- package/src/models/Site.ts +115 -0
- package/src/models/index.ts +50 -0
- package/src/models/orm/index.ts +2043 -0
- package/src/models/types.ts +71 -0
- package/src/router.ts +521 -0
- package/src/sqs-buffering.ts +806 -0
- package/src/stacks-integration.ts +527 -0
- package/src/tracking-script.ts +944 -0
- package/src/tracking.ts +27 -0
- package/src/types/analytics.ts +219 -0
- package/src/types/api.ts +75 -0
- package/src/types/bun-router.d.ts +15 -0
- package/src/types/dashboard.ts +62 -0
- package/src/types/index.ts +66 -0
- package/src/types/stx.d.ts +27 -0
- package/src/types/window.d.ts +101 -0
- package/src/types.ts +911 -0
- package/src/utils/cache.ts +148 -0
- package/src/utils/date.ts +118 -0
- package/src/utils/filters.ts +71 -0
- package/src/utils/geolocation.ts +323 -0
- package/src/utils/index.ts +9 -0
- package/src/utils/response.ts +180 -0
- package/src/utils/timezone-country.ts +242 -0
- package/src/utils/user-agent.ts +110 -0
- package/src/version.ts +7 -0
|
@@ -0,0 +1,2043 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ts-analytics ORM Models
|
|
3
|
+
*
|
|
4
|
+
* ActiveRecord-style ORM models for DynamoDB using bun-query-builder.
|
|
5
|
+
* These models provide a clean API for working with analytics data.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* import { PageView, Session, CustomEvent, configureAnalytics } from './models/orm'
|
|
10
|
+
*
|
|
11
|
+
* // Configure the connection
|
|
12
|
+
* configureAnalytics({
|
|
13
|
+
* tableName: 'ts-analytics',
|
|
14
|
+
* region: 'us-east-1',
|
|
15
|
+
* })
|
|
16
|
+
*
|
|
17
|
+
* // Create a page view
|
|
18
|
+
* const pv = await PageView.create({
|
|
19
|
+
* id: generateId(),
|
|
20
|
+
* siteId: 'my-site',
|
|
21
|
+
* visitorId: 'abc123',
|
|
22
|
+
* sessionId: 'sess-123',
|
|
23
|
+
* path: '/home',
|
|
24
|
+
* hostname: 'example.com',
|
|
25
|
+
* })
|
|
26
|
+
*
|
|
27
|
+
* // Query page views for a site
|
|
28
|
+
* const views = await PageView.forSite('my-site')
|
|
29
|
+
* .since(new Date('2024-01-01'))
|
|
30
|
+
* .limit(100)
|
|
31
|
+
* .get()
|
|
32
|
+
*
|
|
33
|
+
* // Find a session
|
|
34
|
+
* const session = await Session.find('sess-123')
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { Model, configureModels, DynamoDBClient, createClient } from 'bun-query-builder/dynamodb'
|
|
39
|
+
import { isConditionalCheckFailed } from '../../lib/ddb-errors'
|
|
40
|
+
import type { DeviceType } from '../../types'
|
|
41
|
+
import { getConfig, getTtlForEntity } from '../../config'
|
|
42
|
+
import { rawTtlForSite } from '../../lib/site-retention'
|
|
43
|
+
|
|
44
|
+
// ============================================================================
|
|
45
|
+
// Configuration
|
|
46
|
+
// ============================================================================
|
|
47
|
+
|
|
48
|
+
// Use a config object for reliable bundling - object reference is preserved
|
|
49
|
+
const analyticsConfig = {
|
|
50
|
+
tableName: process.env.ANALYTICS_TABLE_NAME || 'ts-analytics',
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Getter function that always returns current config value
|
|
54
|
+
export function getTableName(): string {
|
|
55
|
+
return analyticsConfig.tableName
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
export interface AnalyticsConfig {
|
|
60
|
+
tableName?: string
|
|
61
|
+
region?: string
|
|
62
|
+
endpoint?: string
|
|
63
|
+
credentials?: {
|
|
64
|
+
accessKeyId: string
|
|
65
|
+
secretAccessKey: string
|
|
66
|
+
sessionToken?: string
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Shared client factory for model statics — honors the local-endpoint
|
|
72
|
+
* override (ANALYTICS_DYNAMODB_ENDPOINT, #177) that the plain
|
|
73
|
+
* createClient({region}) call sites bypassed.
|
|
74
|
+
*/
|
|
75
|
+
function ormClient(): ReturnType<typeof createClient> {
|
|
76
|
+
const endpoint = process.env.ANALYTICS_DYNAMODB_ENDPOINT || undefined
|
|
77
|
+
return createClient({
|
|
78
|
+
region: process.env.AWS_REGION || 'us-east-1',
|
|
79
|
+
...(endpoint ? { endpoint } : {}),
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Configure the analytics models
|
|
85
|
+
*/
|
|
86
|
+
export function configureAnalytics(config: AnalyticsConfig): void {
|
|
87
|
+
if (config.tableName) {
|
|
88
|
+
analyticsConfig.tableName = config.tableName
|
|
89
|
+
console.log('[ORM] Configured tableName:', analyticsConfig.tableName)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
configureModels({
|
|
93
|
+
region: config.region,
|
|
94
|
+
endpoint: config.endpoint,
|
|
95
|
+
credentials: config.credentials,
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ============================================================================
|
|
100
|
+
// PageView Model
|
|
101
|
+
// ============================================================================
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* PageView model for tracking page views
|
|
105
|
+
*
|
|
106
|
+
* DynamoDB Keys:
|
|
107
|
+
* - PK: SITE#{siteId}
|
|
108
|
+
* - SK: PAGEVIEW#{timestamp}#{id}
|
|
109
|
+
* - GSI1PK: SITE#{siteId}#DATE#{date}
|
|
110
|
+
* - GSI1SK: PATH#{path}
|
|
111
|
+
*/
|
|
112
|
+
export class PageView extends Model {
|
|
113
|
+
static get tableName(): string { return getTableName() }
|
|
114
|
+
static pkPrefix = 'SITE'
|
|
115
|
+
static skPrefix = 'PAGEVIEW'
|
|
116
|
+
static primaryKey = 'id'
|
|
117
|
+
static timestamps = true
|
|
118
|
+
|
|
119
|
+
// Attributes
|
|
120
|
+
id!: string
|
|
121
|
+
siteId!: string
|
|
122
|
+
visitorId!: string
|
|
123
|
+
sessionId!: string
|
|
124
|
+
path!: string
|
|
125
|
+
hostname!: string
|
|
126
|
+
title?: string
|
|
127
|
+
referrer?: string
|
|
128
|
+
referrerSource?: string
|
|
129
|
+
utmSource?: string
|
|
130
|
+
utmMedium?: string
|
|
131
|
+
utmCampaign?: string
|
|
132
|
+
utmContent?: string
|
|
133
|
+
utmTerm?: string
|
|
134
|
+
gclid?: string
|
|
135
|
+
fbclid?: string
|
|
136
|
+
country?: string
|
|
137
|
+
region?: string
|
|
138
|
+
city?: string
|
|
139
|
+
deviceType?: DeviceType
|
|
140
|
+
browser?: string
|
|
141
|
+
browserVersion?: string
|
|
142
|
+
os?: string
|
|
143
|
+
osVersion?: string
|
|
144
|
+
screenWidth?: number
|
|
145
|
+
screenHeight?: number
|
|
146
|
+
isUnique!: boolean
|
|
147
|
+
isBounce!: boolean
|
|
148
|
+
timeOnPage?: number
|
|
149
|
+
timestamp!: Date | string
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Generate the partition key for this page view
|
|
153
|
+
*/
|
|
154
|
+
getPk(): string {
|
|
155
|
+
return `SITE#${this.siteId}`
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Generate the sort key for this page view
|
|
160
|
+
*/
|
|
161
|
+
getSk(): string {
|
|
162
|
+
const ts = this.timestamp instanceof Date ? this.timestamp.toISOString() : this.timestamp
|
|
163
|
+
return `PAGEVIEW#${ts}#${this.id}`
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Query page views for a specific site
|
|
168
|
+
*/
|
|
169
|
+
static forSite(siteId: string): PageViewQueryBuilder {
|
|
170
|
+
return new PageViewQueryBuilder(siteId)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Create a page view with proper key generation
|
|
175
|
+
*/
|
|
176
|
+
static async record(data: Omit<PageViewData, 'pk' | 'sk' | 'gsi1pk' | 'gsi1sk'>): Promise<PageView> {
|
|
177
|
+
const timestamp = data.timestamp instanceof Date ? data.timestamp : new Date(data.timestamp || Date.now())
|
|
178
|
+
const dateStr = timestamp.toISOString().slice(0, 10)
|
|
179
|
+
|
|
180
|
+
const item = {
|
|
181
|
+
...data,
|
|
182
|
+
pk: `SITE#${data.siteId}`,
|
|
183
|
+
sk: `PAGEVIEW#${timestamp.toISOString()}#${data.id}`,
|
|
184
|
+
gsi1pk: `SITE#${data.siteId}#DATE#${dateStr}`,
|
|
185
|
+
gsi1sk: `PATH#${data.path}`,
|
|
186
|
+
timestamp: timestamp.toISOString(),
|
|
187
|
+
_et: 'PageView',
|
|
188
|
+
// Raw-event TTL so visitor-hash/path/referrer/geo/UTM rows expire per the
|
|
189
|
+
// configured retention instead of living forever (#143).
|
|
190
|
+
ttl: rawTtlForSite(data.siteId),
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const client = ormClient()
|
|
194
|
+
|
|
195
|
+
await client.putItem({
|
|
196
|
+
TableName: getTableName(),
|
|
197
|
+
Item: marshall(item),
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
return new PageView(item)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
interface PageViewData {
|
|
205
|
+
id: string
|
|
206
|
+
siteId: string
|
|
207
|
+
visitorId: string
|
|
208
|
+
sessionId: string
|
|
209
|
+
path: string
|
|
210
|
+
hostname: string
|
|
211
|
+
title?: string
|
|
212
|
+
referrer?: string
|
|
213
|
+
referrerSource?: string
|
|
214
|
+
utmSource?: string
|
|
215
|
+
utmMedium?: string
|
|
216
|
+
utmCampaign?: string
|
|
217
|
+
utmContent?: string
|
|
218
|
+
utmTerm?: string
|
|
219
|
+
gclid?: string
|
|
220
|
+
fbclid?: string
|
|
221
|
+
deviceType?: DeviceType
|
|
222
|
+
browser?: string
|
|
223
|
+
os?: string
|
|
224
|
+
country?: string
|
|
225
|
+
region?: string
|
|
226
|
+
city?: string
|
|
227
|
+
screenWidth?: number
|
|
228
|
+
screenHeight?: number
|
|
229
|
+
isUnique: boolean
|
|
230
|
+
isBounce: boolean
|
|
231
|
+
timestamp?: Date | string
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
class PageViewQueryBuilder {
|
|
235
|
+
private siteId: string
|
|
236
|
+
private startDate?: Date
|
|
237
|
+
private endDate?: Date
|
|
238
|
+
private _limit?: number
|
|
239
|
+
private _path?: string
|
|
240
|
+
|
|
241
|
+
constructor(siteId: string) {
|
|
242
|
+
this.siteId = siteId
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
since(date: Date): this {
|
|
246
|
+
this.startDate = date
|
|
247
|
+
return this
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
until(date: Date): this {
|
|
251
|
+
this.endDate = date
|
|
252
|
+
return this
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
onPath(path: string): this {
|
|
256
|
+
this._path = path
|
|
257
|
+
return this
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
limit(count: number): this {
|
|
261
|
+
this._limit = count
|
|
262
|
+
return this
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async get(): Promise<PageView[]> {
|
|
266
|
+
const client = ormClient()
|
|
267
|
+
|
|
268
|
+
const startKey = this.startDate
|
|
269
|
+
? `PAGEVIEW#${this.startDate.toISOString()}`
|
|
270
|
+
: 'PAGEVIEW#'
|
|
271
|
+
const endKey = this.endDate
|
|
272
|
+
? `PAGEVIEW#${this.endDate.toISOString()}`
|
|
273
|
+
: 'PAGEVIEW#\uffff'
|
|
274
|
+
|
|
275
|
+
const result = await client.query({
|
|
276
|
+
TableName: getTableName(),
|
|
277
|
+
KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
|
|
278
|
+
ExpressionAttributeValues: {
|
|
279
|
+
':pk': { S: `SITE#${this.siteId}` },
|
|
280
|
+
':start': { S: startKey },
|
|
281
|
+
':end': { S: endKey },
|
|
282
|
+
},
|
|
283
|
+
...(this._limit && { Limit: this._limit }),
|
|
284
|
+
ScanIndexForward: false,
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
return (result.Items || []).map((item: any) => new PageView(unmarshall(item)))
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async count(): Promise<number> {
|
|
291
|
+
const client = ormClient()
|
|
292
|
+
|
|
293
|
+
const startKey = this.startDate
|
|
294
|
+
? `PAGEVIEW#${this.startDate.toISOString()}`
|
|
295
|
+
: 'PAGEVIEW#'
|
|
296
|
+
const endKey = this.endDate
|
|
297
|
+
? `PAGEVIEW#${this.endDate.toISOString()}`
|
|
298
|
+
: 'PAGEVIEW#\uffff'
|
|
299
|
+
|
|
300
|
+
const result = await client.query({
|
|
301
|
+
TableName: getTableName(),
|
|
302
|
+
KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
|
|
303
|
+
ExpressionAttributeValues: {
|
|
304
|
+
':pk': { S: `SITE#${this.siteId}` },
|
|
305
|
+
':start': { S: startKey },
|
|
306
|
+
':end': { S: endKey },
|
|
307
|
+
},
|
|
308
|
+
Select: 'COUNT',
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
return result.Count || 0
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ============================================================================
|
|
316
|
+
// Session Model
|
|
317
|
+
// ============================================================================
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Session model for tracking visitor sessions
|
|
321
|
+
*
|
|
322
|
+
* DynamoDB Keys:
|
|
323
|
+
* - PK: SITE#{siteId}
|
|
324
|
+
* - SK: SESSION#{sessionId}
|
|
325
|
+
*/
|
|
326
|
+
export class Session extends Model {
|
|
327
|
+
static get tableName(): string { return getTableName() }
|
|
328
|
+
static pkPrefix = 'SITE'
|
|
329
|
+
static skPrefix = 'SESSION'
|
|
330
|
+
static primaryKey = 'id'
|
|
331
|
+
static timestamps = true
|
|
332
|
+
|
|
333
|
+
// Attributes
|
|
334
|
+
id!: string
|
|
335
|
+
siteId!: string
|
|
336
|
+
visitorId!: string
|
|
337
|
+
entryPath!: string
|
|
338
|
+
exitPath!: string
|
|
339
|
+
referrer?: string
|
|
340
|
+
referrerSource?: string
|
|
341
|
+
utmSource?: string
|
|
342
|
+
utmMedium?: string
|
|
343
|
+
utmCampaign?: string
|
|
344
|
+
utmContent?: string
|
|
345
|
+
utmTerm?: string
|
|
346
|
+
gclid?: string
|
|
347
|
+
fbclid?: string
|
|
348
|
+
country?: string
|
|
349
|
+
region?: string
|
|
350
|
+
city?: string
|
|
351
|
+
deviceType?: DeviceType
|
|
352
|
+
browser?: string
|
|
353
|
+
os?: string
|
|
354
|
+
pageViewCount!: number
|
|
355
|
+
eventCount!: number
|
|
356
|
+
isBounce!: boolean
|
|
357
|
+
duration!: number
|
|
358
|
+
startedAt!: Date | string
|
|
359
|
+
endedAt!: Date | string
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Find a session by site and session ID
|
|
363
|
+
*/
|
|
364
|
+
static async findByKey(siteId: string, sessionId: string): Promise<Session | null> {
|
|
365
|
+
const client = ormClient()
|
|
366
|
+
|
|
367
|
+
const result = await client.getItem({
|
|
368
|
+
TableName: getTableName(),
|
|
369
|
+
Key: {
|
|
370
|
+
pk: { S: `SITE#${siteId}` },
|
|
371
|
+
sk: { S: `SESSION#${sessionId}` },
|
|
372
|
+
},
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
if (!result.Item) return null
|
|
376
|
+
return new Session(unmarshall(result.Item))
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Query sessions for a specific site
|
|
381
|
+
*/
|
|
382
|
+
static forSite(siteId: string): SessionQueryBuilder {
|
|
383
|
+
return new SessionQueryBuilder(siteId)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Create or update a session
|
|
388
|
+
*/
|
|
389
|
+
static async upsert(data: SessionData): Promise<Session> {
|
|
390
|
+
const startedAtIso = data.startedAt instanceof Date ? data.startedAt.toISOString() : data.startedAt
|
|
391
|
+
const item = {
|
|
392
|
+
...data,
|
|
393
|
+
pk: `SITE#${data.siteId}`,
|
|
394
|
+
sk: `SESSION#${data.id}`,
|
|
395
|
+
// Time-keyed index entry (#171): sessions are id-keyed in the main
|
|
396
|
+
// table, so every date-ranged read had to scan the whole partition.
|
|
397
|
+
// GSI1 gives an O(range) query: gsi1pk = SITE#{id}#SESSIONS,
|
|
398
|
+
// gsi1sk = startedAt ISO (+id tiebreak).
|
|
399
|
+
gsi1pk: `SITE#${data.siteId}#SESSIONS`,
|
|
400
|
+
gsi1sk: `${startedAtIso}#${data.id}`,
|
|
401
|
+
startedAt: startedAtIso,
|
|
402
|
+
endedAt: data.endedAt instanceof Date ? data.endedAt.toISOString() : data.endedAt,
|
|
403
|
+
_et: 'Session',
|
|
404
|
+
ttl: rawTtlForSite(data.siteId),
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const client = ormClient()
|
|
408
|
+
|
|
409
|
+
await client.putItem({
|
|
410
|
+
TableName: getTableName(),
|
|
411
|
+
Item: marshall(item),
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
return new Session(item)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Update session metrics
|
|
419
|
+
*/
|
|
420
|
+
async updateMetrics(updates: Partial<Pick<Session, 'exitPath' | 'pageViewCount' | 'eventCount' | 'isBounce' | 'duration' | 'endedAt'>>): Promise<this> {
|
|
421
|
+
const client = ormClient()
|
|
422
|
+
|
|
423
|
+
const setParts: string[] = []
|
|
424
|
+
const exprNames: Record<string, string> = {}
|
|
425
|
+
const exprValues: Record<string, any> = {}
|
|
426
|
+
let idx = 0
|
|
427
|
+
|
|
428
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
429
|
+
if (value !== undefined) {
|
|
430
|
+
const nameKey = `#attr${idx}`
|
|
431
|
+
const valueKey = `:val${idx}`
|
|
432
|
+
exprNames[nameKey] = key
|
|
433
|
+
exprValues[valueKey] = marshallValue(key === 'endedAt' && value instanceof Date ? value.toISOString() : value)
|
|
434
|
+
setParts.push(`${nameKey} = ${valueKey}`)
|
|
435
|
+
;(this as any)[key] = value
|
|
436
|
+
idx++
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (setParts.length > 0) {
|
|
441
|
+
await client.updateItem({
|
|
442
|
+
TableName: getTableName(),
|
|
443
|
+
Key: {
|
|
444
|
+
pk: { S: `SITE#${this.siteId}` },
|
|
445
|
+
sk: { S: `SESSION#${this.id}` },
|
|
446
|
+
},
|
|
447
|
+
UpdateExpression: `SET ${setParts.join(', ')}`,
|
|
448
|
+
ExpressionAttributeNames: exprNames,
|
|
449
|
+
ExpressionAttributeValues: exprValues,
|
|
450
|
+
})
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return this
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Create the session only if it does not already exist. Returns false when a
|
|
458
|
+
* concurrent request already created it, so the caller can fall back to an
|
|
459
|
+
* atomic increment instead of overwriting its counts (#161).
|
|
460
|
+
*/
|
|
461
|
+
static async createIfAbsent(data: SessionData): Promise<boolean> {
|
|
462
|
+
const startedAtIso = data.startedAt instanceof Date ? data.startedAt.toISOString() : data.startedAt
|
|
463
|
+
const item = {
|
|
464
|
+
...data,
|
|
465
|
+
pk: `SITE#${data.siteId}`,
|
|
466
|
+
sk: `SESSION#${data.id}`,
|
|
467
|
+
// Time-keyed index entry (#171): sessions are id-keyed in the main
|
|
468
|
+
// table, so every date-ranged read had to scan the whole partition.
|
|
469
|
+
// GSI1 gives an O(range) query: gsi1pk = SITE#{id}#SESSIONS,
|
|
470
|
+
// gsi1sk = startedAt ISO (+id tiebreak).
|
|
471
|
+
gsi1pk: `SITE#${data.siteId}#SESSIONS`,
|
|
472
|
+
gsi1sk: `${startedAtIso}#${data.id}`,
|
|
473
|
+
startedAt: startedAtIso,
|
|
474
|
+
endedAt: data.endedAt instanceof Date ? data.endedAt.toISOString() : data.endedAt,
|
|
475
|
+
_et: 'Session',
|
|
476
|
+
ttl: rawTtlForSite(data.siteId),
|
|
477
|
+
}
|
|
478
|
+
const client = ormClient()
|
|
479
|
+
try {
|
|
480
|
+
await client.putItem({
|
|
481
|
+
TableName: getTableName(),
|
|
482
|
+
Item: marshall(item),
|
|
483
|
+
ConditionExpression: 'attribute_not_exists(pk)',
|
|
484
|
+
})
|
|
485
|
+
return true
|
|
486
|
+
}
|
|
487
|
+
catch (e: any) {
|
|
488
|
+
if (isConditionalCheckFailed(e))
|
|
489
|
+
return false
|
|
490
|
+
throw e
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Atomically increment session counters and update last-activity fields via a
|
|
496
|
+
* DynamoDB UpdateExpression (ADD for counters, SET for the rest). Unlike a
|
|
497
|
+
* read-modify-write putItem, concurrent pageviews/events can't lose each
|
|
498
|
+
* other's increments (#161). Only touches an already-existing session — the
|
|
499
|
+
* `attribute_exists` guard avoids recreating a partial row if it has expired.
|
|
500
|
+
*/
|
|
501
|
+
static async incrementMetrics(siteId: string, id: string, updates: {
|
|
502
|
+
incPageViews?: number
|
|
503
|
+
incEvents?: number
|
|
504
|
+
exitPath?: string
|
|
505
|
+
endedAt?: Date | string
|
|
506
|
+
duration?: number
|
|
507
|
+
isBounce?: boolean
|
|
508
|
+
/** Accumulate real engaged time (ms) from departure pings (#167). */
|
|
509
|
+
addActiveMs?: number
|
|
510
|
+
/** Backfill the country only when the session doesn't have one yet. */
|
|
511
|
+
countryIfAbsent?: string
|
|
512
|
+
}): Promise<void> {
|
|
513
|
+
const setParts: string[] = []
|
|
514
|
+
const addParts: string[] = []
|
|
515
|
+
const names: Record<string, string> = {}
|
|
516
|
+
const values: Record<string, any> = {}
|
|
517
|
+
const field = (attr: string, key: string, value: unknown, op: 'SET' | 'ADD'): void => {
|
|
518
|
+
names[`#${key}`] = attr
|
|
519
|
+
values[`:${key}`] = marshallValue(value)
|
|
520
|
+
if (op === 'ADD')
|
|
521
|
+
addParts.push(`#${key} :${key}`)
|
|
522
|
+
else
|
|
523
|
+
setParts.push(`#${key} = :${key}`)
|
|
524
|
+
}
|
|
525
|
+
if (updates.incPageViews)
|
|
526
|
+
field('pageViewCount', 'pv', updates.incPageViews, 'ADD')
|
|
527
|
+
if (updates.incEvents)
|
|
528
|
+
field('eventCount', 'ev', updates.incEvents, 'ADD')
|
|
529
|
+
if (updates.exitPath !== undefined)
|
|
530
|
+
field('exitPath', 'ep', updates.exitPath, 'SET')
|
|
531
|
+
if (updates.endedAt !== undefined)
|
|
532
|
+
field('endedAt', 'ea', updates.endedAt instanceof Date ? updates.endedAt.toISOString() : updates.endedAt, 'SET')
|
|
533
|
+
if (updates.duration !== undefined)
|
|
534
|
+
field('duration', 'du', updates.duration, 'SET')
|
|
535
|
+
if (updates.isBounce !== undefined)
|
|
536
|
+
field('isBounce', 'bo', updates.isBounce, 'SET')
|
|
537
|
+
if (updates.addActiveMs)
|
|
538
|
+
field('activeTime', 'at', updates.addActiveMs, 'ADD')
|
|
539
|
+
if (updates.countryIfAbsent) {
|
|
540
|
+
// Sessions created before geo resolved (old tracker, event-first sessions)
|
|
541
|
+
// pick up the country from a later pageview instead of staying countryless.
|
|
542
|
+
names['#co'] = 'country'
|
|
543
|
+
values[':co'] = marshallValue(updates.countryIfAbsent)
|
|
544
|
+
setParts.push('#co = if_not_exists(#co, :co)')
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const clauses: string[] = []
|
|
548
|
+
if (setParts.length > 0)
|
|
549
|
+
clauses.push(`SET ${setParts.join(', ')}`)
|
|
550
|
+
if (addParts.length > 0)
|
|
551
|
+
clauses.push(`ADD ${addParts.join(', ')}`)
|
|
552
|
+
if (clauses.length === 0)
|
|
553
|
+
return
|
|
554
|
+
|
|
555
|
+
const client = ormClient()
|
|
556
|
+
try {
|
|
557
|
+
await client.updateItem({
|
|
558
|
+
TableName: getTableName(),
|
|
559
|
+
Key: {
|
|
560
|
+
pk: { S: `SITE#${siteId}` },
|
|
561
|
+
sk: { S: `SESSION#${id}` },
|
|
562
|
+
},
|
|
563
|
+
UpdateExpression: clauses.join(' '),
|
|
564
|
+
ExpressionAttributeNames: names,
|
|
565
|
+
ExpressionAttributeValues: values,
|
|
566
|
+
ConditionExpression: 'attribute_exists(pk)',
|
|
567
|
+
})
|
|
568
|
+
}
|
|
569
|
+
catch (e: any) {
|
|
570
|
+
// Session vanished between read and write (expiry/race) — skip rather than
|
|
571
|
+
// recreate a partial row missing entryPath/startedAt/dimensions.
|
|
572
|
+
if (isConditionalCheckFailed(e))
|
|
573
|
+
return
|
|
574
|
+
throw e
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
interface SessionData {
|
|
580
|
+
id: string
|
|
581
|
+
siteId: string
|
|
582
|
+
visitorId: string
|
|
583
|
+
entryPath: string
|
|
584
|
+
exitPath: string
|
|
585
|
+
referrer?: string
|
|
586
|
+
referrerSource?: string
|
|
587
|
+
utmSource?: string
|
|
588
|
+
utmMedium?: string
|
|
589
|
+
utmCampaign?: string
|
|
590
|
+
utmContent?: string
|
|
591
|
+
utmTerm?: string
|
|
592
|
+
gclid?: string
|
|
593
|
+
fbclid?: string
|
|
594
|
+
country?: string
|
|
595
|
+
region?: string
|
|
596
|
+
city?: string
|
|
597
|
+
deviceType?: DeviceType
|
|
598
|
+
browser?: string
|
|
599
|
+
os?: string
|
|
600
|
+
pageViewCount: number
|
|
601
|
+
eventCount: number
|
|
602
|
+
isBounce: boolean
|
|
603
|
+
duration: number
|
|
604
|
+
startedAt: Date | string
|
|
605
|
+
endedAt: Date | string
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
class SessionQueryBuilder {
|
|
609
|
+
private siteId: string
|
|
610
|
+
private startDate?: Date
|
|
611
|
+
private endDate?: Date
|
|
612
|
+
private _limit?: number
|
|
613
|
+
|
|
614
|
+
constructor(siteId: string) {
|
|
615
|
+
this.siteId = siteId
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
since(date: Date): this {
|
|
619
|
+
this.startDate = date
|
|
620
|
+
return this
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
until(date: Date): this {
|
|
624
|
+
this.endDate = date
|
|
625
|
+
return this
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
limit(count: number): this {
|
|
629
|
+
this._limit = count
|
|
630
|
+
return this
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
async get(): Promise<Session[]> {
|
|
634
|
+
const client = ormClient()
|
|
635
|
+
|
|
636
|
+
const result = await client.query({
|
|
637
|
+
TableName: getTableName(),
|
|
638
|
+
KeyConditionExpression: 'pk = :pk AND begins_with(sk, :prefix)',
|
|
639
|
+
ExpressionAttributeValues: {
|
|
640
|
+
':pk': { S: `SITE#${this.siteId}` },
|
|
641
|
+
':prefix': { S: 'SESSION#' },
|
|
642
|
+
},
|
|
643
|
+
...(this._limit && { Limit: this._limit }),
|
|
644
|
+
})
|
|
645
|
+
|
|
646
|
+
let sessions = (result.Items || []).map((item: any) => new Session(unmarshall(item)))
|
|
647
|
+
|
|
648
|
+
// Filter by date range if specified
|
|
649
|
+
if (this.startDate || this.endDate) {
|
|
650
|
+
sessions = sessions.filter(s => {
|
|
651
|
+
const sessionStart = new Date(s.startedAt)
|
|
652
|
+
if (this.startDate && sessionStart < this.startDate) return false
|
|
653
|
+
if (this.endDate && sessionStart > this.endDate) return false
|
|
654
|
+
return true
|
|
655
|
+
})
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
return sessions
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
async count(): Promise<number> {
|
|
662
|
+
const sessions = await this.get()
|
|
663
|
+
return sessions.length
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// ============================================================================
|
|
668
|
+
// CustomEvent Model
|
|
669
|
+
// ============================================================================
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* CustomEvent model for tracking custom events
|
|
673
|
+
*
|
|
674
|
+
* DynamoDB Keys:
|
|
675
|
+
* - PK: SITE#{siteId}
|
|
676
|
+
* - SK: EVENT#{timestamp}#{id}
|
|
677
|
+
* - GSI1PK: SITE#{siteId}#DATE#{date}
|
|
678
|
+
* - GSI1SK: EVENT#{name}
|
|
679
|
+
*/
|
|
680
|
+
export class CustomEvent extends Model {
|
|
681
|
+
static get tableName(): string { return getTableName() }
|
|
682
|
+
static pkPrefix = 'SITE'
|
|
683
|
+
static skPrefix = 'EVENT'
|
|
684
|
+
static primaryKey = 'id'
|
|
685
|
+
static timestamps = true
|
|
686
|
+
|
|
687
|
+
// Attributes
|
|
688
|
+
id!: string
|
|
689
|
+
siteId!: string
|
|
690
|
+
visitorId!: string
|
|
691
|
+
sessionId!: string
|
|
692
|
+
name!: string
|
|
693
|
+
category?: string
|
|
694
|
+
value?: number
|
|
695
|
+
properties?: Record<string, string | number | boolean>
|
|
696
|
+
path!: string
|
|
697
|
+
timestamp!: Date | string
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Query events for a specific site
|
|
701
|
+
*/
|
|
702
|
+
static forSite(siteId: string): EventQueryBuilder {
|
|
703
|
+
return new EventQueryBuilder(siteId)
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Record a custom event
|
|
708
|
+
*/
|
|
709
|
+
static async record(data: EventData): Promise<CustomEvent> {
|
|
710
|
+
const timestamp = data.timestamp instanceof Date ? data.timestamp : new Date(data.timestamp || Date.now())
|
|
711
|
+
const dateStr = timestamp.toISOString().slice(0, 10)
|
|
712
|
+
|
|
713
|
+
const item: any = {
|
|
714
|
+
...data,
|
|
715
|
+
pk: `SITE#${data.siteId}`,
|
|
716
|
+
sk: `EVENT#${timestamp.toISOString()}#${data.id}`,
|
|
717
|
+
gsi1pk: `SITE#${data.siteId}#DATE#${dateStr}`,
|
|
718
|
+
gsi1sk: `EVENT#${data.name}`,
|
|
719
|
+
timestamp: timestamp.toISOString(),
|
|
720
|
+
_et: 'CustomEvent',
|
|
721
|
+
ttl: rawTtlForSite(data.siteId),
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// Serialize properties as JSON string
|
|
725
|
+
if (data.properties) {
|
|
726
|
+
item.properties = JSON.stringify(data.properties)
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
const client = ormClient()
|
|
730
|
+
|
|
731
|
+
await client.putItem({
|
|
732
|
+
TableName: getTableName(),
|
|
733
|
+
Item: marshall(item),
|
|
734
|
+
})
|
|
735
|
+
|
|
736
|
+
return new CustomEvent(item)
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
interface EventData {
|
|
741
|
+
id: string
|
|
742
|
+
siteId: string
|
|
743
|
+
visitorId: string
|
|
744
|
+
sessionId: string
|
|
745
|
+
name: string
|
|
746
|
+
category?: string
|
|
747
|
+
value?: number
|
|
748
|
+
properties?: Record<string, string | number | boolean>
|
|
749
|
+
path: string
|
|
750
|
+
// Visitor dimensions (copied from the session) so event reports are filterable.
|
|
751
|
+
deviceType?: string
|
|
752
|
+
browser?: string
|
|
753
|
+
os?: string
|
|
754
|
+
country?: string
|
|
755
|
+
region?: string
|
|
756
|
+
city?: string
|
|
757
|
+
referrerSource?: string
|
|
758
|
+
utmSource?: string
|
|
759
|
+
utmMedium?: string
|
|
760
|
+
utmCampaign?: string
|
|
761
|
+
timestamp?: Date | string
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
class EventQueryBuilder {
|
|
765
|
+
private siteId: string
|
|
766
|
+
private startDate?: Date
|
|
767
|
+
private endDate?: Date
|
|
768
|
+
private _limit?: number
|
|
769
|
+
private _name?: string
|
|
770
|
+
|
|
771
|
+
constructor(siteId: string) {
|
|
772
|
+
this.siteId = siteId
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
since(date: Date): this {
|
|
776
|
+
this.startDate = date
|
|
777
|
+
return this
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
until(date: Date): this {
|
|
781
|
+
this.endDate = date
|
|
782
|
+
return this
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
named(name: string): this {
|
|
786
|
+
this._name = name
|
|
787
|
+
return this
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
limit(count: number): this {
|
|
791
|
+
this._limit = count
|
|
792
|
+
return this
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
async get(): Promise<CustomEvent[]> {
|
|
796
|
+
const client = ormClient()
|
|
797
|
+
|
|
798
|
+
const startKey = this.startDate
|
|
799
|
+
? `EVENT#${this.startDate.toISOString()}`
|
|
800
|
+
: 'EVENT#'
|
|
801
|
+
const endKey = this.endDate
|
|
802
|
+
? `EVENT#${this.endDate.toISOString()}`
|
|
803
|
+
: 'EVENT#\uffff'
|
|
804
|
+
|
|
805
|
+
const result = await client.query({
|
|
806
|
+
TableName: getTableName(),
|
|
807
|
+
KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
|
|
808
|
+
ExpressionAttributeValues: {
|
|
809
|
+
':pk': { S: `SITE#${this.siteId}` },
|
|
810
|
+
':start': { S: startKey },
|
|
811
|
+
':end': { S: endKey },
|
|
812
|
+
},
|
|
813
|
+
...(this._limit && { Limit: this._limit }),
|
|
814
|
+
ScanIndexForward: false,
|
|
815
|
+
})
|
|
816
|
+
|
|
817
|
+
let events = (result.Items || []).map((item: any) => {
|
|
818
|
+
const data = unmarshall(item)
|
|
819
|
+
// Parse properties from JSON if present
|
|
820
|
+
if (typeof data.properties === 'string') {
|
|
821
|
+
try {
|
|
822
|
+
data.properties = JSON.parse(data.properties)
|
|
823
|
+
}
|
|
824
|
+
catch {}
|
|
825
|
+
}
|
|
826
|
+
return new CustomEvent(data)
|
|
827
|
+
})
|
|
828
|
+
|
|
829
|
+
// Filter by name if specified
|
|
830
|
+
if (this._name) {
|
|
831
|
+
events = events.filter(e => e.name === this._name)
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
return events
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
async count(): Promise<number> {
|
|
838
|
+
const events = await this.get()
|
|
839
|
+
return events.length
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
async aggregate(): Promise<Array<{ name: string; count: number; visitors: number; totalValue: number }>> {
|
|
843
|
+
const events = await this.get()
|
|
844
|
+
|
|
845
|
+
const stats: Record<string, { count: number; visitors: Set<string>; totalValue: number }> = {}
|
|
846
|
+
for (const e of events) {
|
|
847
|
+
if (!stats[e.name]) {
|
|
848
|
+
stats[e.name] = { count: 0, visitors: new Set(), totalValue: 0 }
|
|
849
|
+
}
|
|
850
|
+
stats[e.name].count++
|
|
851
|
+
stats[e.name].visitors.add(e.visitorId)
|
|
852
|
+
if (e.value) stats[e.name].totalValue += e.value
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
return Object.entries(stats)
|
|
856
|
+
.map(([name, s]) => ({
|
|
857
|
+
name,
|
|
858
|
+
count: s.count,
|
|
859
|
+
visitors: s.visitors.size,
|
|
860
|
+
totalValue: s.totalValue,
|
|
861
|
+
}))
|
|
862
|
+
.sort((a, b) => b.count - a.count)
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// ============================================================================
|
|
867
|
+
// Utility Functions
|
|
868
|
+
// ============================================================================
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* Marshall a JavaScript object to DynamoDB format
|
|
872
|
+
*/
|
|
873
|
+
export function marshall(obj: Record<string, any>): Record<string, any> {
|
|
874
|
+
const result: Record<string, any> = {}
|
|
875
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
876
|
+
result[key] = marshallValue(value)
|
|
877
|
+
}
|
|
878
|
+
return result
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Marshall a single value to DynamoDB format
|
|
883
|
+
*/
|
|
884
|
+
function marshallValue(value: any): any {
|
|
885
|
+
if (value === null || value === undefined) {
|
|
886
|
+
return { NULL: true }
|
|
887
|
+
}
|
|
888
|
+
if (typeof value === 'string') {
|
|
889
|
+
return { S: value }
|
|
890
|
+
}
|
|
891
|
+
if (typeof value === 'number') {
|
|
892
|
+
return { N: String(value) }
|
|
893
|
+
}
|
|
894
|
+
if (typeof value === 'boolean') {
|
|
895
|
+
return { BOOL: value }
|
|
896
|
+
}
|
|
897
|
+
if (Array.isArray(value)) {
|
|
898
|
+
return { L: value.map(marshallValue) }
|
|
899
|
+
}
|
|
900
|
+
if (typeof value === 'object') {
|
|
901
|
+
const m: Record<string, any> = {}
|
|
902
|
+
for (const [k, v] of Object.entries(value)) {
|
|
903
|
+
m[k] = marshallValue(v)
|
|
904
|
+
}
|
|
905
|
+
return { M: m }
|
|
906
|
+
}
|
|
907
|
+
return { S: String(value) }
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* Unmarshall a DynamoDB item to a JavaScript object
|
|
912
|
+
*/
|
|
913
|
+
export function unmarshall(item: Record<string, any>): Record<string, any> {
|
|
914
|
+
const result: Record<string, any> = {}
|
|
915
|
+
for (const [key, value] of Object.entries(item)) {
|
|
916
|
+
result[key] = unmarshallValue(value)
|
|
917
|
+
}
|
|
918
|
+
return result
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* Unmarshall a single DynamoDB value
|
|
923
|
+
*/
|
|
924
|
+
function unmarshallValue(value: any): any {
|
|
925
|
+
if ('S' in value) return value.S
|
|
926
|
+
if ('N' in value) return Number(value.N)
|
|
927
|
+
if ('BOOL' in value) return value.BOOL
|
|
928
|
+
if ('NULL' in value) return null
|
|
929
|
+
if ('SS' in value) return [...value.SS]
|
|
930
|
+
if ('NS' in value) return value.NS.map(Number)
|
|
931
|
+
if ('L' in value) return value.L.map(unmarshallValue)
|
|
932
|
+
if ('M' in value) return unmarshall(value.M)
|
|
933
|
+
return value
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// ============================================================================
|
|
937
|
+
// HeatmapClick Model
|
|
938
|
+
// ============================================================================
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* HeatmapClick model for tracking user clicks on pages
|
|
942
|
+
*
|
|
943
|
+
* DynamoDB Keys:
|
|
944
|
+
* - PK: SITE#{siteId}
|
|
945
|
+
* - SK: HMCLICK#{timestamp}#{id}
|
|
946
|
+
* - GSI1PK: SITE#{siteId}#PATH#{encodedPath}
|
|
947
|
+
* - GSI1SK: HMCLICK#{timestamp}
|
|
948
|
+
*/
|
|
949
|
+
export class HeatmapClick extends Model {
|
|
950
|
+
static get tableName(): string { return getTableName() }
|
|
951
|
+
static pkPrefix = 'SITE'
|
|
952
|
+
static skPrefix = 'HMCLICK'
|
|
953
|
+
static primaryKey = 'id'
|
|
954
|
+
static timestamps = true
|
|
955
|
+
|
|
956
|
+
// Attributes
|
|
957
|
+
id!: string
|
|
958
|
+
siteId!: string
|
|
959
|
+
sessionId!: string
|
|
960
|
+
visitorId!: string
|
|
961
|
+
path!: string
|
|
962
|
+
viewportX!: number
|
|
963
|
+
viewportY!: number
|
|
964
|
+
documentX!: number
|
|
965
|
+
documentY!: number
|
|
966
|
+
viewportWidth!: number
|
|
967
|
+
viewportHeight!: number
|
|
968
|
+
selector!: string
|
|
969
|
+
elementTag!: string
|
|
970
|
+
elementText?: string
|
|
971
|
+
deviceType?: DeviceType
|
|
972
|
+
timestamp!: Date | string
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* Query heatmap clicks for a specific site
|
|
976
|
+
*/
|
|
977
|
+
static forSite(siteId: string): HeatmapClickQueryBuilder {
|
|
978
|
+
return new HeatmapClickQueryBuilder(siteId)
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* Record a heatmap click
|
|
983
|
+
*/
|
|
984
|
+
static async record(data: HeatmapClickData): Promise<HeatmapClick> {
|
|
985
|
+
const timestamp = data.timestamp instanceof Date ? data.timestamp : new Date(data.timestamp || Date.now())
|
|
986
|
+
const encodedPath = encodeURIComponent(data.path)
|
|
987
|
+
|
|
988
|
+
const item = {
|
|
989
|
+
...data,
|
|
990
|
+
pk: `SITE#${data.siteId}`,
|
|
991
|
+
sk: `HMCLICK#${timestamp.toISOString()}#${data.id}`,
|
|
992
|
+
gsi1pk: `SITE#${data.siteId}#PATH#${encodedPath}`,
|
|
993
|
+
gsi1sk: `HMCLICK#${timestamp.toISOString()}`,
|
|
994
|
+
timestamp: timestamp.toISOString(),
|
|
995
|
+
_et: 'HeatmapClick',
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
const client = ormClient()
|
|
999
|
+
|
|
1000
|
+
await client.putItem({
|
|
1001
|
+
TableName: getTableName(),
|
|
1002
|
+
Item: marshall(item),
|
|
1003
|
+
})
|
|
1004
|
+
|
|
1005
|
+
return new HeatmapClick(item)
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
interface HeatmapClickData {
|
|
1010
|
+
id: string
|
|
1011
|
+
siteId: string
|
|
1012
|
+
sessionId: string
|
|
1013
|
+
visitorId: string
|
|
1014
|
+
path: string
|
|
1015
|
+
viewportX: number
|
|
1016
|
+
viewportY: number
|
|
1017
|
+
documentX: number
|
|
1018
|
+
documentY: number
|
|
1019
|
+
viewportWidth: number
|
|
1020
|
+
viewportHeight: number
|
|
1021
|
+
selector: string
|
|
1022
|
+
elementTag: string
|
|
1023
|
+
elementText?: string
|
|
1024
|
+
deviceType?: DeviceType
|
|
1025
|
+
timestamp?: Date | string
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
class HeatmapClickQueryBuilder {
|
|
1029
|
+
private siteId: string
|
|
1030
|
+
private startDate?: Date
|
|
1031
|
+
private endDate?: Date
|
|
1032
|
+
private _path?: string
|
|
1033
|
+
private _limit?: number
|
|
1034
|
+
private _deviceType?: DeviceType
|
|
1035
|
+
|
|
1036
|
+
constructor(siteId: string) {
|
|
1037
|
+
this.siteId = siteId
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
since(date: Date): this {
|
|
1041
|
+
this.startDate = date
|
|
1042
|
+
return this
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
until(date: Date): this {
|
|
1046
|
+
this.endDate = date
|
|
1047
|
+
return this
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
onPath(path: string): this {
|
|
1051
|
+
this._path = path
|
|
1052
|
+
return this
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
device(deviceType: DeviceType): this {
|
|
1056
|
+
this._deviceType = deviceType
|
|
1057
|
+
return this
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
limit(count: number): this {
|
|
1061
|
+
this._limit = count
|
|
1062
|
+
return this
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
async get(): Promise<HeatmapClick[]> {
|
|
1066
|
+
const client = ormClient()
|
|
1067
|
+
|
|
1068
|
+
// If path is specified, use GSI
|
|
1069
|
+
if (this._path) {
|
|
1070
|
+
const encodedPath = encodeURIComponent(this._path)
|
|
1071
|
+
const startKey = this.startDate
|
|
1072
|
+
? `HMCLICK#${this.startDate.toISOString()}`
|
|
1073
|
+
: 'HMCLICK#'
|
|
1074
|
+
const endKey = this.endDate
|
|
1075
|
+
? `HMCLICK#${this.endDate.toISOString()}`
|
|
1076
|
+
: 'HMCLICK#\uffff'
|
|
1077
|
+
|
|
1078
|
+
const result = await client.query({
|
|
1079
|
+
TableName: getTableName(),
|
|
1080
|
+
IndexName: 'gsi1',
|
|
1081
|
+
KeyConditionExpression: 'gsi1pk = :pk AND gsi1sk BETWEEN :start AND :end',
|
|
1082
|
+
ExpressionAttributeValues: {
|
|
1083
|
+
':pk': { S: `SITE#${this.siteId}#PATH#${encodedPath}` },
|
|
1084
|
+
':start': { S: startKey },
|
|
1085
|
+
':end': { S: endKey },
|
|
1086
|
+
},
|
|
1087
|
+
...(this._limit && { Limit: this._limit }),
|
|
1088
|
+
ScanIndexForward: false,
|
|
1089
|
+
})
|
|
1090
|
+
|
|
1091
|
+
let clicks = (result.Items || []).map((item: any) => new HeatmapClick(unmarshall(item)))
|
|
1092
|
+
|
|
1093
|
+
if (this._deviceType) {
|
|
1094
|
+
clicks = clicks.filter(c => c.deviceType === this._deviceType)
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
return clicks
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// Otherwise query by site
|
|
1101
|
+
const startKey = this.startDate
|
|
1102
|
+
? `HMCLICK#${this.startDate.toISOString()}`
|
|
1103
|
+
: 'HMCLICK#'
|
|
1104
|
+
const endKey = this.endDate
|
|
1105
|
+
? `HMCLICK#${this.endDate.toISOString()}`
|
|
1106
|
+
: 'HMCLICK#\uffff'
|
|
1107
|
+
|
|
1108
|
+
const result = await client.query({
|
|
1109
|
+
TableName: getTableName(),
|
|
1110
|
+
KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
|
|
1111
|
+
ExpressionAttributeValues: {
|
|
1112
|
+
':pk': { S: `SITE#${this.siteId}` },
|
|
1113
|
+
':start': { S: startKey },
|
|
1114
|
+
':end': { S: endKey },
|
|
1115
|
+
},
|
|
1116
|
+
...(this._limit && { Limit: this._limit }),
|
|
1117
|
+
ScanIndexForward: false,
|
|
1118
|
+
})
|
|
1119
|
+
|
|
1120
|
+
let clicks = (result.Items || []).map((item: any) => new HeatmapClick(unmarshall(item)))
|
|
1121
|
+
|
|
1122
|
+
if (this._deviceType) {
|
|
1123
|
+
clicks = clicks.filter(c => c.deviceType === this._deviceType)
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
return clicks
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
async count(): Promise<number> {
|
|
1130
|
+
const clicks = await this.get()
|
|
1131
|
+
return clicks.length
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
/**
|
|
1135
|
+
* Get aggregated click data for heatmap visualization
|
|
1136
|
+
*/
|
|
1137
|
+
async aggregate(gridSize: number = 10): Promise<HeatmapAggregation> {
|
|
1138
|
+
const clicks = await this.get()
|
|
1139
|
+
|
|
1140
|
+
if (clicks.length === 0) {
|
|
1141
|
+
return { grid: [], totalClicks: 0, maxDensity: 0 }
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// Group by normalized grid position
|
|
1145
|
+
const grid: Record<string, { x: number; y: number; count: number }> = {}
|
|
1146
|
+
|
|
1147
|
+
for (const click of clicks) {
|
|
1148
|
+
// Normalize to percentage of viewport
|
|
1149
|
+
const normX = Math.floor((click.viewportX / click.viewportWidth) * 100 / gridSize) * gridSize
|
|
1150
|
+
const normY = Math.floor((click.viewportY / click.viewportHeight) * 100 / gridSize) * gridSize
|
|
1151
|
+
const key = `${normX}-${normY}`
|
|
1152
|
+
|
|
1153
|
+
if (!grid[key]) {
|
|
1154
|
+
grid[key] = { x: normX, y: normY, count: 0 }
|
|
1155
|
+
}
|
|
1156
|
+
grid[key].count++
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
const gridArray = Object.values(grid)
|
|
1160
|
+
const maxDensity = Math.max(...gridArray.map(g => g.count))
|
|
1161
|
+
|
|
1162
|
+
return {
|
|
1163
|
+
grid: gridArray,
|
|
1164
|
+
totalClicks: clicks.length,
|
|
1165
|
+
maxDensity,
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
interface HeatmapAggregation {
|
|
1171
|
+
grid: Array<{ x: number; y: number; count: number }>
|
|
1172
|
+
totalClicks: number
|
|
1173
|
+
maxDensity: number
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
// ============================================================================
|
|
1177
|
+
// HeatmapMovement Model
|
|
1178
|
+
// ============================================================================
|
|
1179
|
+
|
|
1180
|
+
/**
|
|
1181
|
+
* HeatmapMovement model for tracking mouse movement batches
|
|
1182
|
+
*
|
|
1183
|
+
* DynamoDB Keys:
|
|
1184
|
+
* - PK: SITE#{siteId}
|
|
1185
|
+
* - SK: HMMOVE#{sessionId}#{path}#{timestamp}
|
|
1186
|
+
*/
|
|
1187
|
+
export class HeatmapMovement extends Model {
|
|
1188
|
+
static get tableName(): string { return getTableName() }
|
|
1189
|
+
static pkPrefix = 'SITE'
|
|
1190
|
+
static skPrefix = 'HMMOVE'
|
|
1191
|
+
static primaryKey = 'id'
|
|
1192
|
+
static timestamps = true
|
|
1193
|
+
|
|
1194
|
+
// Attributes
|
|
1195
|
+
id!: string
|
|
1196
|
+
siteId!: string
|
|
1197
|
+
sessionId!: string
|
|
1198
|
+
visitorId!: string
|
|
1199
|
+
path!: string
|
|
1200
|
+
points!: Array<[number, number, number]> // [x, y, timestamp]
|
|
1201
|
+
pointCount!: number
|
|
1202
|
+
viewportWidth!: number
|
|
1203
|
+
viewportHeight!: number
|
|
1204
|
+
deviceType?: DeviceType
|
|
1205
|
+
timestamp!: Date | string
|
|
1206
|
+
|
|
1207
|
+
/**
|
|
1208
|
+
* Query heatmap movements for a specific site
|
|
1209
|
+
*/
|
|
1210
|
+
static forSite(siteId: string): HeatmapMovementQueryBuilder {
|
|
1211
|
+
return new HeatmapMovementQueryBuilder(siteId)
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
/**
|
|
1215
|
+
* Record a movement batch
|
|
1216
|
+
*/
|
|
1217
|
+
static async record(data: HeatmapMovementData): Promise<HeatmapMovement> {
|
|
1218
|
+
const timestamp = data.timestamp instanceof Date ? data.timestamp : new Date(data.timestamp || Date.now())
|
|
1219
|
+
const encodedPath = encodeURIComponent(data.path)
|
|
1220
|
+
|
|
1221
|
+
const item = {
|
|
1222
|
+
...data,
|
|
1223
|
+
pk: `SITE#${data.siteId}`,
|
|
1224
|
+
sk: `HMMOVE#${data.sessionId}#${encodedPath}#${timestamp.toISOString()}`,
|
|
1225
|
+
points: JSON.stringify(data.points),
|
|
1226
|
+
pointCount: data.points.length,
|
|
1227
|
+
timestamp: timestamp.toISOString(),
|
|
1228
|
+
_et: 'HeatmapMovement',
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
const client = ormClient()
|
|
1232
|
+
|
|
1233
|
+
await client.putItem({
|
|
1234
|
+
TableName: getTableName(),
|
|
1235
|
+
Item: marshall(item),
|
|
1236
|
+
})
|
|
1237
|
+
|
|
1238
|
+
return new HeatmapMovement({ ...item, points: data.points })
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
interface HeatmapMovementData {
|
|
1243
|
+
id: string
|
|
1244
|
+
siteId: string
|
|
1245
|
+
sessionId: string
|
|
1246
|
+
visitorId: string
|
|
1247
|
+
path: string
|
|
1248
|
+
points: Array<[number, number, number]>
|
|
1249
|
+
viewportWidth: number
|
|
1250
|
+
viewportHeight: number
|
|
1251
|
+
deviceType?: DeviceType
|
|
1252
|
+
timestamp?: Date | string
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
class HeatmapMovementQueryBuilder {
|
|
1256
|
+
private siteId: string
|
|
1257
|
+
private startDate?: Date
|
|
1258
|
+
private endDate?: Date
|
|
1259
|
+
private _path?: string
|
|
1260
|
+
private _limit?: number
|
|
1261
|
+
|
|
1262
|
+
constructor(siteId: string) {
|
|
1263
|
+
this.siteId = siteId
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
since(date: Date): this {
|
|
1267
|
+
this.startDate = date
|
|
1268
|
+
return this
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
until(date: Date): this {
|
|
1272
|
+
this.endDate = date
|
|
1273
|
+
return this
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
onPath(path: string): this {
|
|
1277
|
+
this._path = path
|
|
1278
|
+
return this
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
limit(count: number): this {
|
|
1282
|
+
this._limit = count
|
|
1283
|
+
return this
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
async get(): Promise<HeatmapMovement[]> {
|
|
1287
|
+
const client = ormClient()
|
|
1288
|
+
|
|
1289
|
+
const result = await client.query({
|
|
1290
|
+
TableName: getTableName(),
|
|
1291
|
+
KeyConditionExpression: 'pk = :pk AND begins_with(sk, :prefix)',
|
|
1292
|
+
ExpressionAttributeValues: {
|
|
1293
|
+
':pk': { S: `SITE#${this.siteId}` },
|
|
1294
|
+
':prefix': { S: 'HMMOVE#' },
|
|
1295
|
+
},
|
|
1296
|
+
...(this._limit && { Limit: this._limit }),
|
|
1297
|
+
ScanIndexForward: false,
|
|
1298
|
+
})
|
|
1299
|
+
|
|
1300
|
+
let movements = (result.Items || []).map((item: any) => {
|
|
1301
|
+
const data = unmarshall(item)
|
|
1302
|
+
// Parse points from JSON
|
|
1303
|
+
if (typeof data.points === 'string') {
|
|
1304
|
+
try {
|
|
1305
|
+
data.points = JSON.parse(data.points)
|
|
1306
|
+
}
|
|
1307
|
+
catch {
|
|
1308
|
+
data.points = []
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
return new HeatmapMovement(data)
|
|
1312
|
+
})
|
|
1313
|
+
|
|
1314
|
+
// Filter by path if specified
|
|
1315
|
+
if (this._path) {
|
|
1316
|
+
movements = movements.filter(m => m.path === this._path)
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
// Filter by date if specified
|
|
1320
|
+
if (this.startDate || this.endDate) {
|
|
1321
|
+
movements = movements.filter(m => {
|
|
1322
|
+
const ts = new Date(m.timestamp)
|
|
1323
|
+
if (this.startDate && ts < this.startDate) return false
|
|
1324
|
+
if (this.endDate && ts > this.endDate) return false
|
|
1325
|
+
return true
|
|
1326
|
+
})
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
return movements
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
// ============================================================================
|
|
1334
|
+
// HeatmapScroll Model
|
|
1335
|
+
// ============================================================================
|
|
1336
|
+
|
|
1337
|
+
/**
|
|
1338
|
+
* HeatmapScroll model for tracking scroll depth
|
|
1339
|
+
*
|
|
1340
|
+
* DynamoDB Keys:
|
|
1341
|
+
* - PK: SITE#{siteId}
|
|
1342
|
+
* - SK: HMSCROLL#{sessionId}#{path}
|
|
1343
|
+
*/
|
|
1344
|
+
export class HeatmapScroll extends Model {
|
|
1345
|
+
static get tableName(): string { return getTableName() }
|
|
1346
|
+
static pkPrefix = 'SITE'
|
|
1347
|
+
static skPrefix = 'HMSCROLL'
|
|
1348
|
+
static primaryKey = 'id'
|
|
1349
|
+
static timestamps = true
|
|
1350
|
+
|
|
1351
|
+
// Attributes
|
|
1352
|
+
id!: string
|
|
1353
|
+
siteId!: string
|
|
1354
|
+
sessionId!: string
|
|
1355
|
+
visitorId!: string
|
|
1356
|
+
path!: string
|
|
1357
|
+
maxScrollDepth!: number // 0-100 percentage
|
|
1358
|
+
scrollDepths!: Record<number, number> // depth percentage -> time spent in ms
|
|
1359
|
+
documentHeight!: number
|
|
1360
|
+
viewportHeight!: number
|
|
1361
|
+
deviceType?: DeviceType
|
|
1362
|
+
timestamp!: Date | string
|
|
1363
|
+
|
|
1364
|
+
/**
|
|
1365
|
+
* Query scroll data for a specific site
|
|
1366
|
+
*/
|
|
1367
|
+
static forSite(siteId: string): HeatmapScrollQueryBuilder {
|
|
1368
|
+
return new HeatmapScrollQueryBuilder(siteId)
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
/**
|
|
1372
|
+
* Record scroll data
|
|
1373
|
+
*/
|
|
1374
|
+
static async record(data: HeatmapScrollData): Promise<HeatmapScroll> {
|
|
1375
|
+
const timestamp = data.timestamp instanceof Date ? data.timestamp : new Date(data.timestamp || Date.now())
|
|
1376
|
+
const encodedPath = encodeURIComponent(data.path)
|
|
1377
|
+
|
|
1378
|
+
const item = {
|
|
1379
|
+
...data,
|
|
1380
|
+
pk: `SITE#${data.siteId}`,
|
|
1381
|
+
sk: `HMSCROLL#${data.sessionId}#${encodedPath}`,
|
|
1382
|
+
scrollDepths: JSON.stringify(data.scrollDepths),
|
|
1383
|
+
timestamp: timestamp.toISOString(),
|
|
1384
|
+
_et: 'HeatmapScroll',
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
const client = ormClient()
|
|
1388
|
+
|
|
1389
|
+
await client.putItem({
|
|
1390
|
+
TableName: getTableName(),
|
|
1391
|
+
Item: marshall(item),
|
|
1392
|
+
})
|
|
1393
|
+
|
|
1394
|
+
return new HeatmapScroll({ ...item, scrollDepths: data.scrollDepths })
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
/**
|
|
1398
|
+
* Update scroll data for an existing session
|
|
1399
|
+
*/
|
|
1400
|
+
static async upsert(data: HeatmapScrollData): Promise<HeatmapScroll> {
|
|
1401
|
+
const timestamp = data.timestamp instanceof Date ? data.timestamp : new Date(data.timestamp || Date.now())
|
|
1402
|
+
const encodedPath = encodeURIComponent(data.path)
|
|
1403
|
+
|
|
1404
|
+
const client = ormClient()
|
|
1405
|
+
|
|
1406
|
+
// Check if exists
|
|
1407
|
+
const pk = `SITE#${data.siteId}`
|
|
1408
|
+
const sk = `HMSCROLL#${data.sessionId}#${encodedPath}`
|
|
1409
|
+
|
|
1410
|
+
const existing = await client.getItem({
|
|
1411
|
+
TableName: getTableName(),
|
|
1412
|
+
Key: { pk: { S: pk }, sk: { S: sk } },
|
|
1413
|
+
})
|
|
1414
|
+
|
|
1415
|
+
if (existing.Item) {
|
|
1416
|
+
// Update with merged scroll depths
|
|
1417
|
+
const existingData = unmarshall(existing.Item)
|
|
1418
|
+
const existingDepths = typeof existingData.scrollDepths === 'string'
|
|
1419
|
+
? JSON.parse(existingData.scrollDepths)
|
|
1420
|
+
: existingData.scrollDepths || {}
|
|
1421
|
+
|
|
1422
|
+
// Merge depths (take max time for each depth)
|
|
1423
|
+
const mergedDepths: Record<number, number> = { ...existingDepths }
|
|
1424
|
+
for (const [depth, time] of Object.entries(data.scrollDepths)) {
|
|
1425
|
+
const d = Number(depth)
|
|
1426
|
+
mergedDepths[d] = Math.max(mergedDepths[d] || 0, time as number)
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
const maxDepth = Math.max(existingData.maxScrollDepth || 0, data.maxScrollDepth)
|
|
1430
|
+
|
|
1431
|
+
await client.updateItem({
|
|
1432
|
+
TableName: getTableName(),
|
|
1433
|
+
Key: { pk: { S: pk }, sk: { S: sk } },
|
|
1434
|
+
UpdateExpression: 'SET scrollDepths = :depths, maxScrollDepth = :max, #ts = :ts',
|
|
1435
|
+
ExpressionAttributeNames: { '#ts': 'timestamp' },
|
|
1436
|
+
ExpressionAttributeValues: {
|
|
1437
|
+
':depths': { S: JSON.stringify(mergedDepths) },
|
|
1438
|
+
':max': { N: String(maxDepth) },
|
|
1439
|
+
':ts': { S: timestamp.toISOString() },
|
|
1440
|
+
},
|
|
1441
|
+
})
|
|
1442
|
+
|
|
1443
|
+
return new HeatmapScroll({
|
|
1444
|
+
...existingData,
|
|
1445
|
+
scrollDepths: mergedDepths,
|
|
1446
|
+
maxScrollDepth: maxDepth,
|
|
1447
|
+
timestamp: timestamp.toISOString(),
|
|
1448
|
+
})
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
// Create new
|
|
1452
|
+
return HeatmapScroll.record(data)
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
interface HeatmapScrollData {
|
|
1457
|
+
id: string
|
|
1458
|
+
siteId: string
|
|
1459
|
+
sessionId: string
|
|
1460
|
+
visitorId: string
|
|
1461
|
+
path: string
|
|
1462
|
+
maxScrollDepth: number
|
|
1463
|
+
scrollDepths: Record<number, number>
|
|
1464
|
+
documentHeight: number
|
|
1465
|
+
viewportHeight: number
|
|
1466
|
+
deviceType?: DeviceType
|
|
1467
|
+
timestamp?: Date | string
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
class HeatmapScrollQueryBuilder {
|
|
1471
|
+
private siteId: string
|
|
1472
|
+
private startDate?: Date
|
|
1473
|
+
private endDate?: Date
|
|
1474
|
+
private _path?: string
|
|
1475
|
+
private _limit?: number
|
|
1476
|
+
|
|
1477
|
+
constructor(siteId: string) {
|
|
1478
|
+
this.siteId = siteId
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
since(date: Date): this {
|
|
1482
|
+
this.startDate = date
|
|
1483
|
+
return this
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
until(date: Date): this {
|
|
1487
|
+
this.endDate = date
|
|
1488
|
+
return this
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
onPath(path: string): this {
|
|
1492
|
+
this._path = path
|
|
1493
|
+
return this
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
limit(count: number): this {
|
|
1497
|
+
this._limit = count
|
|
1498
|
+
return this
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
async get(): Promise<HeatmapScroll[]> {
|
|
1502
|
+
const client = ormClient()
|
|
1503
|
+
|
|
1504
|
+
const result = await client.query({
|
|
1505
|
+
TableName: getTableName(),
|
|
1506
|
+
KeyConditionExpression: 'pk = :pk AND begins_with(sk, :prefix)',
|
|
1507
|
+
ExpressionAttributeValues: {
|
|
1508
|
+
':pk': { S: `SITE#${this.siteId}` },
|
|
1509
|
+
':prefix': { S: 'HMSCROLL#' },
|
|
1510
|
+
},
|
|
1511
|
+
...(this._limit && { Limit: this._limit }),
|
|
1512
|
+
ScanIndexForward: false,
|
|
1513
|
+
})
|
|
1514
|
+
|
|
1515
|
+
let scrolls = (result.Items || []).map((item: any) => {
|
|
1516
|
+
const data = unmarshall(item)
|
|
1517
|
+
// Parse scrollDepths from JSON
|
|
1518
|
+
if (typeof data.scrollDepths === 'string') {
|
|
1519
|
+
try {
|
|
1520
|
+
data.scrollDepths = JSON.parse(data.scrollDepths)
|
|
1521
|
+
}
|
|
1522
|
+
catch {
|
|
1523
|
+
data.scrollDepths = {}
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
return new HeatmapScroll(data)
|
|
1527
|
+
})
|
|
1528
|
+
|
|
1529
|
+
// Filter by path if specified
|
|
1530
|
+
if (this._path) {
|
|
1531
|
+
scrolls = scrolls.filter(s => s.path === this._path)
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
// Filter by date if specified
|
|
1535
|
+
if (this.startDate || this.endDate) {
|
|
1536
|
+
scrolls = scrolls.filter(s => {
|
|
1537
|
+
const ts = new Date(s.timestamp)
|
|
1538
|
+
if (this.startDate && ts < this.startDate) return false
|
|
1539
|
+
if (this.endDate && ts > this.endDate) return false
|
|
1540
|
+
return true
|
|
1541
|
+
})
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
return scrolls
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
/**
|
|
1548
|
+
* Aggregate scroll data for visualization
|
|
1549
|
+
*/
|
|
1550
|
+
async aggregate(): Promise<ScrollAggregation> {
|
|
1551
|
+
const scrolls = await this.get()
|
|
1552
|
+
|
|
1553
|
+
if (scrolls.length === 0) {
|
|
1554
|
+
return { depths: {}, avgMaxDepth: 0, totalSessions: 0 }
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
// Aggregate depth data
|
|
1558
|
+
const depthCounts: Record<number, number> = {}
|
|
1559
|
+
let totalMaxDepth = 0
|
|
1560
|
+
|
|
1561
|
+
for (const scroll of scrolls) {
|
|
1562
|
+
totalMaxDepth += scroll.maxScrollDepth
|
|
1563
|
+
for (const [depth] of Object.entries(scroll.scrollDepths)) {
|
|
1564
|
+
const d = Number(depth)
|
|
1565
|
+
depthCounts[d] = (depthCounts[d] || 0) + 1
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
// Convert to percentages
|
|
1570
|
+
const depths: Record<number, number> = {}
|
|
1571
|
+
for (const [depth, count] of Object.entries(depthCounts)) {
|
|
1572
|
+
depths[Number(depth)] = Math.round((count / scrolls.length) * 100)
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
return {
|
|
1576
|
+
depths, // percentage -> % of visitors reaching that depth
|
|
1577
|
+
avgMaxDepth: Math.round(totalMaxDepth / scrolls.length),
|
|
1578
|
+
totalSessions: scrolls.length,
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
interface ScrollAggregation {
|
|
1584
|
+
depths: Record<number, number> // depth % -> % of visitors
|
|
1585
|
+
avgMaxDepth: number
|
|
1586
|
+
totalSessions: number
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
// ============================================================================
|
|
1590
|
+
// Goal Model
|
|
1591
|
+
// ============================================================================
|
|
1592
|
+
|
|
1593
|
+
/**
|
|
1594
|
+
* Goal model for tracking conversion goals
|
|
1595
|
+
*
|
|
1596
|
+
* DynamoDB Keys:
|
|
1597
|
+
* - PK: SITE#{siteId}
|
|
1598
|
+
* - SK: GOAL#{goalId}
|
|
1599
|
+
*/
|
|
1600
|
+
// @ts-expect-error - Goal.create has a specialized signature different from Model.create
|
|
1601
|
+
export class Goal extends Model {
|
|
1602
|
+
static get tableName(): string { return getTableName() }
|
|
1603
|
+
static pkPrefix = 'SITE'
|
|
1604
|
+
static skPrefix = 'GOAL'
|
|
1605
|
+
static primaryKey = 'id'
|
|
1606
|
+
static timestamps = true
|
|
1607
|
+
|
|
1608
|
+
// Attributes
|
|
1609
|
+
id!: string
|
|
1610
|
+
siteId!: string
|
|
1611
|
+
name!: string
|
|
1612
|
+
type!: 'pageview' | 'event' | 'duration'
|
|
1613
|
+
pattern!: string
|
|
1614
|
+
matchType!: 'exact' | 'contains' | 'regex'
|
|
1615
|
+
durationMinutes?: number // For duration goals
|
|
1616
|
+
value?: number // Revenue/value per conversion
|
|
1617
|
+
isActive!: boolean
|
|
1618
|
+
createdAt!: Date | string
|
|
1619
|
+
updatedAt!: Date | string
|
|
1620
|
+
|
|
1621
|
+
getPk(): string {
|
|
1622
|
+
return `SITE#${this.siteId}`
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
getSk(): string {
|
|
1626
|
+
return `GOAL#${this.id}`
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
/**
|
|
1630
|
+
* Query goals for a specific site
|
|
1631
|
+
*/
|
|
1632
|
+
static forSite(siteId: string): GoalQueryBuilder {
|
|
1633
|
+
return new GoalQueryBuilder(siteId)
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
/**
|
|
1637
|
+
* Create a new goal
|
|
1638
|
+
*/
|
|
1639
|
+
static async create(data: GoalData): Promise<Goal> {
|
|
1640
|
+
const now = new Date().toISOString()
|
|
1641
|
+
|
|
1642
|
+
const item = {
|
|
1643
|
+
...data,
|
|
1644
|
+
pk: `SITE#${data.siteId}`,
|
|
1645
|
+
sk: `GOAL#${data.id}`,
|
|
1646
|
+
isActive: data.isActive ?? true,
|
|
1647
|
+
createdAt: data.createdAt || now,
|
|
1648
|
+
updatedAt: data.updatedAt || now,
|
|
1649
|
+
_et: 'Goal',
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
const client = ormClient()
|
|
1653
|
+
|
|
1654
|
+
await client.putItem({
|
|
1655
|
+
TableName: getTableName(),
|
|
1656
|
+
Item: marshall(item),
|
|
1657
|
+
})
|
|
1658
|
+
|
|
1659
|
+
return new Goal(item)
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
/**
|
|
1663
|
+
* Find a goal by ID
|
|
1664
|
+
*/
|
|
1665
|
+
static async findById(siteId: string, goalId: string): Promise<Goal | null> {
|
|
1666
|
+
const client = ormClient()
|
|
1667
|
+
|
|
1668
|
+
const result = await client.getItem({
|
|
1669
|
+
TableName: getTableName(),
|
|
1670
|
+
Key: {
|
|
1671
|
+
pk: { S: `SITE#${siteId}` },
|
|
1672
|
+
sk: { S: `GOAL#${goalId}` },
|
|
1673
|
+
},
|
|
1674
|
+
})
|
|
1675
|
+
|
|
1676
|
+
if (!result.Item) return null
|
|
1677
|
+
return new Goal(unmarshall(result.Item))
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
/**
|
|
1681
|
+
* Update a goal
|
|
1682
|
+
*/
|
|
1683
|
+
static async update(siteId: string, goalId: string, data: Partial<GoalData>): Promise<Goal> {
|
|
1684
|
+
const client = ormClient()
|
|
1685
|
+
|
|
1686
|
+
const updates: string[] = []
|
|
1687
|
+
const exprNames: Record<string, string> = {}
|
|
1688
|
+
const exprValues: Record<string, any> = {}
|
|
1689
|
+
|
|
1690
|
+
const allowedFields = ['name', 'type', 'pattern', 'matchType', 'durationMinutes', 'value', 'isActive']
|
|
1691
|
+
for (const field of allowedFields) {
|
|
1692
|
+
if (data[field as keyof GoalData] !== undefined) {
|
|
1693
|
+
const key = `#${field}`
|
|
1694
|
+
const valueKey = `:${field}`
|
|
1695
|
+
updates.push(`${key} = ${valueKey}`)
|
|
1696
|
+
exprNames[key] = field
|
|
1697
|
+
exprValues[valueKey] = marshallValue(data[field as keyof GoalData])
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
// Always update updatedAt
|
|
1702
|
+
updates.push('#updatedAt = :updatedAt')
|
|
1703
|
+
exprNames['#updatedAt'] = 'updatedAt'
|
|
1704
|
+
exprValues[':updatedAt'] = { S: new Date().toISOString() }
|
|
1705
|
+
|
|
1706
|
+
if (updates.length === 0) {
|
|
1707
|
+
const existing = await Goal.findById(siteId, goalId)
|
|
1708
|
+
if (!existing) throw new Error('Goal not found')
|
|
1709
|
+
return existing
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
await client.updateItem({
|
|
1713
|
+
TableName: getTableName(),
|
|
1714
|
+
Key: {
|
|
1715
|
+
pk: { S: `SITE#${siteId}` },
|
|
1716
|
+
sk: { S: `GOAL#${goalId}` },
|
|
1717
|
+
},
|
|
1718
|
+
UpdateExpression: `SET ${updates.join(', ')}`,
|
|
1719
|
+
ExpressionAttributeNames: exprNames,
|
|
1720
|
+
ExpressionAttributeValues: exprValues,
|
|
1721
|
+
})
|
|
1722
|
+
|
|
1723
|
+
const updated = await Goal.findById(siteId, goalId)
|
|
1724
|
+
if (!updated) throw new Error('Goal not found after update')
|
|
1725
|
+
return updated
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
/**
|
|
1729
|
+
* Delete a goal
|
|
1730
|
+
*/
|
|
1731
|
+
static async delete(siteId: string, goalId: string): Promise<void> {
|
|
1732
|
+
const client = ormClient()
|
|
1733
|
+
|
|
1734
|
+
await client.deleteItem({
|
|
1735
|
+
TableName: getTableName(),
|
|
1736
|
+
Key: {
|
|
1737
|
+
pk: { S: `SITE#${siteId}` },
|
|
1738
|
+
sk: { S: `GOAL#${goalId}` },
|
|
1739
|
+
},
|
|
1740
|
+
})
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
interface GoalData {
|
|
1745
|
+
id: string
|
|
1746
|
+
siteId: string
|
|
1747
|
+
name: string
|
|
1748
|
+
type: 'pageview' | 'event' | 'duration'
|
|
1749
|
+
pattern: string
|
|
1750
|
+
matchType: 'exact' | 'contains' | 'regex'
|
|
1751
|
+
durationMinutes?: number
|
|
1752
|
+
value?: number
|
|
1753
|
+
isActive?: boolean
|
|
1754
|
+
createdAt?: Date | string
|
|
1755
|
+
updatedAt?: Date | string
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
class GoalQueryBuilder {
|
|
1759
|
+
private siteId: string
|
|
1760
|
+
private _activeOnly = false
|
|
1761
|
+
private _limit?: number
|
|
1762
|
+
|
|
1763
|
+
constructor(siteId: string) {
|
|
1764
|
+
this.siteId = siteId
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
active(): this {
|
|
1768
|
+
this._activeOnly = true
|
|
1769
|
+
return this
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
limit(count: number): this {
|
|
1773
|
+
this._limit = count
|
|
1774
|
+
return this
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
async get(): Promise<Goal[]> {
|
|
1778
|
+
const client = ormClient()
|
|
1779
|
+
|
|
1780
|
+
const result = await client.query({
|
|
1781
|
+
TableName: getTableName(),
|
|
1782
|
+
KeyConditionExpression: 'pk = :pk AND begins_with(sk, :prefix)',
|
|
1783
|
+
ExpressionAttributeValues: {
|
|
1784
|
+
':pk': { S: `SITE#${this.siteId}` },
|
|
1785
|
+
':prefix': { S: 'GOAL#' },
|
|
1786
|
+
},
|
|
1787
|
+
...(this._limit && { Limit: this._limit }),
|
|
1788
|
+
})
|
|
1789
|
+
|
|
1790
|
+
let goals = (result.Items || []).map((item: any) => new Goal(unmarshall(item)))
|
|
1791
|
+
|
|
1792
|
+
if (this._activeOnly) {
|
|
1793
|
+
goals = goals.filter(g => g.isActive)
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
return goals
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
// ============================================================================
|
|
1801
|
+
// Conversion Model
|
|
1802
|
+
// ============================================================================
|
|
1803
|
+
|
|
1804
|
+
/**
|
|
1805
|
+
* Conversion model for tracking goal completions
|
|
1806
|
+
*
|
|
1807
|
+
* DynamoDB Keys:
|
|
1808
|
+
* - PK: SITE#{siteId}
|
|
1809
|
+
* - SK: CONVERSION#{timestamp}#{id}
|
|
1810
|
+
* - GSI1PK: SITE#{siteId}#GOAL#{goalId}
|
|
1811
|
+
* - GSI1SK: CONVERSION#{timestamp}
|
|
1812
|
+
*/
|
|
1813
|
+
export class Conversion extends Model {
|
|
1814
|
+
static get tableName(): string { return getTableName() }
|
|
1815
|
+
static pkPrefix = 'SITE'
|
|
1816
|
+
static skPrefix = 'CONVERSION'
|
|
1817
|
+
static primaryKey = 'id'
|
|
1818
|
+
static timestamps = true
|
|
1819
|
+
|
|
1820
|
+
// Attributes
|
|
1821
|
+
id!: string
|
|
1822
|
+
siteId!: string
|
|
1823
|
+
goalId!: string
|
|
1824
|
+
visitorId!: string
|
|
1825
|
+
sessionId!: string
|
|
1826
|
+
value?: number
|
|
1827
|
+
path!: string
|
|
1828
|
+
referrerSource?: string
|
|
1829
|
+
utmSource?: string
|
|
1830
|
+
utmMedium?: string
|
|
1831
|
+
utmCampaign?: string
|
|
1832
|
+
timestamp!: Date | string
|
|
1833
|
+
|
|
1834
|
+
getPk(): string {
|
|
1835
|
+
return `SITE#${this.siteId}`
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
getSk(): string {
|
|
1839
|
+
const ts = this.timestamp instanceof Date ? this.timestamp.toISOString() : this.timestamp
|
|
1840
|
+
return `CONVERSION#${ts}#${this.id}`
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
getGsi1pk(): string {
|
|
1844
|
+
return `SITE#${this.siteId}#GOAL#${this.goalId}`
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
getGsi1sk(): string {
|
|
1848
|
+
const ts = this.timestamp instanceof Date ? this.timestamp.toISOString() : this.timestamp
|
|
1849
|
+
return `CONVERSION#${ts}`
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
/**
|
|
1853
|
+
* Query conversions for a specific goal
|
|
1854
|
+
*/
|
|
1855
|
+
static forGoal(siteId: string, goalId: string): ConversionQueryBuilder {
|
|
1856
|
+
return new ConversionQueryBuilder(siteId, goalId)
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
/**
|
|
1860
|
+
* Query all conversions for a site
|
|
1861
|
+
*/
|
|
1862
|
+
static forSite(siteId: string): ConversionSiteQueryBuilder {
|
|
1863
|
+
return new ConversionSiteQueryBuilder(siteId)
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
/**
|
|
1867
|
+
* Record a conversion
|
|
1868
|
+
*/
|
|
1869
|
+
static async record(data: ConversionData): Promise<Conversion> {
|
|
1870
|
+
const timestamp = data.timestamp instanceof Date ? data.timestamp : new Date(data.timestamp || Date.now())
|
|
1871
|
+
|
|
1872
|
+
const item = {
|
|
1873
|
+
...data,
|
|
1874
|
+
pk: `SITE#${data.siteId}`,
|
|
1875
|
+
sk: `CONVERSION#${timestamp.toISOString()}#${data.id}`,
|
|
1876
|
+
gsi1pk: `SITE#${data.siteId}#GOAL#${data.goalId}`,
|
|
1877
|
+
gsi1sk: `CONVERSION#${timestamp.toISOString()}`,
|
|
1878
|
+
timestamp: timestamp.toISOString(),
|
|
1879
|
+
_et: 'Conversion',
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
const client = ormClient()
|
|
1883
|
+
|
|
1884
|
+
await client.putItem({
|
|
1885
|
+
TableName: getTableName(),
|
|
1886
|
+
Item: marshall(item),
|
|
1887
|
+
})
|
|
1888
|
+
|
|
1889
|
+
return new Conversion(item)
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
interface ConversionData {
|
|
1894
|
+
id: string
|
|
1895
|
+
siteId: string
|
|
1896
|
+
goalId: string
|
|
1897
|
+
visitorId: string
|
|
1898
|
+
sessionId: string
|
|
1899
|
+
value?: number
|
|
1900
|
+
path: string
|
|
1901
|
+
referrerSource?: string
|
|
1902
|
+
utmSource?: string
|
|
1903
|
+
utmMedium?: string
|
|
1904
|
+
utmCampaign?: string
|
|
1905
|
+
timestamp?: Date | string
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
class ConversionQueryBuilder {
|
|
1909
|
+
private siteId: string
|
|
1910
|
+
private goalId: string
|
|
1911
|
+
private startDate?: Date
|
|
1912
|
+
private endDate?: Date
|
|
1913
|
+
private _limit?: number
|
|
1914
|
+
|
|
1915
|
+
constructor(siteId: string, goalId: string) {
|
|
1916
|
+
this.siteId = siteId
|
|
1917
|
+
this.goalId = goalId
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
since(date: Date): this {
|
|
1921
|
+
this.startDate = date
|
|
1922
|
+
return this
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
until(date: Date): this {
|
|
1926
|
+
this.endDate = date
|
|
1927
|
+
return this
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
limit(count: number): this {
|
|
1931
|
+
this._limit = count
|
|
1932
|
+
return this
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
async get(): Promise<Conversion[]> {
|
|
1936
|
+
const client = ormClient()
|
|
1937
|
+
|
|
1938
|
+
// Query using GSI1 for goal-specific conversions
|
|
1939
|
+
const startKey = this.startDate
|
|
1940
|
+
? `CONVERSION#${this.startDate.toISOString()}`
|
|
1941
|
+
: 'CONVERSION#'
|
|
1942
|
+
const endKey = this.endDate
|
|
1943
|
+
? `CONVERSION#${this.endDate.toISOString()}`
|
|
1944
|
+
: 'CONVERSION#\uffff'
|
|
1945
|
+
|
|
1946
|
+
const result = await client.query({
|
|
1947
|
+
TableName: getTableName(),
|
|
1948
|
+
IndexName: 'gsi1',
|
|
1949
|
+
KeyConditionExpression: 'gsi1pk = :pk AND gsi1sk BETWEEN :start AND :end',
|
|
1950
|
+
ExpressionAttributeValues: {
|
|
1951
|
+
':pk': { S: `SITE#${this.siteId}#GOAL#${this.goalId}` },
|
|
1952
|
+
':start': { S: startKey },
|
|
1953
|
+
':end': { S: endKey },
|
|
1954
|
+
},
|
|
1955
|
+
...(this._limit && { Limit: this._limit }),
|
|
1956
|
+
ScanIndexForward: false,
|
|
1957
|
+
})
|
|
1958
|
+
|
|
1959
|
+
return (result.Items || []).map((item: any) => new Conversion(unmarshall(item)))
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
async count(): Promise<number> {
|
|
1963
|
+
const client = ormClient()
|
|
1964
|
+
|
|
1965
|
+
const startKey = this.startDate
|
|
1966
|
+
? `CONVERSION#${this.startDate.toISOString()}`
|
|
1967
|
+
: 'CONVERSION#'
|
|
1968
|
+
const endKey = this.endDate
|
|
1969
|
+
? `CONVERSION#${this.endDate.toISOString()}`
|
|
1970
|
+
: 'CONVERSION#\uffff'
|
|
1971
|
+
|
|
1972
|
+
const result = await client.query({
|
|
1973
|
+
TableName: getTableName(),
|
|
1974
|
+
IndexName: 'gsi1',
|
|
1975
|
+
KeyConditionExpression: 'gsi1pk = :pk AND gsi1sk BETWEEN :start AND :end',
|
|
1976
|
+
ExpressionAttributeValues: {
|
|
1977
|
+
':pk': { S: `SITE#${this.siteId}#GOAL#${this.goalId}` },
|
|
1978
|
+
':start': { S: startKey },
|
|
1979
|
+
':end': { S: endKey },
|
|
1980
|
+
},
|
|
1981
|
+
Select: 'COUNT',
|
|
1982
|
+
})
|
|
1983
|
+
|
|
1984
|
+
return result.Count || 0
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
class ConversionSiteQueryBuilder {
|
|
1989
|
+
private siteId: string
|
|
1990
|
+
private startDate?: Date
|
|
1991
|
+
private endDate?: Date
|
|
1992
|
+
private _limit?: number
|
|
1993
|
+
|
|
1994
|
+
constructor(siteId: string) {
|
|
1995
|
+
this.siteId = siteId
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
since(date: Date): this {
|
|
1999
|
+
this.startDate = date
|
|
2000
|
+
return this
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
until(date: Date): this {
|
|
2004
|
+
this.endDate = date
|
|
2005
|
+
return this
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
limit(count: number): this {
|
|
2009
|
+
this._limit = count
|
|
2010
|
+
return this
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
async get(): Promise<Conversion[]> {
|
|
2014
|
+
const client = ormClient()
|
|
2015
|
+
|
|
2016
|
+
const startKey = this.startDate
|
|
2017
|
+
? `CONVERSION#${this.startDate.toISOString()}`
|
|
2018
|
+
: 'CONVERSION#'
|
|
2019
|
+
const endKey = this.endDate
|
|
2020
|
+
? `CONVERSION#${this.endDate.toISOString()}`
|
|
2021
|
+
: 'CONVERSION#\uffff'
|
|
2022
|
+
|
|
2023
|
+
const result = await client.query({
|
|
2024
|
+
TableName: getTableName(),
|
|
2025
|
+
KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
|
|
2026
|
+
ExpressionAttributeValues: {
|
|
2027
|
+
':pk': { S: `SITE#${this.siteId}` },
|
|
2028
|
+
':start': { S: startKey },
|
|
2029
|
+
':end': { S: endKey },
|
|
2030
|
+
},
|
|
2031
|
+
...(this._limit && { Limit: this._limit }),
|
|
2032
|
+
ScanIndexForward: false,
|
|
2033
|
+
})
|
|
2034
|
+
|
|
2035
|
+
return (result.Items || []).map((item: any) => new Conversion(unmarshall(item)))
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
// ============================================================================
|
|
2040
|
+
// Re-exports
|
|
2041
|
+
// ============================================================================
|
|
2042
|
+
|
|
2043
|
+
export { Model, configureModels, DynamoDBClient, createClient }
|