@@ -55,6 +55,7 @@ funnelRouter.get('/', async (c) => {
5555 name : funnelDefinitions . name ,
5656 description : funnelDefinitions . description ,
5757 steps : funnelDefinitions . steps ,
58+ filters : funnelDefinitions . filters ,
5859 isActive : funnelDefinitions . isActive ,
5960 createdAt : funnelDefinitions . createdAt ,
6061 updatedAt : funnelDefinitions . updatedAt ,
@@ -147,7 +148,7 @@ funnelRouter.post(
147148 try {
148149 const website = c . get ( 'website' )
149150 const user = c . get ( 'user' )
150- const { name, description, steps } = await c . req . json ( )
151+ const { name, description, steps, filters } = await c . req . json ( )
151152
152153 const funnelId = crypto . randomUUID ( )
153154
@@ -159,6 +160,7 @@ funnelRouter.post(
159160 name,
160161 description,
161162 steps,
163+ filters,
162164 createdBy : user . id ,
163165 } )
164166 . returning ( )
@@ -425,87 +427,77 @@ funnelRouter.get('/:id/analytics', async (c) => {
425427
426428 const funnelData = funnel [ 0 ]
427429 const steps = funnelData . steps as Array < { type : string ; target : string ; name : string ; conditions ?: any } >
430+ const filters = funnelData . filters as Array < { field : string ; operator : string ; value : string | string [ ] } > || [ ]
428431
429- // Execute funnel analysis query
430- const analysisQuery = `
431- WITH ${ steps . map ( ( step , index : number ) => {
432- let whereCondition = '' ;
432+ // Build filter conditions
433+ const buildFilterConditions = ( ) => {
434+ if ( ! filters || filters . length === 0 ) return '' ;
435+
436+ const filterConditions = filters . map ( filter => {
437+ const field = filter . field . replace ( / ' / g, "''" ) ;
438+ const value = Array . isArray ( filter . value ) ? filter . value : [ filter . value ] ;
433439
434- if ( step . type === 'PAGE_VIEW' ) {
435- // Handle page views - only screen_view events with matching path
436- const targetPath = step . target . replace ( / ' / g, "''" ) ;
437- whereCondition = `event_name = 'screen_view' AND (path = '${ targetPath } ' OR path LIKE '%${ targetPath } ')` ;
438- } else if ( step . type === 'EVENT' ) {
439- // Handle custom events - exclude system events like in query.ts
440- const eventName = step . target . replace ( / ' / g, "''" ) ;
441- whereCondition = `event_name = '${ eventName } ' AND event_name NOT IN ('screen_view', 'page_exit', 'error', 'web_vitals')` ;
442- } else if ( step . type === 'CUSTOM' && step . conditions ) {
443- // Handle custom conditions with properties
444- const eventName = step . target . replace ( / ' / g, "''" ) ;
445- let customConditions = `event_name = '${ eventName } ' AND event_name NOT IN ('screen_view', 'page_exit', 'error', 'web_vitals')` ;
446-
447- // Add property-based conditions if specified
448- if ( step . conditions && typeof step . conditions === 'object' ) {
449- Object . entries ( step . conditions ) . forEach ( ( [ key , value ] ) => {
450- if ( typeof value === 'string' ) {
451- customConditions += ` AND JSONExtractString(properties, '${ key . replace ( / ' / g, "''" ) } ') = '${ String ( value ) . replace ( / ' / g, "''" ) } '` ;
452- } else if ( typeof value === 'number' ) {
453- customConditions += ` AND JSONExtractFloat(properties, '${ key . replace ( / ' / g, "''" ) } ') = ${ value } ` ;
454- } else if ( typeof value === 'boolean' ) {
455- customConditions += ` AND JSONExtractBool(properties, '${ key . replace ( / ' / g, "''" ) } ') = ${ value ? 1 : 0 } ` ;
456- }
457- } ) ;
458- }
459-
460- whereCondition = customConditions ;
440+ switch ( filter . operator ) {
441+ case 'equals' :
442+ return `${ field } = '${ value [ 0 ] . replace ( / ' / g, "''" ) } '` ;
443+ case 'contains' :
444+ return `${ field } LIKE '%${ value [ 0 ] . replace ( / ' / g, "''" ) } %'` ;
445+ case 'not_equals' :
446+ return `${ field } != '${ value [ 0 ] . replace ( / ' / g, "''" ) } '` ;
447+ case 'in' :
448+ return `${ field } IN (${ value . map ( v => `'${ v . replace ( / ' / g, "''" ) } '` ) . join ( ', ' ) } )` ;
449+ case 'not_in' :
450+ return `${ field } NOT IN (${ value . map ( v => `'${ v . replace ( / ' / g, "''" ) } '` ) . join ( ', ' ) } )` ;
451+ default :
452+ return '' ;
461453 }
462-
463- return `step_${ index + 1 } _users AS (
464- SELECT DISTINCT
465- session_id,
466- anonymous_id,
467- MIN(time) as step_time
468- FROM analytics.events
469- WHERE client_id = '${ website . id } '
470- AND toDate(time) >= '${ startDate } '
471- AND toDate(time) <= '${ endDate } '
472- AND ${ whereCondition }
473- GROUP BY session_id, anonymous_id
474- )` ;
475- } ) . join ( ',\n ' ) } ,
476- step_progression AS (
477- ${ steps . map ( ( step , index : number ) => {
478- const stepNum = index + 1 ;
479- const joins = Array . from ( { length : stepNum } , ( _ , i ) => {
480- if ( i === 0 ) return 'step_1_users s1' ;
481- return `LEFT JOIN step_${ i + 1 } _users s${ i + 1 } ON s1.session_id = s${ i + 1 } .session_id AND s${ i + 1 } .step_time >= s${ i } .step_time` ;
482- } ) . join ( '\n ' ) ;
483-
484- return `
485- SELECT
486- ${ stepNum } as step_number,
487- '${ step . name . replace ( / ' / g, "''" ) } ' as step_name,
488- COUNT(DISTINCT s1.session_id) as total_users,
489- ${ index === 0 ?
490- 'COUNT(DISTINCT s1.session_id)' :
491- `COUNT(DISTINCT s${ stepNum } .session_id)` } as users,
492- ${ index === 0 ?
493- '100.0' :
494- `ROUND((COUNT(DISTINCT s${ stepNum } .session_id) * 100.0 / NULLIF(COUNT(DISTINCT s1.session_id), 0)), 2)` } as conversion_rate,
495- ${ index === 0 ?
496- '0' :
497- `(COUNT(DISTINCT s1.session_id) - COUNT(DISTINCT s${ stepNum } .session_id))` } as dropoffs,
498- ${ index === 0 ?
499- '0.0' :
500- `ROUND(((COUNT(DISTINCT s1.session_id) - COUNT(DISTINCT s${ stepNum } .session_id)) * 100.0 / NULLIF(COUNT(DISTINCT s1.session_id), 0)), 2)` } as dropoff_rate,
501- ${ index === 0 ?
502- '0.0' :
503- `ROUND(AVG(CASE WHEN s${ stepNum } .step_time > s${ stepNum - 1 } .step_time AND dateDiff('second', s${ stepNum - 1 } .step_time, s${ stepNum } .step_time) > 0 AND dateDiff('second', s${ stepNum - 1 } .step_time, s${ stepNum } .step_time) < 86400 THEN dateDiff('second', s${ stepNum - 1 } .step_time, s${ stepNum } .step_time) ELSE NULL END), 2)` } as avg_time_to_complete
504- FROM ${ joins } ` ;
505- } ) . join ( '\nUNION ALL\n' ) }
454+ } ) . filter ( Boolean ) ;
455+
456+ return filterConditions . length > 0 ? ` AND ${ filterConditions . join ( ' AND ' ) } ` : '' ;
457+ } ;
458+
459+ const filterConditions = buildFilterConditions ( ) ;
460+
461+ const stepQueries = steps . map ( ( step , index ) => {
462+ let whereCondition = '' ;
463+
464+ if ( step . type === 'PAGE_VIEW' ) {
465+ const targetPath = step . target . replace ( / ' / g, "''" ) ;
466+ whereCondition = `event_name = 'screen_view' AND (path = '${ targetPath } ' OR path LIKE '%${ targetPath } %')` ;
467+ } else if ( step . type === 'EVENT' ) {
468+ const eventName = step . target . replace ( / ' / g, "''" ) ;
469+ whereCondition = `event_name = '${ eventName } '` ;
470+ }
471+
472+ return `
473+ SELECT
474+ ${ index + 1 } as step_number,
475+ '${ step . name . replace ( / ' / g, "''" ) } ' as step_name,
476+ session_id,
477+ MIN(time) as first_occurrence
478+ FROM analytics.events
479+ WHERE client_id = '${ website . id } '
480+ AND time >= parseDateTimeBestEffort('${ startDate } ')
481+ AND time <= parseDateTimeBestEffort('${ endDate } 23:59:59')
482+ AND ${ whereCondition } ${ filterConditions }
483+ GROUP BY session_id` ;
484+ } ) ;
485+
486+ // Get all step events and then process the funnel logic in JavaScript
487+ const analysisQuery = `
488+ WITH all_step_events AS (
489+ ${ stepQueries . join ( '\n UNION ALL\n' ) }
506490 )
507- SELECT * FROM step_progression ORDER BY step_number
491+ SELECT
492+ step_number,
493+ step_name,
494+ session_id,
495+ first_occurrence
496+ FROM all_step_events
497+ ORDER BY session_id, first_occurrence
508498 ` ;
499+
500+
509501
510502 // Log the generated query for debugging
511503 logger . info ( 'Generated funnel analysis query' , {
@@ -514,17 +506,13 @@ funnelRouter.get('/:id/analytics', async (c) => {
514506 query : analysisQuery
515507 } ) ;
516508
517- let analyticsResults ;
509+ let rawResults ;
518510 try {
519- analyticsResults = await chQuery < {
511+ rawResults = await chQuery < {
520512 step_number : number ;
521513 step_name : string ;
522- total_users : number ;
523- users : number ;
524- conversion_rate : number ;
525- dropoffs : number ;
526- dropoff_rate : number ;
527- avg_time_to_complete ?: number ;
514+ session_id : string ;
515+ first_occurrence : number ;
528516 } > ( analysisQuery ) ;
529517 } catch ( sqlError : any ) {
530518 logger . error ( 'SQL query failed for funnel analytics' , {
@@ -536,6 +524,70 @@ funnelRouter.get('/:id/analytics', async (c) => {
536524 throw new Error ( `SQL query failed: ${ sqlError . message } ` ) ;
537525 }
538526
527+ // Process the results to calculate proper funnel progression
528+ // Group events by session and calculate funnel progression
529+ const sessionEvents = new Map < string , Array < { step_number : number , step_name : string , first_occurrence : number } > > ( ) ;
530+
531+ for ( const event of rawResults ) {
532+ if ( ! sessionEvents . has ( event . session_id ) ) {
533+ sessionEvents . set ( event . session_id , [ ] ) ;
534+ }
535+ sessionEvents . get ( event . session_id ) ! . push ( {
536+ step_number : event . step_number ,
537+ step_name : event . step_name ,
538+ first_occurrence : event . first_occurrence
539+ } ) ;
540+ }
541+
542+ // Calculate funnel progression for each session
543+ const stepCounts = new Map < number , Set < string > > ( ) ;
544+
545+ for ( const [ sessionId , events ] of sessionEvents ) {
546+ // Sort events by time
547+ events . sort ( ( a , b ) => a . first_occurrence - b . first_occurrence ) ;
548+
549+ // Track which steps this session completed in order
550+ let currentStep = 1 ;
551+ const completedSteps = new Set < number > ( ) ;
552+
553+ for ( const event of events ) {
554+ if ( event . step_number === currentStep ) {
555+ completedSteps . add ( event . step_number ) ;
556+ if ( ! stepCounts . has ( event . step_number ) ) {
557+ stepCounts . set ( event . step_number , new Set ( ) ) ;
558+ }
559+ stepCounts . get ( event . step_number ) ! . add ( sessionId ) ;
560+ currentStep ++ ;
561+ }
562+ }
563+ }
564+
565+ // Build analytics results
566+ const analyticsResults = steps . map ( ( step , index ) => {
567+ const stepNumber = index + 1 ;
568+ const users = stepCounts . get ( stepNumber ) ?. size || 0 ;
569+ const prevStepUsers = index > 0 ? ( stepCounts . get ( index ) ?. size || 0 ) : users ;
570+ const totalUsers = stepCounts . get ( 1 ) ?. size || 0 ;
571+
572+ const conversion_rate = index === 0 ? 100.0 :
573+ prevStepUsers > 0 ? Math . round ( ( users / prevStepUsers ) * 100 * 100 ) / 100 : 0 ;
574+
575+ const dropoffs = index > 0 ? prevStepUsers - users : 0 ;
576+ const dropoff_rate = index > 0 && prevStepUsers > 0 ?
577+ Math . round ( ( dropoffs / prevStepUsers ) * 100 * 100 ) / 100 : 0 ;
578+
579+ return {
580+ step_number : stepNumber ,
581+ step_name : step . name ,
582+ users,
583+ total_users : totalUsers ,
584+ conversion_rate,
585+ dropoffs,
586+ dropoff_rate,
587+ avg_time_to_complete : 0
588+ } ;
589+ } ) ;
590+
539591 // Calculate overall metrics
540592 const firstStep = analyticsResults [ 0 ] ;
541593 const lastStep = analyticsResults [ analyticsResults . length - 1 ] ;
0 commit comments