Add goal management functionality in analytics (#100)

* Add goal management functionality in analytics

- Introduced goal creation, deletion, and updating capabilities in the analytics API, allowing users to define conversion goals based on path or event types.
- Implemented corresponding React hooks for managing goals, including fetching, creating, updating, and deleting goals.
- Enhanced the UI with a dedicated Goals page and components for listing and managing goals, improving user experience in tracking conversions.
- Updated package dependencies to include necessary libraries for form handling and validation.

* Enhance goals management with pagination and sorting

- Added pagination and sorting capabilities to the goals fetching logic in the analytics API, allowing users to navigate through goals more efficiently.
- Updated the GoalsPage component to manage current page state and handle page changes, improving user experience.
- Modified the GoalsList component to display pagination metadata and navigation controls, facilitating better goal management.
- Adjusted the server-side getGoals function to support pagination and sorting parameters, ensuring accurate data retrieval.

* Refactor GoalsPage and GoalCard components for improved UI and functionality

- Updated GoalsPage to include a SubHeader component and adjusted layout for better responsiveness.
- Enhanced loading and empty state handling in GoalsPage for a smoother user experience.
- Modified GoalCard to use icons for goal types, improving visual clarity and consistency in the UI.

* Refactor CreateGoalButton and GoalCard components for improved modal handling

- Updated CreateGoalButton to utilize a trigger prop for the GoalFormModal, simplifying modal state management.
- Refactored GoalCard to integrate GoalFormModal for editing goals, enhancing user interaction and reducing state complexity.
- Removed unnecessary state management and modal handling from both components, streamlining the codebase.

* Refactor GoalCard and Clickhouse initialization for improved code clarity

- Removed unnecessary imports in GoalCard component, streamlining the code.
- Updated Clickhouse initialization to include a new 'props' JSON field alongside 'properties', enhancing data structure for analytics.
- Added a utility function in PageviewQueue to parse properties, improving error handling and data integrity.

* enable clickhouse

* fix ch build

* fix ch build

* fix ch build

* wip

* wip

* wip

* Enable json

* add network

* add network

* Refactor Clickhouse configuration and remove unused properties from data models

* Refactor property value handling in analytics queries to utilize native JSON types in ClickHouse, improving type safety and performance.
This commit is contained in:
Bill Yang 2025-04-28 20:58:43 -07:00 committed by GitHub
parent db1bbe2fb1
commit ca0faeb484
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 2293 additions and 72 deletions

View file

@ -0,0 +1,5 @@
<clickhouse>
<settings>
<enable_json_type>1</enable_json_type>
</settings>
</clickhouse>

View file

@ -1,4 +1,3 @@
<clickhouse>
<listen_host>::</listen_host>
<listen_host>0.0.0.0</listen_host>
</clickhouse>
</clickhouse>

324
client/package-lock.json generated
View file

@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@better-auth/stripe": "^1.2.3",
"@hookform/resolvers": "^5.0.1",
"@nivo/bar": "^0.88.0",
"@nivo/calendar": "^0.88.0",
"@nivo/core": "^0.88.0",
@ -18,13 +19,13 @@
"@radix-ui/react-alert-dialog": "^1.1.6",
"@radix-ui/react-dialog": "^1.1.7",
"@radix-ui/react-dropdown-menu": "^2.1.5",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-label": "^2.1.4",
"@radix-ui/react-navigation-menu": "^1.2.5",
"@radix-ui/react-popover": "^1.1.5",
"@radix-ui/react-select": "^2.1.5",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slider": "^1.2.3",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-tooltip": "^1.1.8",
@ -64,6 +65,7 @@
"react-day-picker": "^8.10.1",
"react-dom": "^19.0.0",
"react-globe.gl": "^2.33.2",
"react-hook-form": "^7.56.1",
"react-leaflet": "^5.0.0",
"react-select": "^5.10.0",
"sonner": "^2.0.1",
@ -71,6 +73,7 @@
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"three": "^0.175.0",
"zod": "^3.24.3",
"zustand": "^5.0.3"
},
"devDependencies": {
@ -1190,6 +1193,17 @@
"resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
"integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="
},
"node_modules/@hookform/resolvers": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.0.1.tgz",
"integrity": "sha512-u/+Jp83luQNx9AdyW2fIPGY6Y7NG68eN2ZW8FOJYL+M0i4s49+refdJdOp/A9n9HFQtQs3HIDHQvX3ZET2o7YA==",
"dependencies": {
"@standard-schema/utils": "^0.3.0"
},
"peerDependencies": {
"react-hook-form": "^7.55.0"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.1",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.1.tgz",
@ -2249,6 +2263,23 @@
}
}
},
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-arrow": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.2.tgz",
@ -2401,6 +2432,23 @@
}
}
},
"node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz",
@ -2673,23 +2721,6 @@
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz",
"integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
@ -2885,6 +2916,28 @@
}
}
},
"node_modules/@radix-ui/react-form/node_modules/@radix-ui/react-label": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.2.tgz",
"integrity": "sha512-zo1uGMTaNlHehDyFQcDZXRJhUPDuukcnHz0/jnrup0JA6qL+AFpAnty+7VKa9esuU5xTblAZzTGYJKSKaBxBhw==",
"dependencies": {
"@radix-ui/react-primitive": "2.0.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-hover-card": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.6.tgz",
@ -2933,11 +2986,33 @@
}
},
"node_modules/@radix-ui/react-label": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.2.tgz",
"integrity": "sha512-zo1uGMTaNlHehDyFQcDZXRJhUPDuukcnHz0/jnrup0JA6qL+AFpAnty+7VKa9esuU5xTblAZzTGYJKSKaBxBhw==",
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.4.tgz",
"integrity": "sha512-wy3dqizZnZVV4ja0FNnUhIWNwWdoldXrneEyUcVtLYDAt8ovGS4ridtMAOGgXBBIfggL4BOveVWsjXDORdGEQg==",
"dependencies": {
"@radix-ui/react-primitive": "2.0.2"
"@radix-ui/react-primitive": "2.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.0.tgz",
"integrity": "sha512-/J/FhLdK0zVcILOwt5g+dH4KnkonCtkVJsa2G6JmvbbtZfBEI1gMsO3QMjseL4F/SwfAMt1Vc/0XKYKq+xJ1sw==",
"dependencies": {
"@radix-ui/react-slot": "1.2.0"
},
"peerDependencies": {
"@types/react": "*",
@ -2993,6 +3068,23 @@
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menubar": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.6.tgz",
@ -3117,6 +3209,23 @@
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.2.tgz",
@ -3216,6 +3325,23 @@
}
}
},
"node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-progress": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.2.tgz",
@ -3372,6 +3498,23 @@
}
}
},
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-visually-hidden": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.2.tgz",
@ -3449,11 +3592,11 @@
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz",
"integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
@ -3465,6 +3608,20 @@
}
}
},
"node_modules/@radix-ui/react-slot/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-switch": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.3.tgz",
@ -3690,6 +3847,23 @@
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-visually-hidden": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.2.tgz",
@ -3844,20 +4018,6 @@
}
}
},
"node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-primitive": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.3.tgz",
@ -3880,23 +4040,6 @@
}
}
},
"node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-slot": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz",
"integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/rect": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz",
@ -4113,6 +4256,11 @@
"node": ">=20.0.0"
}
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="
},
"node_modules/@swc/counter": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
@ -7948,6 +8096,45 @@
}
}
},
"node_modules/radix-ui/node_modules/@radix-ui/react-label": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.2.tgz",
"integrity": "sha512-zo1uGMTaNlHehDyFQcDZXRJhUPDuukcnHz0/jnrup0JA6qL+AFpAnty+7VKa9esuU5xTblAZzTGYJKSKaBxBhw==",
"dependencies": {
"@radix-ui/react-primitive": "2.0.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/radix-ui/node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/radix-ui/node_modules/@radix-ui/react-visually-hidden": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.2.tgz",
@ -8029,6 +8216,21 @@
"react": "*"
}
},
"node_modules/react-hook-form": {
"version": "7.56.1",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.56.1.tgz",
"integrity": "sha512-qWAVokhSpshhcEuQDSANHx3jiAEFzu2HAaaQIzi/r9FNPm1ioAvuJSD4EuZzWd7Al7nTRKcKPnBKO7sRn+zavQ==",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@ -9429,9 +9631,9 @@
}
},
"node_modules/zod": {
"version": "3.24.2",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz",
"integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==",
"version": "3.24.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz",
"integrity": "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View file

@ -10,6 +10,7 @@
},
"dependencies": {
"@better-auth/stripe": "^1.2.3",
"@hookform/resolvers": "^5.0.1",
"@nivo/bar": "^0.88.0",
"@nivo/calendar": "^0.88.0",
"@nivo/core": "^0.88.0",
@ -19,13 +20,13 @@
"@radix-ui/react-alert-dialog": "^1.1.6",
"@radix-ui/react-dialog": "^1.1.7",
"@radix-ui/react-dropdown-menu": "^2.1.5",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-label": "^2.1.4",
"@radix-ui/react-navigation-menu": "^1.2.5",
"@radix-ui/react-popover": "^1.1.5",
"@radix-ui/react-select": "^2.1.5",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slider": "^1.2.3",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-tooltip": "^1.1.8",
@ -65,6 +66,7 @@
"react-day-picker": "^8.10.1",
"react-dom": "^19.0.0",
"react-globe.gl": "^2.33.2",
"react-hook-form": "^7.56.1",
"react-leaflet": "^5.0.0",
"react-select": "^5.10.0",
"sonner": "^2.0.1",
@ -72,6 +74,7 @@
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"three": "^0.175.0",
"zod": "^3.24.3",
"zustand": "^5.0.3"
},
"devDependencies": {

View file

@ -0,0 +1,51 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { BACKEND_URL } from "../../lib/const";
import { authedFetchWithError } from "../utils";
export interface CreateGoalRequest {
siteId: number;
name?: string;
goalType: "path" | "event";
config: {
pathPattern?: string;
eventName?: string;
eventPropertyKey?: string;
eventPropertyValue?: string | number | boolean;
};
}
interface CreateGoalResponse {
success: boolean;
goalId: number;
}
export function useCreateGoal() {
const queryClient = useQueryClient();
return useMutation<CreateGoalResponse, Error, CreateGoalRequest>({
mutationFn: async (goalData) => {
try {
return await authedFetchWithError<CreateGoalResponse>(
`${BACKEND_URL}/goal/create`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(goalData),
}
);
} catch (error) {
throw new Error(
error instanceof Error ? error.message : "Failed to create goal"
);
}
},
onSuccess: (_, variables) => {
// Invalidate goals query to refetch with the new goal
queryClient.invalidateQueries({
queryKey: ["goals", variables.siteId.toString()],
});
},
});
}

View file

@ -0,0 +1,32 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { BACKEND_URL } from "../../lib/const";
import { authedFetchWithError } from "../utils";
import { useStore } from "../../lib/store";
export function useDeleteGoal() {
const queryClient = useQueryClient();
const { site } = useStore();
return useMutation<{ success: boolean }, Error, number>({
mutationFn: async (goalId: number) => {
try {
return await authedFetchWithError<{ success: boolean }>(
`${BACKEND_URL}/goal/${goalId}`,
{
method: "DELETE",
}
);
} catch (error) {
throw new Error(
error instanceof Error ? error.message : "Failed to delete goal"
);
}
},
onSuccess: () => {
// Invalidate goals query to refetch without the deleted goal
queryClient.invalidateQueries({
queryKey: ["goals", site],
});
},
});
}

View file

@ -0,0 +1,39 @@
import { useQuery } from "@tanstack/react-query";
import { BACKEND_URL } from "../../lib/const";
import { authedFetch } from "../utils";
import { useStore, Filter } from "../../lib/store";
import { Goal } from "./useGetGoals";
interface GetGoalResponse {
data: Goal;
}
export function useGetGoal({
goalId,
startDate,
endDate,
filters,
enabled = true,
}: {
goalId: number;
startDate: string;
endDate: string;
filters?: Filter[];
enabled?: boolean;
}) {
const { site } = useStore();
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
return useQuery({
queryKey: ["goal", site, goalId, startDate, endDate, timezone, filters],
queryFn: async () => {
return authedFetch(`${BACKEND_URL}/goal/${goalId}/${site}`, {
startDate,
endDate,
timezone,
filters,
}).then((res) => res.json());
},
enabled: !!site && !!goalId && enabled,
});
}

View file

@ -0,0 +1,83 @@
import { useQuery } from "@tanstack/react-query";
import { BACKEND_URL } from "../../lib/const";
import { authedFetch } from "../utils";
import { useStore, Filter } from "../../lib/store";
export interface Goal {
goalId: number;
name: string | null;
goalType: "path" | "event";
config: {
pathPattern?: string;
eventName?: string;
eventPropertyKey?: string;
eventPropertyValue?: string | number | boolean;
};
createdAt: string;
total_conversions: number;
total_sessions: number;
conversion_rate: number;
}
export interface PaginationMeta {
total: number;
page: number;
pageSize: number;
totalPages: number;
}
interface GoalsResponse {
data: Goal[];
meta: PaginationMeta;
}
export function useGetGoals({
startDate,
endDate,
filters,
page = 1,
pageSize = 10,
sort = "createdAt",
order = "desc",
enabled = true,
}: {
startDate: string;
endDate: string;
filters?: Filter[];
page?: number;
pageSize?: number;
sort?: "goalId" | "name" | "goalType" | "createdAt";
order?: "asc" | "desc";
enabled?: boolean;
}) {
const { site } = useStore();
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
return useQuery({
queryKey: [
"goals",
site,
startDate,
endDate,
timezone,
filters,
page,
pageSize,
sort,
order,
],
queryFn: async () => {
return authedFetch(`${BACKEND_URL}/goals/${site}`, {
startDate,
endDate,
timezone,
filters,
page,
pageSize,
sort,
order,
}).then((res) => res.json());
},
enabled: !!site && enabled,
});
}

View file

@ -0,0 +1,54 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { BACKEND_URL } from "../../lib/const";
import { authedFetchWithError } from "../utils";
import { useStore } from "../../lib/store";
export interface UpdateGoalRequest {
goalId: number;
siteId: number;
name?: string;
goalType: "path" | "event";
config: {
pathPattern?: string;
eventName?: string;
eventPropertyKey?: string;
eventPropertyValue?: string | number | boolean;
};
}
interface UpdateGoalResponse {
success: boolean;
goalId: number;
}
export function useUpdateGoal() {
const queryClient = useQueryClient();
const { site } = useStore();
return useMutation<UpdateGoalResponse, Error, UpdateGoalRequest>({
mutationFn: async (goalData) => {
try {
return await authedFetchWithError<UpdateGoalResponse>(
`${BACKEND_URL}/goal/update`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(goalData),
}
);
} catch (error) {
throw new Error(
error instanceof Error ? error.message : "Failed to update goal"
);
}
},
onSuccess: () => {
// Invalidate goals query to refetch with the updated goal
queryClient.invalidateQueries({
queryKey: ["goals", site],
});
},
});
}

View file

@ -1,5 +1,5 @@
"use client";
import { Funnel } from "@phosphor-icons/react/dist/ssr";
import { Funnel, Target } from "@phosphor-icons/react/dist/ssr";
import {
ChartBarDecreasing,
Earth,
@ -88,6 +88,12 @@ export function Sidebar() {
href={getTabPath("funnels")}
icon={<Funnel weight="bold" />}
/>
<SidebarLink
label="Goals"
active={isActiveTab("goals")}
href={getTabPath("goals")}
icon={<Target weight="bold" />}
/>
<SidebarLink
label="Journeys"
active={isActiveTab("journeys")}

View file

@ -0,0 +1,25 @@
"use client";
import { Plus } from "lucide-react";
import { Button } from "../../../../components/ui/button";
import GoalFormModal from "./GoalFormModal";
interface CreateGoalButtonProps {
siteId: number;
}
export default function CreateGoalButton({ siteId }: CreateGoalButtonProps) {
return (
<>
<GoalFormModal
siteId={siteId}
trigger={
<Button className="flex items-center gap-1">
<Plus className="h-4 w-4" />
<span>Add Goal</span>
</Button>
}
/>
</>
);
}

View file

@ -0,0 +1,156 @@
"use client";
import { FileText, MousePointerClick, Pencil, Trash } from "lucide-react";
import { useState } from "react";
import { useDeleteGoal } from "../../../../api/analytics/useDeleteGoal";
import { Goal } from "../../../../api/analytics/useGetGoals";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "../../../../components/ui/alert-dialog";
import { Button } from "../../../../components/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "../../../../components/ui/card";
import GoalFormModal from "./GoalFormModal";
interface GoalCardProps {
goal: Goal;
siteId: number;
}
export default function GoalCard({ goal, siteId }: GoalCardProps) {
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const deleteGoalMutation = useDeleteGoal();
const handleDelete = async () => {
try {
await deleteGoalMutation.mutateAsync(goal.goalId);
setIsDeleteDialogOpen(false);
} catch (error) {
console.error("Error deleting goal:", error);
}
};
return (
<>
<Card className="h-full flex flex-col">
<CardHeader className="pb-2">
<div className="flex justify-between items-start">
<CardTitle className="text-lg">
{goal.name || `Goal #${goal.goalId}`}
</CardTitle>
<div className="flex gap-1">
<GoalFormModal
siteId={siteId}
goal={goal}
trigger={
<Button variant="ghost" size="smIcon">
<Pencil />
</Button>
}
/>
<Button
onClick={() => setIsDeleteDialogOpen(true)}
className=""
variant="ghost"
size="smIcon"
>
<Trash />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="pb-2 flex-grow">
<div className="space-y-2">
<div className="flex items-center">
<span className="text-sm text-gray-500 mr-2">Type:</span>
{goal.goalType === "path" ? (
<div className="flex items-center gap-1">
<FileText className="w-4 h-4 text-blue-500" />
<span className="text-sm">Path Goal</span>
</div>
) : (
<div className="flex items-center gap-1">
<MousePointerClick className="w-4 h-4 text-amber-500" />
<span className="text-sm">Event Goal</span>
</div>
)}
</div>
<div>
<span className="text-sm text-gray-500">Pattern:</span>
<div className="mt-1 text-sm truncate">
{goal.goalType === "path" ? (
<code className="bg-neutral-800/50 px-1 py-0.5 rounded">
{goal.config.pathPattern}
</code>
) : (
<code className="bg-neutral-800/50 px-1 py-0.5 rounded">
{goal.config.eventName}
</code>
)}
</div>
{goal.goalType === "event" && goal.config.eventPropertyKey && (
<div className="mt-1 text-xs text-gray-500">
Property:{" "}
<code className="bg-neutral-800/50 px-1 py-0.5 rounded">
{goal.config.eventPropertyKey} ={" "}
{String(goal.config.eventPropertyValue)}
</code>
</div>
)}
</div>
</div>
</CardContent>
<CardFooter className="grid grid-cols-2 gap-2 p-4 mt-auto bg-neutral-800/20 rounded-b-lg">
<div className="text-center">
<div className="font-bold text-lg">{goal.total_conversions}</div>
<div className="text-xs text-gray-500">Conversions</div>
</div>
<div className="text-center">
<div className="font-bold text-lg">
{(goal.conversion_rate * 100).toFixed(2)}%
</div>
<div className="text-xs text-gray-500">Conversion Rate</div>
</div>
</CardFooter>
</Card>
{/* Delete Confirmation Dialog */}
<AlertDialog
open={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you sure you want to delete this goal?
</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the
goal and remove it from all reports.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} variant="destructive">
{deleteGoalMutation.isPending ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View file

@ -0,0 +1,336 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../../../../components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "../../../../components/ui/form";
import { Input } from "../../../../components/ui/input";
import { Button } from "../../../../components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../../../../components/ui/select";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { Goal } from "../../../../api/analytics/useGetGoals";
import { useCreateGoal } from "../../../../api/analytics/useCreateGoal";
import { useUpdateGoal } from "../../../../api/analytics/useUpdateGoal";
import { Switch } from "../../../../components/ui/switch";
import { Label } from "../../../../components/ui/label";
// Define form schema
const formSchema = z
.object({
name: z.string().optional(),
goalType: z.enum(["path", "event"]),
config: z.object({
pathPattern: z.string().optional(),
eventName: z.string().optional(),
eventPropertyKey: z.string().optional(),
eventPropertyValue: z.string().optional(),
}),
})
.refine(
(data) => {
if (data.goalType === "path") {
return !!data.config.pathPattern;
} else if (data.goalType === "event") {
return !!data.config.eventName;
}
return false;
},
{
message: "Configuration is required based on goal type",
path: ["config"],
}
);
type FormValues = z.infer<typeof formSchema>;
interface GoalFormModalProps {
siteId: number;
goal?: Goal; // Optional goal for editing mode
trigger: React.ReactNode;
}
export default function GoalFormModal({
siteId,
goal,
trigger,
}: GoalFormModalProps) {
const [isOpen, setIsOpen] = useState(false);
const [useProperties, setUseProperties] = useState(
!!goal?.config.eventPropertyKey && !!goal?.config.eventPropertyValue
);
const onClose = () => {
setIsOpen(false);
};
const isEditMode = !!goal;
const createGoal = useCreateGoal();
const updateGoal = useUpdateGoal();
// Initialize form with default values or existing goal
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: isEditMode
? {
name: goal.name || "",
goalType: goal.goalType,
config: {
pathPattern: goal.config.pathPattern || "",
eventName: goal.config.eventName || "",
eventPropertyKey: goal.config.eventPropertyKey || "",
eventPropertyValue:
goal.config.eventPropertyValue !== undefined
? String(goal.config.eventPropertyValue)
: "",
},
}
: {
name: "",
goalType: "path",
config: {
pathPattern: "",
eventName: "",
eventPropertyKey: "",
eventPropertyValue: "",
},
},
});
const goalType = form.watch("goalType");
// Handle form submission
const onSubmit = async (values: FormValues) => {
try {
// Clean up the config based on goal type
if (values.goalType === "path") {
values.config.eventName = undefined;
values.config.eventPropertyKey = undefined;
values.config.eventPropertyValue = undefined;
} else if (values.goalType === "event") {
values.config.pathPattern = undefined;
// If not using properties, clear them
if (!useProperties) {
values.config.eventPropertyKey = undefined;
values.config.eventPropertyValue = undefined;
}
}
if (isEditMode) {
await updateGoal.mutateAsync({
goalId: goal.goalId,
siteId,
name: values.name,
goalType: values.goalType,
config: values.config,
});
} else {
await createGoal.mutateAsync({
siteId,
name: values.name,
goalType: values.goalType,
config: values.config,
});
}
setIsOpen(false);
} catch (error) {
console.error("Error saving goal:", error);
}
};
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
setIsOpen(open);
if (!open) {
form.reset();
}
}}
>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{isEditMode ? "Edit Goal" : "Create Goal"}</DialogTitle>
<DialogDescription>
{isEditMode
? "Update the goal details below."
: "Set up a new conversion goal to track specific user actions."}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Goal Name (optional)</FormLabel>
<FormControl>
<Input placeholder="e.g., Sign Up Completion" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="goalType"
render={({ field }) => (
<FormItem>
<FormLabel>Goal Type</FormLabel>
<Select
disabled={isEditMode} // Don't allow changing goal type in edit mode
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a goal type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="path">Path Goal</SelectItem>
<SelectItem value="event">Event Goal</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{goalType === "path" && (
<FormField
control={form.control}
name="config.pathPattern"
render={({ field }) => (
<FormItem>
<FormLabel>Path Pattern</FormLabel>
<FormControl>
<Input
placeholder="/checkout/complete or /product/*/view"
{...field}
/>
</FormControl>
<FormMessage />
<div className="text-xs text-gray-500 mt-1">
Use * to match a single path segment. Use ** to match
across segments.
</div>
</FormItem>
)}
/>
)}
{goalType === "event" && (
<>
<FormField
control={form.control}
name="config.eventName"
render={({ field }) => (
<FormItem>
<FormLabel>Event Name</FormLabel>
<FormControl>
<Input
placeholder="e.g., sign_up_completed"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="mt-4">
<div className="flex items-center space-x-2 mb-4">
<Switch
id="use-properties"
checked={useProperties}
onCheckedChange={setUseProperties}
/>
<Label htmlFor="use-properties">
Match specific event property
</Label>
</div>
{useProperties && (
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="config.eventPropertyKey"
render={({ field }) => (
<FormItem>
<FormLabel>Property Key</FormLabel>
<FormControl>
<Input placeholder="e.g., plan_type" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.eventPropertyValue"
render={({ field }) => (
<FormItem>
<FormLabel>Property Value</FormLabel>
<FormControl>
<Input placeholder="e.g., premium" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
</>
)}
<div className="flex justify-end space-x-2">
<Button variant="outline" type="button" onClick={onClose}>
Cancel
</Button>
<Button
type="submit"
disabled={createGoal.isPending || updateGoal.isPending}
variant="success"
>
{createGoal.isPending || updateGoal.isPending
? "Saving..."
: isEditMode
? "Update"
: "Create"}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,75 @@
"use client";
import { Goal, PaginationMeta } from "../../../../api/analytics/useGetGoals";
import GoalCard from "./GoalCard";
import { Button } from "../../../../components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
interface GoalsListProps {
goals: Goal[];
siteId: number;
paginationMeta: PaginationMeta;
onPageChange: (page: number) => void;
}
export default function GoalsList({
goals,
siteId,
paginationMeta,
onPageChange,
}: GoalsListProps) {
const { page, pageSize, total, totalPages } = paginationMeta;
const handlePrevPage = () => {
if (page > 1) {
onPageChange(page - 1);
}
};
const handleNextPage = () => {
if (page < totalPages) {
onPageChange(page + 1);
}
};
return (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{goals.map((goal) => (
<GoalCard key={goal.goalId} goal={goal} siteId={siteId} />
))}
</div>
{totalPages > 1 && (
<div className="flex justify-between items-center">
<div className="text-sm text-gray-500">
Showing {goals.length} of {total} goals
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={handlePrevPage}
disabled={page === 1}
>
<ChevronLeft className="h-4 w-4 mr-1" />
Previous
</Button>
<div className="text-sm px-3">
Page {page} of {totalPages}
</div>
<Button
variant="outline"
size="sm"
onClick={handleNextPage}
disabled={page === totalPages}
>
Next
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,70 @@
"use client";
import { useState } from "react";
import { useGetGoals } from "../../../api/analytics/useGetGoals";
import { getStartAndEndDate } from "../../../api/utils";
import { SESSION_PAGE_FILTERS, useStore } from "../../../lib/store";
import { SubHeader } from "../components/SubHeader/SubHeader";
import CreateGoalButton from "./components/CreateGoalButton";
import GoalsList from "./components/GoalsList";
export default function GoalsPage() {
const { time, filters, site, setTime } = useStore();
const { startDate, endDate } = getStartAndEndDate(time);
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 9; // Show 9 cards (3x3 grid)
// Handle the case where startDate or endDate might be null (for 'all-time' mode)
const queryStartDate = startDate || "2020-01-01"; // Default fallback date
const queryEndDate = endDate || new Date().toISOString().split("T")[0]; // Today
// Fetch goals data with pagination
const { data: goalsData, isLoading } = useGetGoals({
startDate: queryStartDate,
endDate: queryEndDate,
filters,
page: currentPage,
pageSize,
});
// Handle page change
const handlePageChange = (newPage: number) => {
setCurrentPage(newPage);
// Scroll to top of page when changing pages
window.scrollTo({
top: 0,
behavior: "smooth",
});
};
return (
<div className="p-2 md:p-4 max-w-[1400px] mx-auto space-y-3">
<SubHeader availableFilters={SESSION_PAGE_FILTERS} />
<div className="flex items-center justify-between">
<div />
<CreateGoalButton siteId={Number(site)} />
</div>
{isLoading ? (
<div className="flex justify-center py-8">
<div className="animate-pulse">Loading goals data...</div>
</div>
) : !goalsData || goalsData.data.length === 0 ? (
<div className="py-10 text-center">
<h3 className="text-lg font-medium">No goals configured yet</h3>
<p className="text-sm text-gray-500 mt-2">
Create your first conversion goal to start tracking important user
actions.
</p>
</div>
) : (
<GoalsList
goals={goalsData.data}
siteId={Number(site)}
paginationMeta={goalsData.meta}
onPageChange={handlePageChange}
/>
)}
</div>
);
}

View file

@ -0,0 +1,178 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
FormProvider,
useFormContext,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
)
})
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
<Label
ref={ref}
className={cn(error && "text-red-500 dark:text-red-900", className)}
htmlFor={formItemId}
{...props}
/>
)
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
})
FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-[0.8rem] text-neutral-500 dark:text-neutral-400", className)}
{...props}
/>
)
})
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : children
if (!body) {
return null
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-[0.8rem] font-medium text-red-500 dark:text-red-900", className)}
{...props}
>
{body}
</p>
)
})
FormMessage.displayName = "FormMessage"
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View file

@ -27,8 +27,7 @@ services:
- "9000:9000"
volumes:
- clickhouse-data:/var/lib/clickhouse
# Reduce clickhouse logging
# - ./clickhouse_config:/etc/clickhouse-server/config.d
- ./clickhouse_config:/etc/clickhouse-server/config.d
environment:
- CLICKHOUSE_DB=analytics
- CLICKHOUSE_USER=default

View file

@ -27,7 +27,6 @@ services:
- "9000:9000"
volumes:
- clickhouse-data:/var/lib/clickhouse
# Reduce clickhouse logging
- ./clickhouse_config:/etc/clickhouse-server/config.d
environment:
- CLICKHOUSE_DB=analytics

View file

@ -0,0 +1,133 @@
import { FastifyReply, FastifyRequest } from "fastify";
import { db } from "../../db/postgres/postgres.js";
import { goals } from "../../db/postgres/schema.js";
import { getUserHasAccessToSite } from "../../lib/auth-utils.js";
import { z } from "zod";
// Define validation schema for path pattern
const pathPatternSchema = z.string().min(1, "Path pattern cannot be empty");
// Define validation schema for event config
const eventConfigSchema = z
.object({
eventName: z.string().min(1, "Event name cannot be empty"),
eventPropertyKey: z.string().optional(),
eventPropertyValue: z
.union([z.string(), z.number(), z.boolean()])
.optional(),
})
.refine(
(data) => {
// If one property matching field is provided, both must be provided
if (data.eventPropertyKey && data.eventPropertyValue === undefined) {
return false;
}
if (data.eventPropertyValue !== undefined && !data.eventPropertyKey) {
return false;
}
return true;
},
{
message:
"Both eventPropertyKey and eventPropertyValue must be provided together or omitted together",
}
);
// Define validation schema for the goal request
const goalSchema = z
.object({
siteId: z.number().int().positive("Site ID must be a positive integer"),
name: z.string().optional(),
goalType: z.enum(["path", "event"]),
config: z.object({
pathPattern: z.string().optional(),
eventName: z.string().optional(),
eventPropertyKey: z.string().optional(),
eventPropertyValue: z
.union([z.string(), z.number(), z.boolean()])
.optional(),
}),
})
.refine(
(data) => {
if (data.goalType === "path") {
return !!data.config.pathPattern;
} else if (data.goalType === "event") {
return !!data.config.eventName;
}
return false;
},
{
message: "Configuration must match goal type",
path: ["config"],
}
);
type CreateGoalRequest = z.infer<typeof goalSchema>;
export async function createGoal(
request: FastifyRequest<{
Body: CreateGoalRequest;
}>,
reply: FastifyReply
) {
try {
// Validate the request body
const validatedData = goalSchema.parse(request.body);
const { siteId, name, goalType, config } = validatedData;
// Check user access to site
const userHasAccessToSite = await getUserHasAccessToSite(
request,
siteId.toString()
);
if (!userHasAccessToSite) {
return reply.status(403).send({ error: "Forbidden" });
}
// Additional validation based on goal type
if (goalType === "path") {
// Validate path pattern
pathPatternSchema.parse(config.pathPattern);
} else if (goalType === "event") {
// Validate event configuration
eventConfigSchema.parse({
eventName: config.eventName,
eventPropertyKey: config.eventPropertyKey,
eventPropertyValue: config.eventPropertyValue,
});
}
// Insert the goal into the database
const result = await db
.insert(goals)
.values({
siteId,
name: name || null, // Use null if name is not provided
goalType,
config,
})
.returning({ goalId: goals.goalId });
if (!result || result.length === 0) {
return reply.status(500).send({ error: "Failed to create goal" });
}
return reply.status(201).send({
success: true,
goalId: result[0].goalId,
});
} catch (error) {
console.error("Error creating goal:", error);
// Handle validation errors
if (error instanceof z.ZodError) {
return reply.status(400).send({
error: "Validation error",
details: error.errors,
});
}
return reply.status(500).send({ error: "Failed to create goal" });
}
}

View file

@ -0,0 +1,52 @@
import { FastifyReply, FastifyRequest } from "fastify";
import { db } from "../../db/postgres/postgres.js";
import { goals } from "../../db/postgres/schema.js";
import { getUserHasAccessToSite } from "../../lib/auth-utils.js";
import { eq } from "drizzle-orm";
export async function deleteGoal(
request: FastifyRequest<{
Params: {
goalId: string;
};
}>,
reply: FastifyReply
) {
const { goalId } = request.params;
try {
// Get the goal to check the site ID
const goalToDelete = await db.query.goals.findFirst({
where: eq(goals.goalId, parseInt(goalId, 10)),
});
if (!goalToDelete) {
return reply.status(404).send({ error: "Goal not found" });
}
// Check user access to the site
const userHasAccessToSite = await getUserHasAccessToSite(
request,
goalToDelete.siteId.toString()
);
if (!userHasAccessToSite) {
return reply.status(403).send({ error: "Forbidden" });
}
// Delete the goal
const result = await db
.delete(goals)
.where(eq(goals.goalId, parseInt(goalId, 10)))
.returning({ deleted: goals.goalId });
if (!result || result.length === 0) {
return reply.status(500).send({ error: "Failed to delete goal" });
}
return reply.send({ success: true });
} catch (error) {
console.error("Error deleting goal:", error);
return reply.status(500).send({ error: "Failed to delete goal" });
}
}

View file

@ -0,0 +1,195 @@
import { FastifyReply, FastifyRequest } from "fastify";
import { db } from "../../db/postgres/postgres.js";
import { goals } from "../../db/postgres/schema.js";
import clickhouse from "../../db/clickhouse/clickhouse.js";
import { getUserHasAccessToSitePublic } from "../../lib/auth-utils.js";
import { eq } from "drizzle-orm";
import {
getFilterStatement,
getTimeStatement,
processResults,
} from "./utils.js";
import SqlString from "sqlstring";
// Helper to convert wildcard patterns to ClickHouse regex (same as in getGoals.ts)
function patternToRegex(pattern: string): string {
// Escape special regex characters except * which we'll handle specially
const escapedPattern = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
// Replace ** with a temporary marker
const withDoubleStar = escapedPattern.replace(/\*\*/g, "{{DOUBLE_STAR}}");
// Replace * with [^/]+ (any characters except /)
const withSingleStar = withDoubleStar.replace(/\*/g, "[^/]+");
// Replace the double star marker with .* (any characters including /)
const finalRegex = withSingleStar.replace(/{{DOUBLE_STAR}}/g, ".*");
// Anchor the regex to start/end of string for exact matches
return `^${finalRegex}$`;
}
export async function getGoal(
request: FastifyRequest<{
Params: {
goalId: string;
site: string;
};
Querystring: {
startDate: string;
endDate: string;
timezone: string;
filters?: string;
};
}>,
reply: FastifyReply
) {
const { goalId, site } = request.params;
const { startDate, endDate, timezone, filters } = request.query;
// Check user access to site
const userHasAccessToSite = await getUserHasAccessToSitePublic(request, site);
if (!userHasAccessToSite) {
return reply.status(403).send({ error: "Forbidden" });
}
try {
// Fetch the goal from PostgreSQL
const goal = await db.query.goals.findFirst({
where: eq(goals.goalId, parseInt(goalId, 10)),
});
if (!goal) {
return reply.status(404).send({ error: "Goal not found" });
}
// Ensure the goal belongs to the specified site
if (goal.siteId !== parseInt(site, 10)) {
return reply
.status(403)
.send({ error: "Goal does not belong to the specified site" });
}
// Build filter and time clauses for ClickHouse queries
const filterStatement = filters ? getFilterStatement(filters) : "";
const timeStatement = getTimeStatement({
date: { startDate, endDate, timezone },
});
// First, get the total number of unique sessions (denominator for conversion rate)
const totalSessionsQuery = `
SELECT COUNT(DISTINCT session_id) AS total_sessions
FROM events
WHERE site_id = ${SqlString.escape(Number(site))}
${timeStatement}
${filterStatement}
`;
const totalSessionsResult = await clickhouse.query({
query: totalSessionsQuery,
format: "JSONEachRow",
});
const totalSessionsData = await processResults<{ total_sessions: number }>(
totalSessionsResult
);
const totalSessions = totalSessionsData[0]?.total_sessions || 0;
// Build a query specific to this goal type to calculate conversions
let conversionQuery = "";
if (goal.goalType === "path") {
const pathPattern = goal.config.pathPattern;
if (!pathPattern) {
return reply.status(400).send({ error: "Invalid goal configuration" });
}
const regex = patternToRegex(pathPattern);
conversionQuery = `
SELECT COUNT(DISTINCT session_id) AS total_conversions
FROM events
WHERE site_id = ${SqlString.escape(Number(site))}
AND type = 'pageview'
AND match(pathname, ${SqlString.escape(regex)})
${timeStatement}
${filterStatement}
`;
} else if (goal.goalType === "event") {
const eventName = goal.config.eventName;
const eventPropertyKey = goal.config.eventPropertyKey;
const eventPropertyValue = goal.config.eventPropertyValue;
if (!eventName) {
return reply.status(400).send({ error: "Invalid goal configuration" });
}
let eventClause = `type = 'custom_event' AND event_name = ${SqlString.escape(
eventName
)}`;
// Add property matching if needed
if (eventPropertyKey && eventPropertyValue !== undefined) {
// Access the sub-column directly for native JSON type
const propValueAccessor = `props.${SqlString.escapeId(
eventPropertyKey
)}`;
// Comparison needs to handle the Dynamic type returned
// Let ClickHouse handle the comparison based on the provided value type
if (typeof eventPropertyValue === "string") {
eventClause += ` AND toString(${propValueAccessor}) = ${SqlString.escape(
eventPropertyValue
)}`;
} else if (typeof eventPropertyValue === "number") {
// Use toFloat64 or toInt* depending on expected number type
eventClause += ` AND toFloat64OrNull(${propValueAccessor}) = ${SqlString.escape(
eventPropertyValue
)}`;
} else if (typeof eventPropertyValue === "boolean") {
// Booleans might be stored as 0/1 or true/false in JSON
// Comparing toUInt8 seems robust
eventClause += ` AND toUInt8OrNull(${propValueAccessor}) = ${
eventPropertyValue ? 1 : 0
}`;
}
}
conversionQuery = `
SELECT COUNT(DISTINCT session_id) AS total_conversions
FROM events
WHERE site_id = ${SqlString.escape(Number(site))}
AND ${eventClause}
${timeStatement}
${filterStatement}
`;
} else {
return reply.status(400).send({ error: "Invalid goal type" });
}
// Execute the conversion query
const conversionResult = await clickhouse.query({
query: conversionQuery,
format: "JSONEachRow",
});
const conversionData = await processResults<{ total_conversions: number }>(
conversionResult
);
const totalConversions = conversionData[0]?.total_conversions || 0;
const conversionRate =
totalSessions > 0 ? totalConversions / totalSessions : 0;
// Return the goal with conversion metrics
return reply.send({
data: {
...goal,
total_conversions: totalConversions,
total_sessions: totalSessions,
conversion_rate: conversionRate,
},
});
} catch (error) {
console.error("Error fetching goal:", error);
return reply.status(500).send({ error: "Failed to fetch goal data" });
}
}

View file

@ -0,0 +1,329 @@
import { FastifyReply, FastifyRequest } from "fastify";
import { db } from "../../db/postgres/postgres.js";
import { goals } from "../../db/postgres/schema.js";
import clickhouse from "../../db/clickhouse/clickhouse.js";
import { getUserHasAccessToSitePublic } from "../../lib/auth-utils.js";
import { eq, desc, asc, sql } from "drizzle-orm";
import {
getFilterStatement,
getTimeStatement,
processResults,
} from "./utils.js";
import SqlString from "sqlstring";
// Helper to convert wildcard patterns to ClickHouse regex
function patternToRegex(pattern: string): string {
// Escape special regex characters except * which we'll handle specially
const escapedPattern = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
// Replace ** with a temporary marker
const withDoubleStar = escapedPattern.replace(/\*\*/g, "{{DOUBLE_STAR}}");
// Replace * with [^/]+ (any characters except /)
const withSingleStar = withDoubleStar.replace(/\*/g, "[^/]+");
// Replace the double star marker with .* (any characters including /)
const finalRegex = withSingleStar.replace(/{{DOUBLE_STAR}}/g, ".*");
// Anchor the regex to start/end of string for exact matches
return `^${finalRegex}$`;
}
// Types for the response
interface GoalWithConversions {
goalId: number;
name: string | null;
goalType: string;
config: any;
createdAt: string | null;
total_conversions: number;
total_sessions: number;
conversion_rate: number;
}
interface GetGoalsResponse {
data: GoalWithConversions[];
meta: {
total: number;
page: number;
pageSize: number;
totalPages: number;
};
}
export async function getGoals(
request: FastifyRequest<{
Params: {
site: string;
};
Querystring: {
startDate: string;
endDate: string;
timezone: string;
filters?: string;
page?: string;
pageSize?: string;
sort?: string;
order?: "asc" | "desc";
};
}>,
reply: FastifyReply
) {
const { site } = request.params;
const {
startDate,
endDate,
timezone,
filters,
page = "1",
pageSize = "10",
sort = "createdAt",
order = "desc",
} = request.query;
const pageNumber = parseInt(page, 10);
const pageSizeNumber = parseInt(pageSize, 10);
// Validate page and pageSize
if (isNaN(pageNumber) || pageNumber < 1) {
return reply.status(400).send({ error: "Invalid page number" });
}
if (isNaN(pageSizeNumber) || pageSizeNumber < 1 || pageSizeNumber > 100) {
return reply
.status(400)
.send({ error: "Invalid page size, must be between 1 and 100" });
}
// Check user access to site
const userHasAccessToSite = await getUserHasAccessToSitePublic(request, site);
if (!userHasAccessToSite) {
return reply.status(403).send({ error: "Forbidden" });
}
try {
// Count total goals for pagination metadata
const totalGoalsResult = await db
.select({ count: sql<number>`count(*)` })
.from(goals)
.where(eq(goals.siteId, Number(site)));
const totalGoals = totalGoalsResult[0]?.count || 0;
const totalPages = Math.ceil(totalGoals / pageSizeNumber);
// If no goals exist, return early with empty data
if (totalGoals === 0) {
return reply.send({
data: [],
meta: {
total: 0,
page: pageNumber,
pageSize: pageSizeNumber,
totalPages: 0,
},
});
}
// Apply sorting
let orderBy;
// Only allow sorting by valid columns
const validSortColumns = ["goalId", "name", "goalType", "createdAt"];
const sortColumn = validSortColumns.includes(sort) ? sort : "createdAt";
if (order === "asc") {
if (sortColumn === "goalId") orderBy = asc(goals.goalId);
else if (sortColumn === "name") orderBy = asc(goals.name);
else if (sortColumn === "goalType") orderBy = asc(goals.goalType);
else orderBy = asc(goals.createdAt);
} else {
if (sortColumn === "goalId") orderBy = desc(goals.goalId);
else if (sortColumn === "name") orderBy = desc(goals.name);
else if (sortColumn === "goalType") orderBy = desc(goals.goalType);
else orderBy = desc(goals.createdAt);
}
// Fetch paginated goals for this site from PostgreSQL
const siteGoals = await db
.select()
.from(goals)
.where(eq(goals.siteId, Number(site)))
.orderBy(orderBy)
.limit(pageSizeNumber)
.offset((pageNumber - 1) * pageSizeNumber);
if (siteGoals.length === 0) {
// If no goals for this page, return empty data
return reply.send({
data: [],
meta: {
total: totalGoals,
page: pageNumber,
pageSize: pageSizeNumber,
totalPages,
},
});
}
// Build filter and time clauses for ClickHouse queries
const filterStatement = filters ? getFilterStatement(filters) : "";
const timeStatement = getTimeStatement({
date: { startDate, endDate, timezone },
});
// First, get the total number of unique sessions (denominator for conversion rate)
const totalSessionsQuery = `
SELECT COUNT(DISTINCT session_id) AS total_sessions
FROM events
WHERE site_id = ${SqlString.escape(Number(site))}
${timeStatement}
${filterStatement}
`;
const totalSessionsResult = await clickhouse.query({
query: totalSessionsQuery,
format: "JSONEachRow",
});
const totalSessionsData = await processResults<{ total_sessions: number }>(
totalSessionsResult
);
const totalSessions = totalSessionsData[0]?.total_sessions || 0;
// Build a single query that calculates all goal conversions at once using conditional aggregation
// This is more efficient than separate queries for each goal
let conditionalClauses: string[] = [];
for (const goal of siteGoals) {
if (goal.goalType === "path") {
const pathPattern = goal.config.pathPattern;
if (!pathPattern) continue;
const regex = patternToRegex(pathPattern);
conditionalClauses.push(`
COUNT(DISTINCT IF(
type = 'pageview' AND match(pathname, ${SqlString.escape(regex)}),
session_id,
NULL
)) AS goal_${goal.goalId}_conversions
`);
} else if (goal.goalType === "event") {
const eventName = goal.config.eventName;
const eventPropertyKey = goal.config.eventPropertyKey;
const eventPropertyValue = goal.config.eventPropertyValue;
if (!eventName) continue;
let eventClause = `type = 'custom_event' AND event_name = ${SqlString.escape(
eventName
)}`;
// Add property matching if needed
if (eventPropertyKey && eventPropertyValue !== undefined) {
// Access the sub-column directly for native JSON type
const propValueAccessor = `props.${SqlString.escapeId(
eventPropertyKey
)}`;
// Comparison needs to handle the Dynamic type returned
// Let ClickHouse handle the comparison based on the provided value type
if (typeof eventPropertyValue === "string") {
eventClause += ` AND toString(${propValueAccessor}) = ${SqlString.escape(
eventPropertyValue
)}`;
} else if (typeof eventPropertyValue === "number") {
// Use toFloat64 or toInt* depending on expected number type
eventClause += ` AND toFloat64OrNull(${propValueAccessor}) = ${SqlString.escape(
eventPropertyValue
)}`;
} else if (typeof eventPropertyValue === "boolean") {
// Booleans might be stored as 0/1 or true/false in JSON
// Comparing toUInt8 seems robust
eventClause += ` AND toUInt8OrNull(${propValueAccessor}) = ${
eventPropertyValue ? 1 : 0
}`;
}
}
conditionalClauses.push(`
COUNT(DISTINCT IF(
${eventClause},
session_id,
NULL
)) AS goal_${goal.goalId}_conversions
`);
}
}
if (conditionalClauses.length === 0) {
// If no valid goals to calculate, return the goals without conversion data
const goalsWithZeroConversions = siteGoals.map((goal) => ({
...goal,
total_conversions: 0,
total_sessions: totalSessions,
conversion_rate: 0,
}));
return reply.send({
data: goalsWithZeroConversions,
meta: {
total: totalGoals,
page: pageNumber,
pageSize: pageSizeNumber,
totalPages,
},
});
}
// Execute the comprehensive query
const conversionQuery = `
SELECT
${conditionalClauses.join(", ")}
FROM events
WHERE site_id = ${SqlString.escape(Number(site))}
${timeStatement}
${filterStatement}
`;
const conversionResult = await clickhouse.query({
query: conversionQuery,
format: "JSONEachRow",
});
const conversionData = await processResults<Record<string, number>>(
conversionResult
);
// If we didn't get any results, use zeros
const conversions = conversionData[0] || {};
// Combine goals data with conversion metrics
const goalsWithConversions: GoalWithConversions[] = siteGoals.map(
(goal) => {
const totalConversions =
conversions[`goal_${goal.goalId}_conversions`] || 0;
const conversionRate =
totalSessions > 0 ? totalConversions / totalSessions : 0;
return {
...goal,
total_conversions: totalConversions,
total_sessions: totalSessions,
conversion_rate: conversionRate,
};
}
);
return reply.send({
data: goalsWithConversions,
meta: {
total: totalGoals,
page: pageNumber,
pageSize: pageSizeNumber,
totalPages,
},
});
} catch (error) {
console.error("Error fetching goals:", error);
return reply.status(500).send({ error: "Failed to fetch goals data" });
}
}

View file

@ -123,7 +123,7 @@ SELECT
referrer,
type,
event_name,
properties
props
FROM events
WHERE
site_id = {siteId:Int32}

View file

@ -0,0 +1,151 @@
import { FastifyReply, FastifyRequest } from "fastify";
import { db } from "../../db/postgres/postgres.js";
import { goals } from "../../db/postgres/schema.js";
import { getUserHasAccessToSite } from "../../lib/auth-utils.js";
import { z } from "zod";
import { eq } from "drizzle-orm";
// Define validation schema for path pattern
const pathPatternSchema = z.string().min(1, "Path pattern cannot be empty");
// Define validation schema for event config
const eventConfigSchema = z
.object({
eventName: z.string().min(1, "Event name cannot be empty"),
eventPropertyKey: z.string().optional(),
eventPropertyValue: z
.union([z.string(), z.number(), z.boolean()])
.optional(),
})
.refine(
(data) => {
// If one property matching field is provided, both must be provided
if (data.eventPropertyKey && data.eventPropertyValue === undefined) {
return false;
}
if (data.eventPropertyValue !== undefined && !data.eventPropertyKey) {
return false;
}
return true;
},
{
message:
"Both eventPropertyKey and eventPropertyValue must be provided together or omitted together",
}
);
// Define validation schema for the goal request
const updateGoalSchema = z
.object({
goalId: z.number().int().positive("Goal ID must be a positive integer"),
siteId: z.number().int().positive("Site ID must be a positive integer"),
name: z.string().optional(),
goalType: z.enum(["path", "event"]),
config: z.object({
pathPattern: z.string().optional(),
eventName: z.string().optional(),
eventPropertyKey: z.string().optional(),
eventPropertyValue: z
.union([z.string(), z.number(), z.boolean()])
.optional(),
}),
})
.refine(
(data) => {
if (data.goalType === "path") {
return !!data.config.pathPattern;
} else if (data.goalType === "event") {
return !!data.config.eventName;
}
return false;
},
{
message: "Configuration must match goal type",
path: ["config"],
}
);
type UpdateGoalRequest = z.infer<typeof updateGoalSchema>;
export async function updateGoal(
request: FastifyRequest<{
Body: UpdateGoalRequest;
}>,
reply: FastifyReply
) {
try {
// Validate the request body
const validatedData = updateGoalSchema.parse(request.body);
const { goalId, siteId, name, goalType, config } = validatedData;
// Check if the goal exists
const existingGoal = await db.query.goals.findFirst({
where: eq(goals.goalId, goalId),
});
if (!existingGoal) {
return reply.status(404).send({ error: "Goal not found" });
}
// Check if the goal belongs to the specified site
if (existingGoal.siteId !== siteId) {
return reply
.status(403)
.send({ error: "Goal does not belong to the specified site" });
}
// Check user access to site
const userHasAccessToSite = await getUserHasAccessToSite(
request,
siteId.toString()
);
if (!userHasAccessToSite) {
return reply.status(403).send({ error: "Forbidden" });
}
// Additional validation based on goal type
if (goalType === "path") {
// Validate path pattern
pathPatternSchema.parse(config.pathPattern);
} else if (goalType === "event") {
// Validate event configuration
eventConfigSchema.parse({
eventName: config.eventName,
eventPropertyKey: config.eventPropertyKey,
eventPropertyValue: config.eventPropertyValue,
});
}
// Update the goal
const result = await db
.update(goals)
.set({
name: name || null, // Use null if name is not provided
goalType,
config,
})
.where(eq(goals.goalId, goalId))
.returning({ goalId: goals.goalId });
if (!result || result.length === 0) {
return reply.status(500).send({ error: "Failed to update goal" });
}
return reply.send({
success: true,
goalId: result[0].goalId,
});
} catch (error) {
console.error("Error updating goal:", error);
// Handle validation errors
if (error instanceof z.ZodError) {
return reply.status(400).send({
error: "Validation error",
details: error.errors,
});
}
return reply.status(500).send({ error: "Failed to update goal" });
}
}

View file

@ -41,7 +41,7 @@ export const initializeClickhouse = async () => {
device_type LowCardinality(String),
type LowCardinality(String) DEFAULT 'pageview',
event_name String,
properties String
props JSON
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(timestamp)
@ -82,6 +82,7 @@ export const initializeClickhouse = async () => {
ORDER BY (site_id, session_id);
`,
});
await clickhouse.exec({
query: `
CREATE MATERIALIZED VIEW IF NOT EXISTS sessions_mv

View file

@ -233,3 +233,31 @@ export const session = pgTable(
unique("session_token_unique").on(table.token),
]
);
// Goals table for tracking conversion goals
export const goals = pgTable(
"goals",
{
goalId: serial("goal_id").primaryKey().notNull(),
siteId: integer("site_id").notNull(),
name: text("name"), // Optional, user-defined name for the goal
goalType: text("goal_type").notNull(), // 'path' or 'event'
// Configuration specific to the goal type
config: jsonb("config").notNull().$type<{
// For 'path' type
pathPattern?: string; // e.g., "/pricing", "/product/*/view", "/docs/**"
// For 'event' type
eventName?: string; // e.g., "signup_completed", "file_downloaded"
eventPropertyKey?: string; // Optional property key to match
eventPropertyValue?: string | number | boolean; // Optional property value to match (exact match)
}>(),
createdAt: timestamp("created_at", { mode: "string" }).defaultNow(),
},
(table) => [
foreignKey({
columns: [table.siteId],
foreignColumns: [sites.siteId],
name: "goals_site_id_sites_site_id_fk",
}),
]
);

View file

@ -6,10 +6,14 @@ import { dirname, join } from "path";
import { Headers, HeadersInit } from "undici";
import { fileURLToPath } from "url";
import { createFunnel } from "./api/analytics/createFunnel.js";
import { createGoal } from "./api/analytics/createGoal.js";
import { deleteGoal } from "./api/analytics/deleteGoal.js";
import { deleteReport } from "./api/analytics/deleteReport.js";
import { getEvents } from "./api/analytics/getEvents.js";
import { getFunnel } from "./api/analytics/getFunnel.js";
import { getFunnels } from "./api/analytics/getFunnels.js";
import { getGoal } from "./api/analytics/getGoal.js";
import { getGoals } from "./api/analytics/getGoals.js";
import { getJourneys } from "./api/analytics/getJourneys.js";
import { getLiveSessionLocations } from "./api/analytics/getLiveSessionLocations.js";
import { getLiveUsercount } from "./api/analytics/getLiveUsercount.js";
@ -23,6 +27,7 @@ import { getUserInfo } from "./api/analytics/getUserInfo.js";
import { getUserSessionCount } from "./api/analytics/getUserSessionCount.js";
import { getUserSessions } from "./api/analytics/getUserSessions.js";
import { getUsers } from "./api/analytics/getUsers.js";
import { updateGoal } from "./api/analytics/updateGoal.js";
import { addSite } from "./api/sites/addSite.js";
import { changeSiteDomain } from "./api/sites/changeSiteDomain.js";
import { changeSitePublic } from "./api/sites/changeSitePublic.js";
@ -136,6 +141,8 @@ const ANALYTICS_ROUTES = [
"/funnels/",
"/funnel/",
"/journeys/",
"/goals/",
"/goal/",
"/get-site",
];
@ -205,6 +212,11 @@ server.get("/journeys/:site", getJourneys);
server.post("/funnel/:site", getFunnel);
server.post("/funnel/create/:site", createFunnel);
server.delete("/report/:reportId", deleteReport);
server.get("/goals/:site", getGoals);
server.get("/goal/:goalId/:site", getGoal);
server.post("/goal/create", createGoal);
server.delete("/goal/:goalId", deleteGoal);
server.put("/goal/update", updateGoal);
// Administrative
server.post("/add-site", addSite);

View file

@ -18,6 +18,14 @@ type TotalPayload = TrackingPayload & {
properties?: string;
};
const getParsedProperties = (properties: string | undefined) => {
try {
return properties ? JSON.parse(properties) : undefined;
} catch (error) {
return undefined;
}
};
class PageviewQueue {
private queue: TotalPayload[] = [];
private batchSize = 5000;
@ -104,7 +112,7 @@ class PageviewQueue {
lon: longitude || 0,
type: pv.type || "pageview",
event_name: pv.event_name || "",
properties: pv.properties,
props: getParsedProperties(pv.properties),
utm_source: utmParams["utm_source"] || "",
utm_medium: utmParams["utm_medium"] || "",
utm_campaign: utmParams["utm_campaign"] || "",