@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.
Files changed (237) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/LICENSE.md +21 -0
  3. package/README.md +361 -0
  4. package/bin/cli.ts +169 -0
  5. package/dist/Analytics.d.ts +558 -0
  6. package/dist/api.d.ts +109 -0
  7. package/dist/batching.d.ts +93 -0
  8. package/dist/chunk-2mx7fq49.js +4 -0
  9. package/dist/chunk-3z29508k.js +204 -0
  10. package/dist/chunk-deephkz6.js +56 -0
  11. package/dist/chunk-j261vgyp.js +305 -0
  12. package/dist/chunk-xga17tz7.js +207 -0
  13. package/dist/config.d.ts +132 -0
  14. package/dist/dashboard/components/index.d.ts +80 -0
  15. package/dist/dashboard/composables/useAnalytics.d.ts +99 -0
  16. package/dist/dashboard/index.d.ts +110 -0
  17. package/dist/dashboard/types/index.d.ts +144 -0
  18. package/dist/dashboard/utils/index.d.ts +81 -0
  19. package/dist/dynamodb.d.ts +86 -0
  20. package/dist/funnels.d.ts +104 -0
  21. package/dist/geolocation.d.ts +96 -0
  22. package/dist/index.d.ts +371 -0
  23. package/dist/index.js +10211 -0
  24. package/dist/infrastructure/cdk.d.ts +60 -0
  25. package/dist/infrastructure/cloudformation.d.ts +44 -0
  26. package/dist/infrastructure/index.d.ts +45 -0
  27. package/dist/infrastructure/setup.d.ts +133 -0
  28. package/dist/integrations/cloudflare.d.ts +76 -0
  29. package/dist/integrations/hono.d.ts +60 -0
  30. package/dist/integrations/index.d.ts +24 -0
  31. package/dist/integrations/nuxt.d.ts +7 -0
  32. package/dist/integrations/nuxt.js +42 -0
  33. package/dist/integrations/runtime/use-ts-analytics.d.ts +18 -0
  34. package/dist/integrations/runtime/use-ts-analytics.js +17 -0
  35. package/dist/integrations/stx.d.ts +88 -0
  36. package/dist/integrations/stx.js +18 -0
  37. package/dist/lib/crypto-random.d.ts +4 -0
  38. package/dist/lib/salt.d.ts +16 -0
  39. package/dist/local.d.ts +56 -0
  40. package/dist/model-connector.d.ts +145 -0
  41. package/dist/models/AggregatedStats.d.ts +9 -0
  42. package/dist/models/CampaignStats.d.ts +9 -0
  43. package/dist/models/Conversion.d.ts +11 -0
  44. package/dist/models/CustomEvent.d.ts +11 -0
  45. package/dist/models/DeviceStats.d.ts +9 -0
  46. package/dist/models/EventStats.d.ts +9 -0
  47. package/dist/models/GeoStats.d.ts +9 -0
  48. package/dist/models/Goal.d.ts +9 -0
  49. package/dist/models/GoalStats.d.ts +9 -0
  50. package/dist/models/PageStats.d.ts +11 -0
  51. package/dist/models/PageView.d.ts +13 -0
  52. package/dist/models/RealtimeStats.d.ts +9 -0
  53. package/dist/models/ReferrerStats.d.ts +9 -0
  54. package/dist/models/Session.d.ts +11 -0
  55. package/dist/models/Site.d.ts +11 -0
  56. package/dist/models/index.d.ts +28 -0
  57. package/dist/models/types.d.ts +60 -0
  58. package/dist/sqs-buffering.d.ts +243 -0
  59. package/dist/stacks-integration.d.ts +159 -0
  60. package/dist/tracking-script.d.ts +71 -0
  61. package/dist/tracking.d.ts +17 -0
  62. package/dist/tracking.js +26 -0
  63. package/dist/types.d.ts +595 -0
  64. package/dist/utils/geolocation.d.ts +111 -0
  65. package/dist/utils/user-agent.d.ts +17 -0
  66. package/dist/version.d.ts +7 -0
  67. package/package.json +119 -0
  68. package/src/Analytics.ts +3349 -0
  69. package/src/api.ts +1286 -0
  70. package/src/assets/crosswind.css +2220 -0
  71. package/src/batching.ts +452 -0
  72. package/src/components/dashboard/index.ts +18 -0
  73. package/src/config.ts +456 -0
  74. package/src/dashboard/Dashboard.stx +1517 -0
  75. package/src/dashboard/components/AlertCard.stx +177 -0
  76. package/src/dashboard/components/AnalyticsDashboard.stx +354 -0
  77. package/src/dashboard/components/AnimatedNumber.stx +86 -0
  78. package/src/dashboard/components/BarChart.stx +220 -0
  79. package/src/dashboard/components/BrowserBreakdown.stx +98 -0
  80. package/src/dashboard/components/BrowsersTable.stx +125 -0
  81. package/src/dashboard/components/CampaignBreakdown.stx +163 -0
  82. package/src/dashboard/components/CampaignTable.stx +238 -0
  83. package/src/dashboard/components/CountryList.stx +101 -0
  84. package/src/dashboard/components/DataTable.stx +226 -0
  85. package/src/dashboard/components/DateRangePicker.stx +77 -0
  86. package/src/dashboard/components/DeviceBreakdown.stx +94 -0
  87. package/src/dashboard/components/DevicesTable.stx +163 -0
  88. package/src/dashboard/components/DonutChart.stories.ts +55 -0
  89. package/src/dashboard/components/DonutChart.stx +157 -0
  90. package/src/dashboard/components/EmptyState.stx +90 -0
  91. package/src/dashboard/components/EngagementMetrics.stx +183 -0
  92. package/src/dashboard/components/EventsSection.stx +192 -0
  93. package/src/dashboard/components/FilterBar.stx +104 -0
  94. package/src/dashboard/components/FullAnalyticsDashboard.stx +455 -0
  95. package/src/dashboard/components/FunnelChart.stx +142 -0
  96. package/src/dashboard/components/GeoTable.stx +337 -0
  97. package/src/dashboard/components/GoalsPanel.stx +109 -0
  98. package/src/dashboard/components/Header.stx +39 -0
  99. package/src/dashboard/components/HeatmapChart.stx +140 -0
  100. package/src/dashboard/components/LiveActivityFeed.stx +172 -0
  101. package/src/dashboard/components/MetricComparison.stx +106 -0
  102. package/src/dashboard/components/MiniStats.stx +96 -0
  103. package/src/dashboard/components/OSBreakdown.stx +124 -0
  104. package/src/dashboard/components/PageDetailCard.stx +102 -0
  105. package/src/dashboard/components/PagesTable.stx +127 -0
  106. package/src/dashboard/components/ProgressRing.stx +89 -0
  107. package/src/dashboard/components/RealtimeCounter.stories.ts +45 -0
  108. package/src/dashboard/components/RealtimeCounter.stx +46 -0
  109. package/src/dashboard/components/ReferrersTable.stx +180 -0
  110. package/src/dashboard/components/SparklineChart.stx +160 -0
  111. package/src/dashboard/components/StatCard.stories.ts +58 -0
  112. package/src/dashboard/components/StatCard.stx +90 -0
  113. package/src/dashboard/components/SummaryStats.stx +81 -0
  114. package/src/dashboard/components/TabNav.stx +66 -0
  115. package/src/dashboard/components/ThemeSwitcher.stx +124 -0
  116. package/src/dashboard/components/TimeSeriesChart.stx +106 -0
  117. package/src/dashboard/components/TopList.stories.ts +58 -0
  118. package/src/dashboard/components/TopList.stx +74 -0
  119. package/src/dashboard/components/TrendIndicator.stx +84 -0
  120. package/src/dashboard/components/heatmap/ClickHeatmap.stx +264 -0
  121. package/src/dashboard/components/heatmap/ElementClickList.stx +125 -0
  122. package/src/dashboard/components/heatmap/HeatmapControls.stx +125 -0
  123. package/src/dashboard/components/heatmap/HeatmapLegend.stx +69 -0
  124. package/src/dashboard/components/heatmap/PageHeatmap.stx +264 -0
  125. package/src/dashboard/components/heatmap/ScrollHeatmap.stx +127 -0
  126. package/src/dashboard/components/heatmap/index.ts +12 -0
  127. package/src/dashboard/components/index.ts +86 -0
  128. package/src/dashboard/composables/useAnalytics.ts +465 -0
  129. package/src/dashboard/demo/DemoApp.stx +370 -0
  130. package/src/dashboard/demo/index.ts +8 -0
  131. package/src/dashboard/demo/mockData.ts +234 -0
  132. package/src/dashboard/index.ts +117 -0
  133. package/src/dashboard/stx-shim.d.ts +4 -0
  134. package/src/dashboard/types/index.ts +203 -0
  135. package/src/dashboard/utils/index.ts +426 -0
  136. package/src/dynamodb.ts +344 -0
  137. package/src/funnels.ts +534 -0
  138. package/src/geolocation.ts +515 -0
  139. package/src/handlers/alerts.ts +328 -0
  140. package/src/handlers/annotations.ts +128 -0
  141. package/src/handlers/api-keys.ts +240 -0
  142. package/src/handlers/auth.ts +1020 -0
  143. package/src/handlers/authz.ts +137 -0
  144. package/src/handlers/collect.ts +724 -0
  145. package/src/handlers/data.ts +625 -0
  146. package/src/handlers/experiments.ts +138 -0
  147. package/src/handlers/funnels.ts +216 -0
  148. package/src/handlers/goals.ts +218 -0
  149. package/src/handlers/heatmaps.ts +272 -0
  150. package/src/handlers/index.ts +56 -0
  151. package/src/handlers/lib/read-cache.ts +63 -0
  152. package/src/handlers/misc.ts +486 -0
  153. package/src/handlers/oauth.ts +233 -0
  154. package/src/handlers/performance.ts +388 -0
  155. package/src/handlers/sessions.ts +413 -0
  156. package/src/handlers/sharing.ts +198 -0
  157. package/src/handlers/stats.ts +1368 -0
  158. package/src/handlers/team.ts +161 -0
  159. package/src/handlers/uptime.ts +283 -0
  160. package/src/handlers/views.ts +390 -0
  161. package/src/handlers/webhooks.ts +226 -0
  162. package/src/heatmap/index.ts +32 -0
  163. package/src/heatmap/tracking-script.ts +452 -0
  164. package/src/heatmap/types.ts +79 -0
  165. package/src/index.ts +387 -0
  166. package/src/infrastructure/cdk.ts +496 -0
  167. package/src/infrastructure/cloudformation.ts +595 -0
  168. package/src/infrastructure/index.ts +48 -0
  169. package/src/infrastructure/setup.ts +611 -0
  170. package/src/integrations/cloudflare.ts +732 -0
  171. package/src/integrations/hono.ts +589 -0
  172. package/src/integrations/index.ts +27 -0
  173. package/src/integrations/nuxt.ts +78 -0
  174. package/src/integrations/runtime/use-ts-analytics.ts +32 -0
  175. package/src/integrations/stx.ts +138 -0
  176. package/src/jobs/index.ts +127 -0
  177. package/src/lib/crypto-random.ts +41 -0
  178. package/src/lib/ddb-errors.ts +20 -0
  179. package/src/lib/dynamodb.ts +216 -0
  180. package/src/lib/email.ts +38 -0
  181. package/src/lib/ga-import.ts +471 -0
  182. package/src/lib/ga4-api.ts +244 -0
  183. package/src/lib/goals.ts +205 -0
  184. package/src/lib/index.ts +6 -0
  185. package/src/lib/ingest-counters.ts +123 -0
  186. package/src/lib/log.ts +36 -0
  187. package/src/lib/membership.ts +98 -0
  188. package/src/lib/plans.ts +90 -0
  189. package/src/lib/quota.ts +46 -0
  190. package/src/lib/rate-limit.ts +27 -0
  191. package/src/lib/rollups.ts +472 -0
  192. package/src/lib/salt.ts +97 -0
  193. package/src/lib/scheduler.ts +97 -0
  194. package/src/lib/significance.ts +66 -0
  195. package/src/lib/site-retention.ts +56 -0
  196. package/src/local.ts +360 -0
  197. package/src/model-connector.ts +616 -0
  198. package/src/models/AggregatedStats.ts +162 -0
  199. package/src/models/CampaignStats.ts +141 -0
  200. package/src/models/Conversion.ts +135 -0
  201. package/src/models/CustomEvent.ts +123 -0
  202. package/src/models/DeviceStats.ts +105 -0
  203. package/src/models/EventStats.ts +108 -0
  204. package/src/models/GeoStats.ts +112 -0
  205. package/src/models/Goal.ts +105 -0
  206. package/src/models/GoalStats.ts +106 -0
  207. package/src/models/PageStats.ts +152 -0
  208. package/src/models/PageView.ts +277 -0
  209. package/src/models/RealtimeStats.ts +96 -0
  210. package/src/models/ReferrerStats.ts +115 -0
  211. package/src/models/Session.ts +235 -0
  212. package/src/models/Site.ts +115 -0
  213. package/src/models/index.ts +50 -0
  214. package/src/models/orm/index.ts +2043 -0
  215. package/src/models/types.ts +71 -0
  216. package/src/router.ts +521 -0
  217. package/src/sqs-buffering.ts +806 -0
  218. package/src/stacks-integration.ts +527 -0
  219. package/src/tracking-script.ts +944 -0
  220. package/src/tracking.ts +27 -0
  221. package/src/types/analytics.ts +219 -0
  222. package/src/types/api.ts +75 -0
  223. package/src/types/bun-router.d.ts +15 -0
  224. package/src/types/dashboard.ts +62 -0
  225. package/src/types/index.ts +66 -0
  226. package/src/types/stx.d.ts +27 -0
  227. package/src/types/window.d.ts +101 -0
  228. package/src/types.ts +911 -0
  229. package/src/utils/cache.ts +148 -0
  230. package/src/utils/date.ts +118 -0
  231. package/src/utils/filters.ts +71 -0
  232. package/src/utils/geolocation.ts +323 -0
  233. package/src/utils/index.ts +9 -0
  234. package/src/utils/response.ts +180 -0
  235. package/src/utils/timezone-country.ts +242 -0
  236. package/src/utils/user-agent.ts +110 -0
  237. 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 }