Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
[![Bun](https://img.shields.io/badge/Bun-1.2-blue.svg)](https://bun.sh/)
[![Tailwind CSS](https://img.shields.io/badge/Tailwind-3.4-blue.svg)](https://tailwindcss.com/)

[![CodeRabbit Pull Request Reviews](https://img.shields.io/coderabbit/prs/github/databuddy-analytics/Databuddy?utm_source=oss&utm_medium=github&utm_campaign=databuddy-analytics%2FDatabuddy&labelColor=171717&color=FF570A&link=https%3A%2F%2Fcoderabbit.ai&label=CodeRabbit+Reviews)](https://coderabbit.ai)
[![CI/CD](https://github.com/databuddy/databuddy/actions/workflows/ci.yml/badge.svg)](https://github.com/databuddy/databuddy/actions/workflows/ci.yml)
[![Code Coverage](https://img.shields.io/badge/coverage-85%25-green.svg)](https://github.com/databuddy/databuddy/actions/workflows/coverage.yml)
[![Security Scan](https://img.shields.io/badge/security-A%2B-green.svg)](https://github.com/databuddy/databuddy/actions/workflows/security.yml)
Expand Down
11 changes: 6 additions & 5 deletions apps/api/src/query/simple-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ export class SimpleQueryBuilder {
) { }

private buildFilter(filter: Filter, index: number): { clause: string, params: Record<string, unknown> } {
if (this.config.allowedFilters && !this.config.allowedFilters.includes(filter.field)) {
throw new Error(`Filter on field '${filter.field}' is not permitted.`);
}
const key = `f${index}`;
const operator = FilterOperators[filter.op];

Expand Down Expand Up @@ -117,11 +120,9 @@ export class SimpleQueryBuilder {
for (let i = 0; i < this.request.filters.length; i++) {
const filter = this.request.filters[i];
if (!filter) continue;
if (this.config.allowedFilters?.includes(filter.field)) {
const { clause, params: filterParams } = this.buildFilter(filter, i);
whereClause.push(clause);
Object.assign(params, filterParams);
}
const { clause, params: filterParams } = this.buildFilter(filter, i);
whereClause.push(clause);
Object.assign(params, filterParams);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

Expand Down
22 changes: 9 additions & 13 deletions packages/db/src/clickhouse/client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { LogParams, ErrorLogParams, WarnLogParams, Logger, ResponseJSON } from '@clickhouse/client';
import { ClickHouseLogLevel, createClient } from '@clickhouse/client';
import type { ResponseJSON } from '@clickhouse/client';
import { createClient } from '@clickhouse/client';
import type { NodeClickHouseClientConfigOptions } from '@clickhouse/client/dist/config';


Expand Down Expand Up @@ -94,31 +94,27 @@ export async function chQueryWithMeta<T extends Record<string, any>>(
query: string,
params?: Record<string, unknown>
): Promise<ResponseJSON<T>> {
const start = Date.now();
const res = await clickHouse.query({
query,
query_params: params,
});
const beforeParse = Date.now();
const json = await res.json<T>();
const afterParse = Date.now();
const keys = Object.keys(json.data[0] || {});
const response = {
...json,
data: json.data.map((item) => {
return keys.reduce((acc, key) => {
const meta = json.meta?.find((m) => m.name === key);
return Object.assign(acc, {
[key]:
item[key] && meta?.type.includes('Int')
? Number.parseFloat(item[key] as string)
: item[key],
});
}, {} as T);
acc[key] =
item[key] && meta?.type.includes('Int')
? Number.parseFloat(item[key] as string)
: item[key];
return acc;
}, {} as Record<string, any>);
}),
};

return response;
return response as ResponseJSON<T>;
}

export async function chQuery<T extends Record<string, any>>(
Expand Down
18 changes: 3 additions & 15 deletions packages/rpc/src/routers/funnels.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,17 @@
import { z } from 'zod';
import { z } from 'zod/v4';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Potential build-break: sub-path import zod/v4 is non-standard

zod does not expose a stable sub-path named v4; the canonical entry-point is simply "zod". Unless you are publishing your own fork with that sub-path, this will resolve to module-not-found at runtime and during TypeScript emit.

-import { z } from 'zod/v4';
+import { z } from 'zod';
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import { z } from 'zod/v4';
-import { z } from 'zod/v4';
+import { z } from 'zod';
🤖 Prompt for AI Agents
In packages/rpc/src/routers/funnels.ts at line 1, the import statement uses a
non-standard sub-path 'zod/v4' which can cause module-not-found errors. Change
the import to use the canonical entry point by importing directly from 'zod'
instead of 'zod/v4' to ensure compatibility and prevent build failures.

import { createTRPCRouter, protectedProcedure } from '../trpc';
import { and, eq, isNull, desc, sql } from 'drizzle-orm';
import { escape as sqlEscape } from 'sqlstring';
import { TRPCError } from '@trpc/server';
import { funnelDefinitions, chQuery } from '@databuddy/db';
import { authorizeWebsiteAccess } from '../utils/auth';
import { logger } from '../utils/discord-webhook';
import { parseReferrer } from '../utils/referrer';

// Validation schemas
const funnelStepSchema = z.object({
type: z.enum(['PAGE_VIEW', 'EVENT', 'CUSTOM']),
target: z.string().min(1),
name: z.string().min(1),
conditions: z.record(z.any()).optional(),
conditions: z.record(z.string(), z.any()).optional(),
});

const funnelFilterSchema = z.object({
Expand Down Expand Up @@ -52,7 +50,6 @@ const funnelAnalyticsSchema = z.object({
endDate: z.string().optional(),
});

// Security - allowed fields for filtering
const ALLOWED_FIELDS = new Set([
'id', 'client_id', 'event_name', 'anonymous_id', 'time', 'session_id',
'event_type', 'event_id', 'session_start_time', 'timestamp',
Expand All @@ -74,7 +71,6 @@ const ALLOWED_OPERATORS = new Set([
'equals', 'contains', 'not_equals', 'in', 'not_in',
]);

// Utility functions
const buildFilterConditions = (
filters: Array<{ field: string; operator: string; value: string | string[] }>,
paramPrefix: string,
Expand Down Expand Up @@ -117,7 +113,6 @@ const getDefaultDateRange = () => {
};

export const funnelsRouter = createTRPCRouter({
// Get autocomplete data for funnel creation
getAutocomplete: protectedProcedure
.input(analyticsDateRangeSchema)
.query(async ({ ctx, input }) => {
Expand Down Expand Up @@ -236,7 +231,6 @@ export const funnelsRouter = createTRPCRouter({
}
}),

// Get all funnels for a website
list: protectedProcedure
.input(z.object({ websiteId: z.string() }))
.query(async ({ ctx, input }) => {
Expand Down Expand Up @@ -272,7 +266,6 @@ export const funnelsRouter = createTRPCRouter({
}
}),

// Get a specific funnel
getById: protectedProcedure
.input(z.object({ id: z.string(), websiteId: z.string() }))
.query(async ({ ctx, input }) => {
Expand Down Expand Up @@ -311,7 +304,6 @@ export const funnelsRouter = createTRPCRouter({
}
}),

// Create a new funnel
create: protectedProcedure
.input(createFunnelSchema)
.mutation(async ({ ctx, input }) => {
Expand Down Expand Up @@ -708,7 +700,6 @@ export const funnelsRouter = createTRPCRouter({
}>(sessionReferrerQuery, params);


// Group events by session
const sessionEvents = new Map<string, Array<{
step_number: number,
step_name: string,
Expand All @@ -723,7 +714,6 @@ export const funnelsRouter = createTRPCRouter({
sessionEvents.get(event.session_id)?.push(event);
}

// Group sessions strictly by lowercased domain (fallback to 'direct')
const referrerGroups = new Map<string, { parsed: ReturnType<typeof parseReferrer>, sessionIds: Set<string> }>();
for (const [sessionId, events] of sessionEvents) {
if (events.length > 0) {
Expand All @@ -740,7 +730,6 @@ export const funnelsRouter = createTRPCRouter({
}
}

// Calculate analytics for each referrer group
const referrerAnalytics = [];
for (const [groupKey, group] of referrerGroups) {
const stepCounts = new Map<number, Set<string>>();
Expand Down Expand Up @@ -771,7 +760,6 @@ export const funnelsRouter = createTRPCRouter({
});
}

// AGGREGATE BY DOMAIN
const aggregated = new Map<string, {
parsed: ReturnType<typeof parseReferrer>,
total_users: number,
Expand All @@ -780,7 +768,7 @@ export const funnelsRouter = createTRPCRouter({
conversion_rate_count: number
}>();
for (const { referrer, referrer_parsed, total_users, completed_users, conversion_rate } of referrerAnalytics) {
const key = referrer; // This is the domain, e.g., "github.com"
const key = referrer;
if (!aggregated.has(key)) {
aggregated.set(key, {
parsed: referrer_parsed,
Expand Down
16 changes: 1 addition & 15 deletions packages/rpc/src/routers/goals.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import { z } from 'zod';
import { createTRPCRouter, protectedProcedure } from '../trpc';
import { and, eq, isNull, desc, sql, inArray } from 'drizzle-orm';
import { escape as sqlEscape } from 'sqlstring';
import { TRPCError } from '@trpc/server';
import { goals, chQuery } from '@databuddy/db';
import { authorizeWebsiteAccess } from '../utils/auth';
import { logger } from '../utils/discord-webhook';

const goalSchema = z.object({
type: z.enum(['PAGE_VIEW', 'EVENT', 'CUSTOM']),
Expand Down Expand Up @@ -66,7 +64,6 @@ const ALLOWED_OPERATORS = new Set([
'equals', 'contains', 'not_equals', 'in', 'not_in',
]);

// Refactored buildFilterConditions to use parameterized values
const buildFilterConditions = (filters: Array<{ field: string; operator: string; value: string | string[] }>, paramPrefix: string, params: Record<string, unknown>) => {
if (!filters || filters.length === 0) return '';
const filterConditions = filters.map((filter, i) => {
Expand Down Expand Up @@ -105,7 +102,6 @@ const getDefaultDateRange = () => {
};

export const goalsRouter = createTRPCRouter({
// List all goals for a website
list: protectedProcedure
.input(z.object({ websiteId: z.string() }))
.query(async ({ ctx, input }) => {
Expand All @@ -120,7 +116,6 @@ export const goalsRouter = createTRPCRouter({
.orderBy(desc(goals.createdAt));
return result;
}),
// Get a specific goal
getById: protectedProcedure
.input(z.object({ id: z.string(), websiteId: z.string() }))
.query(async ({ ctx, input }) => {
Expand All @@ -139,7 +134,6 @@ export const goalsRouter = createTRPCRouter({
}
return result[0];
}),
// Create a new goal
create: protectedProcedure
.input(createGoalSchema)
.mutation(async ({ ctx, input }) => {
Expand All @@ -162,7 +156,6 @@ export const goalsRouter = createTRPCRouter({

return newGoal;
}),
// Update a goal
update: protectedProcedure
.input(updateGoalSchema)
.mutation(async ({ ctx, input }) => {
Expand Down Expand Up @@ -192,7 +185,6 @@ export const goalsRouter = createTRPCRouter({
.returning();
return updatedGoal;
}),
// Delete a goal (soft delete)
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
Expand Down Expand Up @@ -220,7 +212,6 @@ export const goalsRouter = createTRPCRouter({
));
return { success: true };
}),
// Get goal analytics (conversion rate)
getAnalytics: protectedProcedure
.input(analyticsDateRangeSchema)
.query(async ({ ctx, input }) => {
Expand Down Expand Up @@ -305,7 +296,6 @@ export const goalsRouter = createTRPCRouter({
first_occurrence: number;
}>(analysisQuery, params);

// Process the results to calculate analytics
const sessionEvents = new Map<string, Array<{ step_number: number, step_name: string, first_occurrence: number }>>();
for (const event of rawResults) {
if (!sessionEvents.has(event.session_id)) {
Expand All @@ -317,7 +307,6 @@ export const goalsRouter = createTRPCRouter({
first_occurrence: event.first_occurrence
});
}
// Calculate analytics
const stepCounts = new Map<number, Set<string>>();
for (const [sessionId, events] of sessionEvents) {
events.sort((a, b) => a.first_occurrence - b.first_occurrence);
Expand All @@ -332,7 +321,6 @@ export const goalsRouter = createTRPCRouter({
}
}
}
// Build analytics results
const goalCompletions = stepCounts.get(1)?.size || 0;
const conversion_rate = totalWebsiteUsers > 0 ? (goalCompletions / totalWebsiteUsers) * 100 : 0;
const dropoffs = 0;
Expand All @@ -356,7 +344,6 @@ export const goalsRouter = createTRPCRouter({
steps_analytics: analyticsResults
};
}),
// Bulk analytics for multiple goals
bulkAnalytics: protectedProcedure
.input(z.object({
websiteId: z.string(),
Expand All @@ -381,9 +368,8 @@ export const goalsRouter = createTRPCRouter({
const params: Record<string, unknown> = {
websiteId: input.websiteId,
startDate,
endDate: endDate + ' 23:59:59',
endDate: `${endDate} 23:59:59`,
};
// Get total unique users for the website in the date range
const totalWebsiteUsersQuery = `
SELECT COUNT(DISTINCT session_id) as total_users
FROM analytics.events
Expand Down
1 change: 0 additions & 1 deletion packages/rpc/src/utils/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ export class RateLimiter {
}

const prevWindowCount = results[0]?.[1] ? Number.parseInt(results[0][1] as string) : 0
const currentWindowCount = results[1]?.[1] ? Number.parseInt(results[1][1] as string) : 0
const newCurrentWindowCount = results[2]?.[1] as number

if (typeof newCurrentWindowCount !== 'number') {
Expand Down
3 changes: 1 addition & 2 deletions packages/shared/src/lists/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
export * from './referrers';
export * from './bots';
export * from './referrers';
export * from './timezones';
export * from './languages';
Loading