Skip to content

Redesign the dashboard entirely#207

Merged
izadoesdev merged 181 commits into
mainfrom
staging
Dec 2, 2025
Merged

Redesign the dashboard entirely#207
izadoesdev merged 181 commits into
mainfrom
staging

Conversation

@izadoesdev

@izadoesdev izadoesdev commented Dec 2, 2025

Copy link
Copy Markdown
Member

Pull Request

Description

Please include a summary of the change and which issue is fixed. Also include relevant motivation and context.

Checklist

  • My code follows the style guidelines of this project
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes

Note

Major dashboard UI overhaul with new components and API key UX, adds active users and refined analytics (filters, funnels/goals), and updates SDK/tracker for better vitals/error handling.

  • Dashboard/UI:
    • Refreshes styles across components/ui/* (badges, buttons, tables, sheets, dialogs, tooltips, inputs, tabs, etc.).
    • Adds new components: RightSidebar, ChartTooltip, DeleteDialog, LineSlider, SegmentedControl, KeyboardShortcuts, TableEmptyState.
    • Refactors API keys UI (api-key-detail-dialog, api-key-list) and organizations provider to useQueries.
  • Analytics/Queries:
    • Simplifies funnel/goal analytics processing and referrer grouping; supports ignoreHistoricData.
    • Expands filter operators (contains, not_contains, starts_with, in, not_in) and mapping.
    • Migrates vitals/errors/custom events to span-based queries and adds hourly aggregations.
  • Billing/Websites:
    • Adds active users per website and returns alongside mini-chart data; improves billing usage aggregation.
  • Exports:
    • Validates date ranges; improves proto/CSV formatting; centralizes data fetch.
  • SDK/Tracker:
    • Bumps SDK to 2.3.0; refines script injection (excludes server-only props) and README.
    • Tracker: skip localhost unless debug, better error filtering (ignore extensions/"Script error."), improved batching/beacon usage; vitals FPS capture and batching.
  • Docs:
    • Adds Bento section and style updates; multiple copy/style tweaks.
  • Tests/Types:
    • Updates e2e tests for batching, SPA navigation, sampling/filters; refines shared types and filter lists.

Written by Cursor Bugbot for commit 949003d. This will update automatically on new commits. Configure here.

Summary by CodeRabbit

  • New Features

    • Saved filters: add, save/rename, apply, duplicate, and manage saved query filters.
    • New Settings pages: Account, Appearance, Notifications, Privacy, Analytics, Features.
    • Two‑factor auth dialog and expanded Account settings (profile, password, connected identities).
  • Improvements

    • Query filtering: query-string support and refined operators (contains/starts_with/not_contains).
    • Web Vitals & vitals views migrated to span-based metrics for richer aggregations.
    • UI refresh: dashboard panels, billing, organization screens, sidebars and error/detail views.

✏️ Tip: You can customize this high-level summary in your review settings.

Comment thread packages/rpc/src/services/export-service.ts Fixed

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 32

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (17)
apps/dashboard/app/(auth)/register/page.tsx (1)

541-550: Remove unnecessary/conflicting CSS classes from the Link.

The Link component has several classes that don't apply correctly in this context:

  • flex-1 has no effect since the parent <p> is not a flex container
  • h-auto and p-0 are button-like utilities that don't make sense for an inline link
  • text-right conflicts with the inline text flow
 						<Link
-							className="h-auto flex-1 cursor-pointer p-0 text-right font-medium text-[13px] text-accent-foreground transition-colors duration-200 hover:text-accent-foreground/60"
+							className="font-medium text-[13px] text-accent-foreground transition-colors duration-200 hover:text-accent-foreground/60"
 							href={
 								callbackUrl
 									? `/login?callback=${encodeURIComponent(callbackUrl)}`
 									: "/login"
 							}
 						>
apps/api/src/lib/clickhouse-backup.ts (1)

178-184: Advisory: Consider parameterized queries for INSERT statements.

While backupName is timestamp-derived and safe, lastBackupPath originates from a previous database query. If the database were compromised, this could enable SQL injection. Consider using parameterized queries consistently throughout the file, similar to the improvement made in getBackupMetadata.

The same applies to the error message escaping on line 245—replace(/'/g, "\\'") may not handle all edge cases (e.g., backslashes in error messages). ClickHouse's parameterized queries would be more robust:

await clickHouseOG.command({
    query: `
        INSERT INTO internal.databuddy_backups 
        (backup_name, backup_path, backup_type, base_backup_path, status)
        VALUES ({name:String}, {path:String}, {type:String}, {base:String}, 'started')
    `,
    query_params: {
        name: backupName,
        path: s3Url,
        type: isIncremental ? "incremental" : "full",
        base: lastBackupPath ?? "",
    },
});
apps/api/src/query/builders/profiles.ts (1)

55-55: Inconsistent page_views calculation.

The visitor_sessions CTE counts all events as page views using COUNT(*), while the profile_sessions query (line 212) correctly filters to only screen_view events using countIf(event_name = 'screen_view'). This creates the same inconsistency issue as the unique_pages metric.

Apply this diff to align with the corrected logic:

-        COUNT(*) as page_views,
+        countIf(e.event_name = 'screen_view') as page_views,
apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview-tab.tsx (2)

951-951: Avoid as any type assertion.

Using as any bypasses TypeScript's type checking. Consider fixing the type incompatibility between pagesTabs and the DataTable component's expected tabs prop type, either by adjusting the pagesTabs type or updating the component's type definitions.


978-987: Fix device type mapping: "laptop" should map to "desktop", not "mobile".

The mapping incorrectly converts "laptop" display name to "mobile" device_type value. Laptops are desktop-class devices and should map to "desktop" for correct filtering behavior. Users selecting laptop devices will currently get mobile device analytics instead.

Change line 980 from laptop: "mobile" to laptop: "desktop".

apps/dashboard/app/(main)/organizations/settings/api-keys/api-key-settings.tsx (1)

56-63: Tighten query typing to avoid the as ApiKeyRowItem[] cast

data is currently cast to ApiKeyRowItem[] via (data ?? []) as ApiKeyRowItem[], which hides mismatches between the RPC response and ApiKeyRowItem. Prefer wiring useQuery so it’s correctly typed up front, e.g. by:

  • Ensuring orpc.apikeys.list.queryOptions is generic and returns a UseQueryOptions<ApiKeyRowItem[]>, or
  • Calling useQuery<ApiKeyRowItem[]>({ queryKey, queryFn, ... }) explicitly without a loose cast.

This keeps the RPC layer and UI in sync and lets TS catch shape changes.

Also applies to: 65-66

apps/dashboard/app/(main)/organizations/settings/api-keys/api-key-row.tsx (1)

14-24: Ensure ApiKeyRowItem date fields match the actual RPC payload

ApiKeyRowItem declares:

revokedAt: Date | null;
expiresAt: string | null;
createdAt: Date;

If orpc.apikeys.list (or the underlying API) returns plain JSON, these are typically ISO strings, not Date instances. In that case, the current typing is misleading and can hide serialization/hydration issues.

Consider either:

  • Typing them as string | null (and letting dayjs accept strings), or
  • Normalizing to real Date objects at the boundary (and making sure the RPC layer reflects that in its types).

Aligning the type here with the actual transport avoids subtle bugs later.

apps/dashboard/app/(main)/billing/components/billing-header.tsx (1)

5-36: Billing header refactor to shared PageHeader looks good

The usePathnamePAGE_TITLES/DEFAULT_TITLE mapping is clean, and delegating layout to PageHeader improves consistency. One small nit: PageHeader unconditionally overrides the icon weight to "fill", so weight="duotone" on CreditCardIcon here is effectively ignored; consider dropping it (or adding a configurable iconWeight prop to PageHeader) to avoid confusion.

apps/api/src/schemas/query-schemas.ts (1)

8-24: Keep filter operator definitions DRY between schema and query types

FilterSchema.op and FilterType["op"] now mirror the new operator set, but they duplicate the string literals already represented by FilterOperator in apps/api/src/query/types.ts. To reduce risk of drift on future changes, consider importing and reusing a shared type (or deriving the union from a single source of truth) instead of repeating the literals here.

Also applies to: 82-86

apps/dashboard/app/(main)/websites/[id]/audience/page.tsx (1)

37-45: Remove unused websiteData prop.

The websiteData prop is passed to WebsiteAudienceTab but the component definition (audience-tab.tsx lines 65-72) doesn't accept it. The component only destructures: websiteId, dateRange, isRefreshing, setIsRefreshing, filters, and addFilter. Since websiteId is already provided, websiteData is unnecessary and should be removed from the props being passed.

apps/dashboard/app/(main)/organizations/components/organization-provider.tsx (1)

103-103: InviteMemberDialog cannot be opened in this component.

The showInviteMemberDialog state is defined and the dialog is rendered with proper props binding, but there is no code path that ever calls setShowInviteMemberDialog(true). Unlike the CreateOrganizationDialog which has a clear trigger, the InviteMemberDialog remains unreachable. Either add a button or action to open it, or remove it if not yet implemented.

apps/dashboard/app/(main)/websites/[id]/_components/tabs/audience-tab.tsx (1)

418-439: Minor: Inconsistent indentation in skeleton rendering.

The closing </div> tags at lines 436-439 appear to have inconsistent indentation compared to their opening tags, which may affect readability.

apps/dashboard/app/(main)/websites/[id]/assistant/components/chat-history-sheet.tsx (1)

148-165: Consider using a cleaner download approach.

The export handler manually creates and removes anchor elements. This works but could be simplified.

 const handleExportChat = async (websiteId: string, websiteName?: string) => {
 	try {
 		const exportData = await chatDB.exportChat(websiteId);
 		const blob = new Blob([JSON.stringify(exportData, null, 2)], {
 			type: "application/json",
 		});
 		const url = URL.createObjectURL(blob);
 		const a = document.createElement("a");
 		a.href = url;
 		a.download = `chat-${websiteName || websiteId}-${new Date().toISOString().split("T")[0]}.json`;
-		document.body.appendChild(a);
 		a.click();
-		document.body.removeChild(a);
 		URL.revokeObjectURL(url);
 	} catch (error) {
 		console.error("Failed to export chat:", error);
 	}
 };

Modern browsers don't require appending the anchor to the DOM before clicking it.

apps/api/src/query/index.ts (1)

16-27: Avoid duplicated operator literals between schema and types.

The added operators look correct, but the op literals are now hard‑coded here and (via FilterOperators) elsewhere. Consider centralizing them in a shared const (e.g., const FILTER_OPS = [...] as const) and reusing that both for FilterOperators and this z.enum to prevent future drift.

apps/dashboard/app/(main)/billing/cost-breakdown/components/usage-breakdown-table.tsx (2)

16-22: Remove unused Table imports.

These Table components are imported but no longer used after the refactor to div-based layout.

-import {
-	Table,
-	TableBody,
-	TableCell,
-	TableHead,
-	TableHeader,
-	TableRow,
-} from "@/components/ui/table";

178-189: Dead code: This condition can never be true.

If sortedBreakdown.length === 0, the early return at line 92-100 would have already rendered the EmptyState component and exited. This block is unreachable.

Remove the dead code:

-			{sortedBreakdown.length === 0 && (
-				<div className="py-12 text-center">
-					<TableIcon
-						className="mx-auto mb-4 size-12 text-muted-foreground"
-						weight="duotone"
-					/>
-					<h3 className="font-semibold text-lg text-foreground">No Event Data</h3>
-					<p className="mt-1 text-muted-foreground text-sm">
-						No events found for the selected period
-					</p>
-				</div>
-			)}
apps/api/src/query/builders/errors.ts (1)

152-226: Guard errorRate against division by zero in error_summary.

error_summary computes:

ROUND((err.error_count / ts.total) * 100, 2) as errorRate

If total_sessions (ts.total) is 0 for the selected window, this will divide by zero and likely fail the query or emit infinities/NaN.

Consider wrapping the expression with a conditional:

-						ROUND((err.error_count / ts.total) * 100, 2) as errorRate
+						ROUND(if(ts.total = 0, 0, (err.error_count / ts.total) * 100), 2) as errorRate

This keeps the shape of the result stable and avoids runtime errors on low‑traffic or newly onboarded sites.

♻️ Duplicate comments (40)
apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview-tab.tsx (1)

963-963: Redundant Tailwind class lg:grid-cols-1.

lg:grid-cols-1 is redundant since grid-cols-1 already applies to all breakpoints until overridden.

-<div className="grid grid-cols-1 gap-4 lg:grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3">
+<div className="grid grid-cols-1 gap-4 xl:grid-cols-2 2xl:grid-cols-3">
apps/dashboard/app/(main)/layout.tsx (2)

3-7: Confirm auth strategy for (main) and avoid relying on implicit React namespace

Two things to double‑check here:

  • This layout now renders children unconditionally with no guard. If (main) is meant to stay protected, please confirm there is middleware or a higher‑level layout enforcing auth; otherwise, this effectively exposes the dashboard publicly. If it’s intentionally public, a short comment noting where auth is enforced would help future readers.
  • You’re using React.ReactNode in the prop type but don’t import React or ReactNode. Depending on your TS/JSX config this can fail type‑checking. Safer to import the type explicitly and drop the React. namespace:
-import { Sidebar } from "@/components/layout/sidebar";
-
-export default function MainLayout({
-	children,
-}: {
-	children: React.ReactNode;
-}) {
+import { Sidebar } from "@/components/layout/sidebar";
+import type { ReactNode } from "react";
+
+export default function MainLayout({
+	children,
+}: {
+	children: ReactNode;
+}) {

9-16: Reduce nested h-screen/overflow to avoid double scroll and mobile vh quirks

You currently have three stacked h-screen containers with overflow-hidden on the outer and overflow-y-auto on the inner. This tends to cause awkward scrolling (especially on mobile Safari) and makes layout debugging harder. You can keep the same visual layout with a single full‑height shell and one scroll area, e.g.:

-		<div className="h-screen overflow-hidden text-foreground">
-			<Sidebar />
-			<div className="relative h-screen pl-0 md:pl-76 lg:pl-84">
-				<div className="h-screen overflow-y-auto overflow-x-hidden pt-16 md:pt-0">
-					{children}
-				</div>
-			</div>
-		</div>
+		<div className="min-h-screen text-foreground">
+			<Sidebar />
+			<div className="relative pl-0 md:pl-76 lg:pl-84">
+				<div className="h-screen overflow-y-auto overflow-x-hidden pt-16 md:pt-0">
+					{children}
+				</div>
+			</div>
+		</div>

This keeps the sidebar/content padding but avoids nested viewport heights and competing overflow declarations.

apps/dashboard/app/(auth)/login/forgot/page.tsx (1)

68-68: Button background override may bypass variant styling.

The bg-primary className override on the Button component may conflict with the Button's built-in variant system for hover, focus, and disabled states. Consider using the Button's variant API instead of direct className overrides.

apps/dashboard/app/(auth)/layout.tsx (2)

35-36: Fix Tailwind important modifier syntax and verify Button variant.

Two issues:

  1. The px-0! syntax is incorrect - the ! should come before the utility: !px-0
  2. The variant change to "ghost" is appropriate for this secondary navigation action

Apply this diff to fix the syntax:

-						className="px-0! text-white/50 hover:bg-transparent hover:text-white/80"
+						className="!px-0 text-white/50 hover:bg-transparent hover:text-white/80"

38-38: Missing group class for icon hover effect.

The icon uses group-hover:translate-x-[-4px] but the wrapping Button (line 34-40) lacks the group class, so the hover translation won't trigger.

Add group to the Button's className:

 					<Button
-						className="!px-0 text-white/50 hover:bg-transparent hover:text-white/80"
+						className="group !px-0 text-white/50 hover:bg-transparent hover:text-white/80"
 						variant="ghost"
apps/dashboard/app/(auth)/login/page.tsx (2)

157-180: Nested interactive elements issue already flagged.

This nested Button inside Link creates invalid HTML and accessibility problems, as detailed in the previous review comment. The suggested fixes (using asChild pattern or router.push) should be applied.


265-280: Text wrapping issue already flagged.

The use of text-nowrap on flex-1 elements can cause horizontal overflow on narrow viewports, as noted in the previous review comment. Consider removing text-nowrap or making it responsive with lg:text-nowrap.

apps/dashboard/app/(main)/websites/[id]/_components/utils/technology-helpers.tsx (1)

197-207: Confirm custom gradient and accent utility classes are defined and themed correctly

The updated getColorClass branches now rely on custom utilities like green-angled-rectangle-gradient, blue-angled-rectangle-gradient, amber-angled-rectangle-gradient, badge-angled-rectangle-gradient, and bg-accent-brighter, plus new border colors.

Please double-check that:

  • These classes are actually defined in your Tailwind config / global CSS and are included in the compiled dashboard bundle.
  • Their colors follow the same token/contrast strategy as the existing text-* / dark:text-* usage so badges remain legible in both light and dark themes.

If design has already validated these utilities, no code change is needed. If more components adopt these variants, consider centralizing the class strings into a small map (e.g., variant → className) to avoid repeating long literals.

apps/dashboard/app/(main)/billing/components/usage-row.tsx (1)

80-84: Restore icon margin on “Bonus” badge for consistency with other badges

The GiftIcon in the “Bonus” badge is now the only badge icon without a right margin, while WarningIcon and LightningIcon both use className="mr-1". This will likely make the “Bonus” label appear tighter than “Overage” / “Unlimited”.

Consider restoring the margin for visual consistency:

-							{feature.hasExtraCredits && (
-								<Badge variant="secondary">
-									<GiftIcon size={10} weight="fill" />
-									Bonus
-								</Badge>
-							)}
+							{feature.hasExtraCredits && (
+								<Badge variant="secondary">
+									<GiftIcon className="mr-1" size={10} weight="fill" />
+									Bonus
+								</Badge>
+							)}
apps/dashboard/app/(main)/settings/layout.tsx (1)

1-6: Import ReactNode instead of using React.ReactNode without a React import

SettingsLayoutProps references React.ReactNode, but this file doesn’t import React or ReactNode. In this codebase, other layouts typically import the ReactNode type directly, and relying on a global React namespace can cause type errors.

Align it with the prevailing pattern:

+import type { ReactNode } from "react";
+
 type SettingsLayoutProps = {
-	children: React.ReactNode;
+	children: ReactNode;
 };
 
 export default function SettingsLayout({ children }: SettingsLayoutProps) {
 	return <div className="h-full overflow-y-auto">{children}</div>;
 }
apps/api/src/query/types.ts (1)

13-21: Document how contains vs starts_with differ despite both mapping to LIKE

contains, not_contains, and starts_with all map to SQL LIKE/NOT LIKE, so the semantic difference is entirely in how values are formatted (e.g., %value% vs value%). A brief comment here would make this clearer and keep it in sync with the builder logic that applies the wildcards.

Example:

 export const FilterOperators = {
 	eq: "=",
 	ne: "!=",
+	// contains/not_contains use %value%, starts_with uses value%
 	contains: "LIKE",
 	not_contains: "NOT LIKE",
 	starts_with: "LIKE",
 	in: "IN",
 	not_in: "NOT IN",
 } as const;

Also double-check that all filter-building code paths format values consistently with this contract.

#!/bin/bash
# Spot-check where filter operators are applied to SQL and how LIKE patterns are built.
rg -nP "contains|not_contains|starts_with" apps/api/src/query -C4
apps/dashboard/app/(main)/settings/_components/chart-preview.tsx (2)

65-83: Extract duplicate gradient definition.

The linearGradient definition is duplicated between the "bar" case (lines 65-83) and "area" case (lines 117-135). Extract this into a shared helper function to reduce duplication and maintenance burden.

+	const renderGradient = () => (
+		<defs>
+			<linearGradient id={`gradient-${chartId}`} x1="0" x2="0" y1="0" y2="1">
+				<stop offset="0%" stopColor="var(--color-primary)" stopOpacity={0.4} />
+				<stop offset="100%" stopColor="var(--color-primary)" stopOpacity={0} />
+			</linearGradient>
+		</defs>
+	);

Then replace the duplicated <defs> blocks with {renderGradient()} in both cases.

Also applies to: 117-135


155-169: Inconsistent styling for default/"composed" case.

The default case renders a BarChart with solid fill (fill="var(--color-primary)"), while the explicit "bar" case uses a gradient fill. Since "composed" is a valid chartType value, users passing it will get visually different output than "bar". Either implement a distinct "composed" visualization or align the styling.

apps/dashboard/app/(main)/organizations/settings/danger/transfer-assets.tsx (1)

171-171: Inconsistent vertical spacing between website lists.

Personal websites list uses space-y-2 (line 171) while Organization websites list uses space-y-1 (line 227). This creates visual asymmetry between the two symmetric sections.

-			<div className="flex-1 space-y-1 overflow-y-auto">
+			<div className="flex-1 space-y-2 overflow-y-auto">

Also applies to: 227-227

apps/dashboard/app/(main)/organizations/components/organizations-list.tsx (1)

169-183: Consider moving CreateOrganizationDialog outside RightSidebar.

The dialog is rendered as a child of RightSidebar. While most dialog implementations use portals and should work correctly, placing it as a sibling after RightSidebar provides cleaner component hierarchy and avoids potential z-index or stacking context issues.

 			<RightSidebar className="gap-4 p-5">
 				<Button
 					className="w-full"
 					onClick={() => setShowCreateOrganizationDialog(true)}
 				>
 					<PlusIcon size={16} />
 					New Organization
 				</Button>
-				<CreateOrganizationDialog
-					isOpen={showCreateOrganizationDialog}
-					onClose={() => setShowCreateOrganizationDialog(false)}
-				/>
 				<RightSidebar.DocsLink />
 				<RightSidebar.Tip description="Click on an organization to switch to it. The active organization is used across the dashboard." />
 			</RightSidebar>
+			<CreateOrganizationDialog
+				isOpen={showCreateOrganizationDialog}
+				onClose={() => setShowCreateOrganizationDialog(false)}
+			/>
 		</div>
apps/dashboard/app/(main)/settings/account/sections/two-factor-dialog.tsx (2)

231-242: Add error handling for clipboard operations.

navigator.clipboard.writeText can reject if permissions are denied or the API is unavailable. Without a try/catch, the promise rejection goes unhandled and the user gets no feedback.

 const handleCopyBackupCodes = useCallback(async () => {
-	const codesText = backupCodes.join("\n");
-	await navigator.clipboard.writeText(codesText);
-	setCopiedBackup(true);
-	toast.success("Backup codes copied to clipboard");
-	setTimeout(() => setCopiedBackup(false), 2000);
+	try {
+		const codesText = backupCodes.join("\n");
+		await navigator.clipboard.writeText(codesText);
+		setCopiedBackup(true);
+		toast.success("Backup codes copied to clipboard");
+		setTimeout(() => setCopiedBackup(false), 2000);
+	} catch {
+		toast.error("Failed to copy to clipboard");
+	}
 }, [backupCodes]);

 const handleCopySecret = useCallback(async () => {
-	await navigator.clipboard.writeText(secret);
-	toast.success("Secret key copied to clipboard");
+	try {
+		await navigator.clipboard.writeText(secret);
+		toast.success("Secret key copied to clipboard");
+	} catch {
+		toast.error("Failed to copy to clipboard");
+	}
 }, [secret]);

474-481: Use backup code as key instead of array index.

Since backup codes are unique strings, using the code itself as the key is more semantically correct and avoids potential issues if codes are regenerated during the same render cycle.

-{backupCodes.map((code, i) => (
+{backupCodes.map((code) => (
 	<code
 		className="rounded bg-background px-2 py-1 text-center font-mono text-sm"
-		key={i}
+		key={code}
 	>
 		{code}
 	</code>
 ))}

Also applies to: 545-552

apps/api/src/query/expressions.ts (1)

740-746: The quantile source encoding remains fragile.

As noted in the past review, encoding source as "level)(column" (e.g., "0.50)(metric_value") to produce correct SQL is non-obvious and error-prone. The typed agg.quantile() and agg.quantileIf() methods (lines 164-166) provide a safer API, but compileAggregate still relies on this fragile convention.

Consider adding validation to detect malformed quantile sources, or document this convention clearly if it must remain.

apps/dashboard/app/(main)/billing/cost-breakdown/page.tsx (2)

76-92: Suspense boundaries remain ineffective.

This issue was previously flagged: both ConsumptionChart and UsageBreakdownTable receive isLoading props and handle loading states internally. Since data fetching happens in the parent via useQuery (lines 47-56), these Suspense boundaries won't trigger their fallbacks.


97-97: Remove trailing whitespace.

Line 97 contains trailing whitespace, which was previously flagged but remains unaddressed.

apps/api/src/query/simple-builder.ts (1)

62-62: **Fix ClickHouse queryString() function parameter: use url instead of path.**ClickHouse's queryString() function signature is queryString(url) — the parameter should be a URL, not path. The queryString() function takes a URL string as input and returns the query parameters as a clean string.

If the path column contains full URLs (including protocol), this may work, but the parameter name is semantically incorrect. If path only contains the path portion (e.g., /page/sub?foo=bar), this will still extract query strings correctly since ClickHouse's URL functions are maximally simplified.

Verify what the path column contains in your analytics tables:

#!/bin/bash
# Check what data the path column typically contains
ast-grep --pattern 'path'
rg -n "path" apps/api/src/types/tables.ts -A2 -B2
apps/api/src/query/builders/custom-events.ts (1)

251-295: Missing LIMIT clause could return unbounded results.

Unlike custom_events_by_path which has a default limit of 50, this builder has no LIMIT clause. For long date ranges, this could return a large number of rows (one per day), potentially causing performance issues.

Apply this diff to add a limit:

 	custom_events_trends: {
 		customSql: (
 			websiteId: string,
 			startDate: string,
 			endDate: string,
 			_filters?: Filter[],
 			_granularity?: TimeUnit,
 			_limit?: number,
 			_offset?: number,
 			_timezone?: string,
 			filterConditions?: string[],
 			filterParams?: Record<string, Filter["value"]>
 		) => {
+			const limit = _limit ?? 1000;
 			const combinedWhereClause = filterConditions?.length
 				? `AND ${filterConditions.join(" AND ")}`
 				: "";

 			return {
 				sql: `
 					SELECT 
 						toDate(timestamp) as date,
 						COUNT(*) as total_events,
 						COUNT(DISTINCT event_name) as unique_event_types,
 						COUNT(DISTINCT anonymous_id) as unique_users
 					FROM ${Analytics.custom_event_spans}
 					WHERE 
 						client_id = {websiteId:String}
 						AND timestamp >= toDateTime({startDate:String})
 						AND timestamp <= toDateTime(concat({endDate:String}, ' 23:59:59'))
 						AND event_name != ''
 						${combinedWhereClause}
 					GROUP BY toDate(timestamp)
 					ORDER BY date ASC
+					LIMIT {limit:UInt32}
 				`,
 				params: {
 					websiteId,
 					startDate,
 					endDate,
+					limit,
 					...filterParams,
 				},
 			};
 		},
apps/dashboard/app/(main)/billing/cost-breakdown/components/usage-breakdown-table.tsx (1)

113-113: Double border between items.

The parent container uses divide-y divide-border (line 113) which adds borders between children, but each item also has border-b border-border (line 141). This creates duplicate bottom borders.

Remove the redundant border from items:

 <div
 	key={item.event_category}
-	className="group flex items-center gap-4 border-b border-border px-4 py-3 transition-colors hover:bg-accent/50 sm:px-6 sm:py-4"
+	className="group flex items-center gap-4 px-4 py-3 transition-colors hover:bg-accent/50 sm:px-6 sm:py-4"
 >

Also applies to: 141-141

apps/dashboard/app/(main)/websites/[id]/_components/shared/tracking-components.tsx (1)

312-316: Bug: subtitle prop does not exist on NoticeBanner.

Based on the NoticeBanner component definition (from relevant code snippets at apps/dashboard/app/(main)/websites/_components/notice-banner.tsx), it accepts description, not subtitle. The status text will not render.

Apply this diff to fix the prop name:

 		<NoticeBanner
 			className="w-full"
 			icon={
 				isSetup ? (
 					<CheckIcon className="size-4" weight="duotone" />
 				) : (
 					<WarningCircleIcon className="size-4" weight="duotone" />
 				)
 			}
-			subtitle={
+			description={
 				isSetup
 					? "Data is being collected successfully"
 					: "Install the tracking script to start collecting data"
 			}
 			title={isSetup ? "Tracking Active" : "Tracking Not Setup"}
 		>
apps/dashboard/app/(main)/settings/appearance/page.tsx (3)

126-157: Add aria-pressed for accessibility on theme buttons.

The theme selection buttons lack aria-pressed, so screen readers won't communicate which theme is currently selected.

 <button
 	className={cn(
 		"flex flex-col items-center gap-2 rounded border p-4 transition-colors",
 		isActive
 			? "border-primary bg-primary/5"
 			: "border-border hover:bg-accent"
 	)}
 	key={id}
 	onClick={() => setTheme(id)}
 	type="button"
+	aria-pressed={isActive}
 >

94-106: Potential hydration mismatch with useTheme().

The theme value from next-themes is undefined during SSR and initial client render until the component mounts. This causes hydration mismatches when rendering theme directly (line 400) or comparing it in theme button active states (line 124).

Add a mounted state check to guard theme-dependent rendering:

-import { useState } from "react";
+import { useEffect, useState } from "react";
...
 export default function AppearanceSettingsPage() {
 	const { theme, setTheme } = useTheme();
+	const [mounted, setMounted] = useState(false);
 	const { preferences, updateLocationPreferences, updateAllPreferences } =
 		useAllChartPreferences();
+
+	useEffect(() => {
+		setMounted(true);
+	}, []);

Then guard theme comparisons with mounted && or render a placeholder until mounted.


308-386: Invalid nesting of interactive elements inside <button>.

Placing <Select> components inside a <button> element creates invalid HTML. Per the HTML spec, buttons cannot contain interactive content. This causes accessibility issues—screen readers may not properly announce the nested controls, and keyboard navigation may behave unexpectedly.

Restructure to use a non-interactive row container with a separate clickable region for the location selection:

-<button
+<div
 	className={cn(
-		"grid w-full grid-cols-[1fr_5.5rem_7.5rem] items-center gap-3 px-4 py-2.5 text-left transition-colors",
+		"grid w-full grid-cols-[1fr_5.5rem_7.5rem] items-center gap-3 px-4 py-2.5 transition-colors",
 		i < CHART_LOCATIONS.length - 1 && "border-b",
 		isActive && "bg-accent/50"
 	)}
 	key={location}
-	onClick={() => setPreviewLocation(location)}
-	type="button"
 >
-	<div className="flex items-center gap-2">
+	<button
+		className="flex items-center gap-2 text-left"
+		onClick={() => setPreviewLocation(location)}
+		type="button"
+	>
 		<Icon ... />
 		<span ...>
 			{CHART_LOCATION_LABELS[location]}
 		</span>
-	</div>
+	</button>
 	<Select ...>
-		<SelectTrigger onClick={(e) => e.stopPropagation()} ...>
+		<SelectTrigger ...>
 		...
 	</Select>
 	...
-</button>
+</div>
apps/api/src/query/builders/vitals.ts (1)

72-111: Unused parameters _filters and _granularity in vitals_by_page.

The parameters _filters and _granularity are declared but never used in the query. Since customizable: true, these should either be implemented to support dynamic filtering or removed if not needed.

Apply this diff to remove unused parameters:

 vitals_by_page: {
 	customSql: (
 		websiteId: string,
 		startDate: string,
 		endDate: string,
-		_filters?,
-		_granularity?,
 		_limit?: number
 	) => {

Or implement filter/granularity support to justify customizable: true.

apps/dashboard/app/(main)/settings/account/page.tsx (4)

56-63: Handle edge case where name could be empty string.

If name is an empty string, split(" ") returns [""], and accessing n[0] on an empty string returns undefined, causing undefined.toUpperCase() to throw.

Apply this diff to handle empty strings:

 function getInitials(name: string): string {
+	if (!name.trim()) return "?";
 	return name
 		.split(" ")
+		.filter(Boolean)
 		.map((n) => n[0])
 		.join("")
 		.toUpperCase()
 		.slice(0, 2);
 }

77-102: Add minimum password length validation for consistency.

The ChangePasswordDialog only validates that passwords match but doesn't enforce minimum length, while the TwoFactorDialog enforces an 8-character minimum. This inconsistency could allow weak passwords.

Apply this diff:

 const changePasswordMutation = useMutation({
 	mutationFn: async () => {
 		if (newPassword !== confirmPassword) {
 			throw new Error("Passwords do not match");
 		}
+		if (newPassword.length < 8) {
+			throw new Error("Password must be at least 8 characters");
+		}
 		const result = await authClient.changePassword({

227-241: All provider buttons show loading state when any link is pending.

When linkSocial.isPending is true, all unconnected provider buttons are disabled and would show spinners. Track which provider is being linked to show the spinner only on the relevant button.

Track the linking provider:

+const [linkingProvider, setLinkingProvider] = useState<SocialProvider | null>(null);

 const linkSocial = useMutation({
 	mutationFn: async (provider: SocialProvider) => {
+		setLinkingProvider(provider);
 		const result = await authClient.linkSocial({
 			provider,
 			callbackURL: window.location.href,
 		});

Then in the JSX, check linkingProvider === provider instead of linkSocial.isPending for the spinner.


589-596: Tip content mentions email but email cannot be changed.

The tip advises keeping email up to date, but the email field is disabled (line 351). Consider updating to actionable advice.

 <p className="text-muted-foreground text-xs">
-	Keep your email up to date to ensure you receive important
-	notifications about your account.
+	Enable two-factor authentication and connect multiple login
+	methods to keep your account secure.
 </p>
apps/api/src/types/tables.ts (1)

1-10: Still missing analytics.outgoing_links mapping in TableFieldsMap.

Analytics exposes outgoing_links, but TableFieldsMap doesn’t define a corresponding key, so code using the map can’t get strong typing for that table.

You can address this by importing the outgoing‑links row type and wiring it into the map:

 import type {
 	AnalyticsEvent,
 	BlockedTraffic,
 	CustomEventSpan,
 	CustomEventsHourlyAggregate,
+	CustomOutgoingLink,
 	ErrorHourlyAggregate,
 	ErrorSpanRow,
 	WebVitalsHourlyAggregate,
 	WebVitalsSpan,
 } from "@databuddy/db";

 export type TableFieldsMap = {
 	"analytics.events": keyof AnalyticsEvent;
 	"analytics.error_spans": keyof ErrorSpanRow;
 	"analytics.error_hourly": keyof ErrorHourlyAggregate;
 	"analytics.web_vitals_spans": keyof WebVitalsSpan;
 	"analytics.web_vitals_hourly": keyof WebVitalsHourlyAggregate;
 	"analytics.custom_event_spans": keyof CustomEventSpan;
 	"analytics.custom_events_hourly": keyof CustomEventsHourlyAggregate;
 	"analytics.blocked_traffic": keyof BlockedTraffic;
+	"analytics.outgoing_links": keyof CustomOutgoingLink;
 };

Also applies to: 12-35

apps/api/src/query/builders/performance.ts (2)

216-227: LEFT JOINs combined with WHERE e.* != '' behave as INNER JOINs.

In the web_vitals_by_* builders you use:

LEFT JOIN ${Analytics.events} e ON (...)
WHERE
  wv.client_id = ...
  AND ...
  AND e.browser_name != '' -- / e.country / e.os_name / e.region

The WHERE e.* != '' predicate null‑filters the right side, so these are effectively INNER JOINs and will drop vitals rows without matching events. If that’s intentional, it’s clearer to declare INNER JOIN; if you meant to preserve unmatched vitals, move the non‑empty checks into the JOIN condition or use COALESCE with a default label.

This mirrors an earlier nitpick on the same pattern in this file.

Also applies to: 262-273, 309-320, 355-366


205-205: Avoid redundant any() on columns already in GROUP BY.

You’re selecting:

any(e.browser_name) as name
any(e.country) as name
any(e.os_name) as name
CONCAT(any(e.region), ', ', any(e.country)) as name

while also grouping by those same columns. The any() wrappers aren’t needed and can be simplified to the grouped columns directly:

- any(e.browser_name) as name,
+ e.browser_name as name,

- any(e.country) as name,
+ e.country as name,

- any(e.os_name) as name,
+ e.os_name as name,

- CONCAT(any(e.region), ', ', any(e.country)) as name,
+ CONCAT(e.region, ', ', e.country) as name,

This keeps the intent clearer without changing semantics.

Also applies to: 251-251, 298-298, 344-344

apps/dashboard/app/(main)/websites/[id]/errors/_components/error-detail-modal.tsx (4)

52-82: Local CopyButton still duplicates existing docs CopyButton

You’re re‑implementing a CopyButton here while there’s already one in apps/docs/components/docs/copy-button.tsx. If the divergence is intentional (e.g., section-aware copy state), consider either extracting a shared primitive (with variant props for analytics/docs) or adding a short comment to explain why this version is local to avoid future drift.


110-121: Clear copy timeout on unmount to avoid state updates after unmount

The setTimeout clearing copiedSection may fire after the modal unmounts, causing a state update on an unmounted component and a small memory leak. Track the timeout in a ref and clear it on unmount and before scheduling a new one.

-import { useState } from "react";
+import { useEffect, useRef, useState } from "react";
@@
 export const ErrorDetailModal = ({
   error,
   isOpen,
   onClose,
 }: ErrorDetailModalProps) => {
 	const [copiedSection, setCopiedSection] = useState<CopiedSection>(null);
+
+	const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+
+	useEffect(() => {
+		return () => {
+			if (copyTimeoutRef.current) {
+				clearTimeout(copyTimeoutRef.current);
+			}
+		};
+	}, []);
@@
 	const copyToClipboard = async (text: string, section: CopiedSection) => {
 		try {
 			await navigator.clipboard.writeText(text);
 			setCopiedSection(section);
 			toast.success("Copied to clipboard");
-			setTimeout(() => setCopiedSection(null), 2000);
+			if (copyTimeoutRef.current) {
+				clearTimeout(copyTimeoutRef.current);
+			}
+			copyTimeoutRef.current = setTimeout(() => {
+				setCopiedSection(null);
+				copyTimeoutRef.current = null;
+			}, 2000);
 		} catch (err) {

149-205: Normalize indentation of quickActions conditional blocks

The if blocks building quickActions are flush-left while surrounding code is indented, which hurts readability and looks like a formatting glitch. Align them with the rest of the function body:

-If (error.path) {
-	quickActions.push({
+	if (error.path) {
+		quickActions.push({
 		key: "copy-url",
 		// ...
-	});
-}
+		});
+	}
@@
-If (isAbsoluteUrl) {
-	quickActions.push({
+	if (isAbsoluteUrl) {
+		quickActions.push({
 		key: "open-page",
 		// ...
-	});
-}
+		});
+	}
@@
-If (error.session_id) {
-	quickActions.push({
+	if (error.session_id) {
+		quickActions.push({
 		key: "copy-session",
 		// ...
-	});
-}
+		});
+	}
@@
-If (error.stack) {
-	quickActions.push({
+	if (error.stack) {
+		quickActions.push({
 		key: "copy-stack",
 		// ...
-	});
-}
+		});
+	}

207-232: Tighten contextRows typing to remove as CopiedSection and ensure text is always a string

section={row.copySection as CopiedSection} bypasses type safety, and copyValue can be inferred as string | undefined while CopyButton.text is string. You can make contextRows fully typed and remove the cast while guaranteeing text is always a string:

-	const contextRows = [
+	const contextRows = [
 		{
 			key: "url",
 			label: "Page URL",
 			value: error.path || "—",
 			icon: <LinkIcon className="h-4 w-4 shrink-0 text-muted-foreground" weight="duotone" />,
-			copySection: error.path ? "url" : null,
-			copyValue: error.path,
+			copySection: error.path ? ("url" as const) : null,
+			copyValue: error.path ?? "",
 		},
 		{
 			key: "session",
 			label: "Session ID",
 			value: error.session_id || "—",
 			icon: <HashIcon className="h-4 w-4 shrink-0 text-muted-foreground" weight="duotone" />,
-			copySection: error.session_id ? "session" : null,
-			copyValue: error.session_id,
+			copySection: error.session_id ? ("session" as const) : null,
+			copyValue: error.session_id ?? "",
 		},
 		{
 			key: "user",
 			label: "User ID",
 			value: error.anonymous_id || "—",
 			icon: <UserIcon className="h-4 w-4 shrink-0 text-muted-foreground" weight="duotone" />,
-			copySection: error.anonymous_id ? "user" : null,
-			copyValue: error.anonymous_id,
+			copySection: error.anonymous_id ? ("user" as const) : null,
+			copyValue: error.anonymous_id ?? "",
 		},
-	];
+	] satisfies {
+		key: string;
+		label: string;
+		value: string;
+		icon: ReactNode;
+		copySection: CopiedSection;
+		copyValue: string;
+	}[];
@@
-									{row.copySection && row.copyValue && (
+									{row.copySection && row.copyValue && (
 										<CopyButton
 											ariaLabel={`Copy ${row.label}`}
 											copiedSection={copiedSection}
 											onCopy={copyToClipboard}
-											section={row.copySection as CopiedSection}
+											section={row.copySection}
 											text={row.copyValue}
 										/>
 									)}

This keeps runtime behavior the same but removes the need for assertions and aligns types with CopyButton.

Also applies to: 396-403

Comment on lines +202 to +203
max_backup_bandwidth = 104857600,
s3_use_virtual_addressing = false${baseBackupSetting}

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.

🧹 Nitpick | 🔵 Trivial

Consider making max_backup_bandwidth configurable.

The hardcoded 100 MiB/s (104857600 bytes) bandwidth limit is reasonable, but different environments may have varying network constraints. Consider exposing this as an optional environment variable with this as the default.

+// At top of file or in env schema
+const BACKUP_BANDWIDTH = env.BACKUP_BANDWIDTH ?? 104857600; // 100 MiB/s default
+
 // In backupQuery settings
-                max_backup_bandwidth = 104857600,
+                max_backup_bandwidth = ${BACKUP_BANDWIDTH},

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In apps/api/src/lib/clickhouse-backup.ts around lines 202-203 the backup config
hardcodes max_backup_bandwidth = 104857600; make this value configurable via an
environment variable (e.g. MAX_BACKUP_BANDWIDTH) while preserving 104857600 as
the default. Change the config generation to read
process.env.MAX_BACKUP_BANDWIDTH, parse it to an integer with a safe fallback to
104857600 and a simple validation (positive integer), and then inject that
computed value into the generated config instead of the literal. Also update any
environment typing/schema (if present) and add a comment documenting the env var
and units (bytes/sec).

Comment thread apps/api/src/query/builders/profiles.ts

SELECT
ce.id,
generateUUIDv4() as id,

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 | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the full context around line 161 in sessions.ts
cd apps/api/src/query/builders && wc -l sessions.ts

Repository: databuddy-analytics/Databuddy

Length of output: 88


🏁 Script executed:

# Check the context around line 161 to understand the custom_event_spans query
sed -n '150,175p' apps/api/src/query/builders/sessions.ts

Repository: databuddy-analytics/Databuddy

Length of output: 862


🏁 Script executed:

# Search for event ID usage patterns - looking for references to event.id, events[].id, etc.
rg -nP --type=ts --type=tsx '\b(event.*\.id|events\[.*\]\.id|evt\.id|ce\.id)\b' -C3 apps/

Repository: databuddy-analytics/Databuddy

Length of output: 100


🏁 Script executed:

# Search for generateUUIDv4 usage to understand the intent behind the change
rg -nP --type=ts --type=tsx 'generateUUIDv4' -C2 apps/

Repository: databuddy-analytics/Databuddy

Length of output: 100


🏁 Script executed:

# Search for custom_event_spans references to understand the table schema
rg -nP --type=ts --type=tsx 'custom_event_spans' -C3 apps/

Repository: databuddy-analytics/Databuddy

Length of output: 100


🏁 Script executed:

# Search for event ID usage patterns without tsx flag
rg -nP --type=ts 'event.*\.id|events\[.*\]\.id|evt\.id|ce\.id' -C3 apps/

Repository: databuddy-analytics/Databuddy

Length of output: 2497


🏁 Script executed:

# Search for generateUUIDv4 usage to understand intent
rg -nP --type=ts 'generateUUIDv4' -C2 apps/

Repository: databuddy-analytics/Databuddy

Length of output: 397


🏁 Script executed:

# Look for custom_event_spans references
rg -nP --type=ts 'custom_event_spans' -C3 apps/

Repository: databuddy-analytics/Databuddy

Length of output: 4304


🏁 Script executed:

# Search for any frontend/client code that might reference event IDs
rg -nP --type=ts 'event.*id|eventId|event_id' -C2 apps/ | head -100

Repository: databuddy-analytics/Databuddy

Length of output: 9767


🏁 Script executed:

# Check if there are any tests or documentation about event ID stability
rg -nP --type=ts '(track.*event|dedup|referential|event.*id|stable.*id)' -C2 apps/ | head -100

Repository: databuddy-analytics/Databuddy

Length of output: 6058


🏁 Script executed:

# Search for CustomEventSpan type definition
rg -nP --type=ts 'CustomEventSpan|interface.*CustomEvent' -A 10 apps/

Repository: databuddy-analytics/Databuddy

Length of output: 8398


🏁 Script executed:

# Look for where custom_event_spans is populated/inserted
rg -nP --type=ts '(INSERT INTO|custom_event_spans|custom events)' apps/ -C 3

Repository: databuddy-analytics/Databuddy

Length of output: 6758


🏁 Script executed:

# Check if ce.id exists or needs to be generated
grep -r "ce\.id\|custom_event.*\.id" apps/ --include="*.ts" -C 3

Repository: databuddy-analytics/Databuddy

Length of output: 55


🏁 Script executed:

# Look for the original query to see what ce.id was before
rg -nP --type=ts 'session_events|SELECT.*FROM.*custom_event' -B 5 -A 15 apps/api/src/query/builders/

Repository: databuddy-analytics/Databuddy

Length of output: 6923


🏁 Script executed:

# Find the @databuddy/db package definition for CustomEventSpan
fd -t f -e ts 'CustomEventSpan|types' packages/db/src/ 2>/dev/null | head -20

Repository: databuddy-analytics/Databuddy

Length of output: 55


🏁 Script executed:

# Search for CustomEventSpan type definition
rg -nP 'export.*CustomEventSpan|interface CustomEventSpan|type CustomEventSpan' -A 15 packages/

Repository: databuddy-analytics/Databuddy

Length of output: 3437


🏁 Script executed:

# Look for database schema or migration files
fd -t f -e sql -o -e ts 'schema|migration' packages/db/ 2>/dev/null | head -20

Repository: databuddy-analytics/Databuddy

Length of output: 55


🏁 Script executed:

# Check if there's an id field in the database schema
rg -nP 'custom_event|id.*String|UUID' packages/db/ -A 3 -B 1 | head -100

Repository: databuddy-analytics/Databuddy

Length of output: 6603


🏁 Script executed:

# Get the full custom_event_spans table schema definition
sed -n '361,383p' packages/db/src/clickhouse/schema.ts

Repository: databuddy-analytics/Databuddy

Length of output: 832


Remove generateUUIDv4() and use a stable event identifier instead.

The custom_event_spans table has no id column, so generateUUIDv4() as id generates a new UUID on each query execution. This means the same custom event receives a different ID every time it's queried, breaking any expectation of stable event identifiers. The frontend and deduplication logic in security.ts rely on consistent event IDs. Either store the client-provided eventId from the intake payload or generate IDs deterministically to match the regular events table behavior.

🤖 Prompt for AI Agents
In apps/api/src/query/builders/sessions.ts around line 161, remove the call to
generateUUIDv4() as id because the custom_event_spans table has no id column and
generating a new UUID per query breaks stable identifiers; instead, return the
client-provided eventId field from the intake payload (or compute a
deterministic ID the same way the regular events table does) so the query emits
a stable event id used by the frontend and deduplication logic—update the SELECT
to use the existing eventId column (or the deterministic generator) and remove
generateUUIDv4().

Comment thread apps/api/src/query/expressions.ts Outdated
Comment on lines +74 to 75
<Loader2 className="h-4 w-4 animate-spin" />
Sending reset link...

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.

🧹 Nitpick | 🔵 Trivial

Restore spacing between loading icon and text.

The Loader2 icon has no margin separating it from the text "Sending reset link...", which may reduce readability. Consider adding mr-2 to the icon classes for consistent visual spacing.

Apply this diff to restore the spacing:

-							<Loader2 className="h-4 w-4 animate-spin" />
+							<Loader2 className="mr-2 h-4 w-4 animate-spin" />
📝 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
<Loader2 className="h-4 w-4 animate-spin" />
Sending reset link...
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sending reset link...
🤖 Prompt for AI Agents
In apps/dashboard/app/(auth)/login/forgot/page.tsx around lines 74-75, the
Loader2 icon is missing spacing from the adjacent "Sending reset link..." text;
update the icon's className to include spacing (e.g., add "mr-2") so there is
consistent margin between the spinner and the text for improved readability.

Comment on lines +35 to +37
function getFieldLabel(field: string): string {
return filterOptions.find((o) => o.value === field)?.label ?? field;
}

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.

🧹 Nitpick | 🔵 Trivial

Duplicate helper function across filter components.

getFieldLabel is duplicated in saved-filters-menu.tsx, filters-section.tsx, and save-filter-dialog.tsx. Consider extracting this to a shared utility (e.g., in @/hooks/use-filters alongside getOperatorLabel).

-function getFieldLabel(field: string): string {
-	return filterOptions.find((o) => o.value === field)?.label ?? field;
-}
+import { getFieldLabel } from "@/hooks/use-filters";

Then add to apps/dashboard/hooks/use-filters.ts:

export function getFieldLabel(field: string): string {
  return filterOptions.find((o) => o.value === field)?.label ?? field;
}
🤖 Prompt for AI Agents
In
apps/dashboard/app/(main)/websites/[id]/_components/filters/saved-filters-menu.tsx
around lines 35-37, the getFieldLabel helper is duplicated across multiple
filter components; extract it into the shared hooks file
apps/dashboard/hooks/use-filters.ts and replace the local implementation with an
import. Specifically, add the exported getFieldLabel function to use-filters.ts
(same logic as currently in this file), update saved-filters-menu.tsx to import
{ getFieldLabel } from '@/hooks/use-filters', and remove the in-file duplicate;
repeat the same replacement in filters-section.tsx and save-filter-dialog.tsx so
all components use the shared utility.

Comment thread apps/dashboard/app/(main)/websites/[id]/_components/tabs/audience-tab.tsx Outdated
Comment on lines +566 to 573
<div className="flex items-center justify-center rounded border bg-sidebar py-12">
<div className="text-center">
<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-primary border-b-2" />
<p className="text-sidebar-foreground/70 text-sm">
Loading performance data...
</p>
</div>
</div>

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 | 🟡 Minor

Incomplete spinner border styling.

The spinner element has border-b-2 for the animated portion but only border-primary for the rest, which doesn't specify border width. This may result in an invisible or inconsistent spinner appearance.

-				<div className="flex items-center justify-center rounded border bg-sidebar py-12">
-					<div className="text-center">
-						<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-primary border-b-2" />
+				<div className="flex items-center justify-center rounded border bg-sidebar py-12">
+					<div className="text-center">
+						<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-2 border-primary/30 border-t-primary" />
📝 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
<div className="flex items-center justify-center rounded border bg-sidebar py-12">
<div className="text-center">
<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-primary border-b-2" />
<p className="text-sidebar-foreground/70 text-sm">
Loading performance data...
</p>
</div>
</div>
<div className="flex items-center justify-center rounded border bg-sidebar py-12">
<div className="text-center">
<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-2 border-primary/30 border-t-primary" />
<p className="text-sidebar-foreground/70 text-sm">
Loading performance data...
</p>
</div>
</div>
🤖 Prompt for AI Agents
In apps/dashboard/app/(main)/websites/[id]/_components/tabs/performance-tab.tsx
around lines 566 to 573, the spinner uses border-primary but only sets
border-b-2 for width which can make the non-animated borders invisible or
inconsistent; update the spinner markup/classes so all borders have a defined
width (e.g., use border-2) and make the non-animated sides transparent (e.g.,
border-transparent) while keeping the animated segment using border-primary and
the same width (e.g., border-b-2 or border-2 with a differing side color) to
ensure a consistent visible spinner.

Comment on lines +48 to 54
"w-full cursor-pointer rounded border bg-sidebar p-4 text-left transition-all hover:shadow-sm";
const activeClasses =
filter === "fast"
? "bg-green-50 ring-1 ring-green-500/20 border-green-500 dark:bg-green-950/20"
: "bg-red-50 ring-1 ring-red-500/20 border-red-500 dark:bg-red-950/20";
const inactiveClasses = "hover:bg-muted/50 hover:border-primary/50";
const inactiveClasses = "hover:bg-sidebar/80 hover:border-primary/50";
const badgeClasses =

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 | 🟠 Major

Styling updates look good; guard against totalPages === 0 in percentage math

The move to bg-sidebar/text-sidebar-foreground and the adjusted spacing aligns this card with the new theme and doesn’t affect behavior.

However, the percentage calculations:

  • Line 150: percentage={(summary.fastPages / summary.totalPages) * 100}
  • Line 161: percentage={(summary.slowPages / summary.totalPages) * 100}

will yield NaN or Infinity when summary.totalPages is 0, which is likely in “no data yet” scenarios and will render as NaN%/Infinity%.

Consider a small guard, for example:

-			<FilterButton
+			const fastPercentage =
+				summary.totalPages > 0
+					? (summary.fastPages / summary.totalPages) * 100
+					: 0;
+			const slowPercentage =
+				summary.totalPages > 0
+					? (summary.slowPages / summary.totalPages) * 100
+					: 0;
+
+			<FilterButton
 				activeFilter={activeFilter}
@@
-				percentage={(summary.fastPages / summary.totalPages) * 100}
+				percentage={fastPercentage}
@@
-			<FilterButton
+			<FilterButton
@@
-				percentage={(summary.slowPages / summary.totalPages) * 100}
+				percentage={slowPercentage}

This keeps the UI stable when there are no pages to report on.

Also applies to: 71-79, 103-141, 143-163

🤖 Prompt for AI Agents
In
apps/dashboard/app/(main)/websites/[id]/_components/tabs/performance/_components/performance-summary-card.tsx
around lines 48-54 (also review 71-79, 103-141, 143-163), the percentage
calculations use summary.totalPages as the divisor and will produce NaN/Infinity
when totalPages === 0; change each percentage expression to guard against zero
by returning 0 (or another safe default) when summary.totalPages is falsy (e.g.,
totalPages === 0) and otherwise compute (num/totalPages)*100, ensuring the UI
renders a stable 0% instead of NaN/Infinity.

Comment on lines +128 to +139
const fullErrorInfo = `Error: ${error.message}
${error.stack ? `\nStack Trace:\n${error.stack}` : ""}

Context:
• URL: ${error.path}
• Session: ${error.session_id || "Unknown"}
• User: ${error.anonymous_id}
• Time: ${formatDateTimeSeconds(error.timestamp)}
• Browser: ${error.browser_name || "Unknown"}
• OS: ${error.os_name || "Unknown"}
• Device: ${error.device_type || "Unknown"}
• Location: ${locationLabel}`;

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 | 🟡 Minor

Recheck which fields are included in fullErrorInfo / “Copy All” for PII

fullErrorInfo currently includes URL, session ID, anonymous user ID, timestamp, browser, OS, device, location, and (via Metadata) IP/user agent are visible elsewhere. Copying this whole blob to the clipboard makes it easy to paste PII into tickets or chats.

If you have stricter privacy constraints, consider:

  • Excluding or masking IP / user agent from the “Copy All” payload, or
  • Making “Copy All” obviously labeled as including user-identifiable metadata so teams know where it’s safe to paste.

Also applies to: 500-511

🤖 Prompt for AI Agents
In
apps/dashboard/app/(main)/websites/[id]/errors/_components/error-detail-modal.tsx
around lines 128-139 (also applies to lines ~500-511), fullErrorInfo currently
includes user-identifiable fields; remove or mask PII by excluding session_id,
anonymous_id, IP address and user-agent (and consider masking query params in
URL), replacing those values with a fixed token like "<redacted>" (or omit the
lines entirely) and ensure the "Copy All" action uses the masked payload;
additionally update the UI to make the "Copy All" button clearly
labeled/tooltiped that it contains user-identifiable metadata if you choose to
keep any non-redacted metadata.

}: GoalItemProps) {
const conversionRate = analytics?.overall_conversion_rate ?? 0;
const totalUsers = analytics?.total_users_entered ?? 0;
const completions = analytics?.total_users_completed ?? 0;
AND timestamp >= toDateTime({startDate:String})
AND timestamp <= toDateTime(concat({endDate:String}, ' 23:59:59'))
AND path != ''
GROUP BY path

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: GROUP BY mismatch causes incorrect path aggregation

The web_vitals_by_page query has a mismatch between the SELECT and GROUP BY clauses. The SELECT normalizes the path with decodeURLComponent(CASE WHEN trimRight(path(path), '/') = '' THEN '/' ELSE trimRight(path(path), '/') END) as name, but the GROUP BY uses raw path. This causes paths like /about and /about/ to be grouped separately but display with the same normalized name in the output, resulting in duplicate rows and incorrect metric aggregation. The similar query vitals_by_page in vitals.ts correctly uses GROUP BY page (the alias), which this query should follow.

Fix in Cursor Fix in Web

@vercel vercel Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Additional Suggestion:

The empty state for Web Vitals is shown when data loading fails, instead of only showing it when there's legitimately no data. When an error occurs, users see both the error banner and the "No data yet" message, which is confusing.

View Details
📝 Patch Details
diff --git a/apps/dashboard/app/(main)/websites/[id]/vitals/page.tsx b/apps/dashboard/app/(main)/websites/[id]/vitals/page.tsx
index 61808bc8..5d757014 100644
--- a/apps/dashboard/app/(main)/websites/[id]/vitals/page.tsx
+++ b/apps/dashboard/app/(main)/websites/[id]/vitals/page.tsx
@@ -251,7 +251,7 @@ export default function VitalsPage() {
 					</div>
 				)}
 
-				{!isLoading && overviewData.length === 0 && (
+				{!isLoading && !isError && overviewData.length === 0 && (
 					<div className="rounded border bg-card p-8 text-center">
 						<HeartbeatIcon
 							className="mx-auto size-12 text-muted-foreground/40"

Analysis

Empty state shown alongside error banner in Web Vitals page

What fails: The empty state message "No Web Vitals data yet" is displayed at the same time as the error banner from WebsitePageHeader when a query fails, creating a confusing UX with duplicate error messaging.

How to reproduce:

  1. Navigate to the Web Vitals page for a website (/websites/[id]/vitals)
  2. Trigger an API failure (network error, server error, or invalid request)
  3. Observe both the error banner and "No Web Vitals data yet" message displayed simultaneously

What happened: When the query fails, isError becomes true and overviewData is empty (defaults to []). The condition !isLoading && overviewData.length === 0 evaluates to true even during error state, causing the empty state to render alongside the error banner.

Expected: Only the error banner should be displayed when there's an error. The empty state should only appear when loading is complete AND there's no error AND data is genuinely absent.

Root cause: Line 254 of apps/dashboard/app/(main)/websites/[id]/vitals/page.tsx was missing an !isError check in the condition. The correct pattern is already used in other pages like apps/dashboard/app/(main)/websites/page.tsx (line 132) and apps/dashboard/app/(main)/websites/[id]/users/page.tsx.

Fix applied: Added !isError check to the empty state condition: {!isLoading && !isError && overviewData.length === 0 && (

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 31

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (13)
apps/dashboard/app/(main)/billing/cost-breakdown/components/usage-breakdown-table.tsx (1)

173-186: Unreachable code block.

This fallback block is never rendered because:

  1. Line 83 returns early if !usageData?.eventTypeBreakdown?.length
  2. sortedBreakdown is derived from eventTypeBreakdown (line 95), so if we reach this point, sortedBreakdown.length will always be > 0

Consider removing this dead code:

-			{sortedBreakdown.length === 0 && (
-				<div className="py-12 text-center">
-					<TableIcon
-						className="mx-auto mb-4 size-12 text-muted-foreground"
-						weight="duotone"
-					/>
-					<h3 className="font-semibold text-foreground text-lg">
-						No Event Data
-					</h3>
-					<p className="mt-1 text-muted-foreground text-sm">
-						No events found for the selected period
-					</p>
-				</div>
-			)}
apps/dashboard/app/(main)/websites/[id]/_components/shared/tracking-components.tsx (1)

205-217: Critical: Double-toggle bug caused by nested click handlers.

The card's onClick={onToggle} (line 207) and the Switch's onCheckedChange={onToggle} (line 216) both fire when clicking the switch, toggling the state twice and canceling the intended action. Users will perceive the switch as non-functional.

Solution: Remove the card's onClick handler and rely solely on the Switch:

 		<div
-			className="cursor-pointer rounded border bg-card p-4 transition-all duration-200 hover:bg-accent-brighter"
-			onClick={onToggle}
+			className="rounded border bg-card p-4 transition-all duration-200 hover:bg-accent-brighter"
 		>

If you want the entire card to be clickable, add a click handler that stops propagation from the Switch:

 		<div
 			className="cursor-pointer rounded border bg-card p-4 transition-all duration-200 hover:bg-accent-brighter"
 			onClick={onToggle}
 		>
 			<div className="flex items-start justify-between pb-3">
 				<div className="min-w-0 flex-1 space-y-1 pr-3">
 					<div className="text-left font-medium text-sm">{title}</div>
 					<div className="text-left text-muted-foreground text-xs leading-relaxed">
 						{description}
 					</div>
 				</div>
-				<Switch checked={isEnabled} onCheckedChange={onToggle} />
+				<div onClick={(e) => e.stopPropagation()}>
+					<Switch checked={isEnabled} onCheckedChange={onToggle} />
+				</div>
 			</div>
apps/dashboard/app/(main)/organizations/settings/danger/transfer-assets.tsx (1)

25-53: Add ARIA state to expose selection while keeping new visuals

The updated card/selection styling looks good. Since WebsiteItem represents a selectable/toggle state, consider exposing this to assistive tech, e.g.:

<button
  aria-pressed={selected}
  // or role="option" aria-selected={selected} if used in a listbox pattern
>

This keeps the new styling while making the selection state accessible.

apps/api/src/lib/clickhouse-backup.ts (2)

218-228: Prefer parameterized queries for UPDATE statement.

Similar to the INSERT statement, this UPDATE uses string interpolation. For consistency and defense-in-depth, use parameterized queries.

 		await clickHouseOG.command({
-			query: `
+		await clickHouseOG.query({
+			query: `
                 ALTER TABLE internal.databuddy_backups 
                 UPDATE 
                     status = 'completed',
-                    size_bytes = ${metadata?.size_bytes ?? 0},
-                    compressed_size_bytes = ${metadata?.compressed_size_bytes ?? 0},
-                    num_files = ${metadata?.num_files ?? 0}
-                WHERE backup_name = '${backupName}'
+                    size_bytes = {sizeBytes:UInt64},
+                    compressed_size_bytes = {compressedSizeBytes:UInt64},
+                    num_files = {numFiles:UInt64}
+                WHERE backup_name = {backupName:String}
             `,
-		});
+			query_params: {
+				sizeBytes: metadata?.size_bytes ?? 0,
+				compressedSizeBytes: metadata?.compressed_size_bytes ?? 0,
+				numFiles: metadata?.num_files ?? 0,
+				backupName,
+			},
+		});

191-204: S3 credentials in backup queries will be logged in ClickHouse's query log.

Lines 191-204 and 410-417 embed S3 credentials directly into BACKUP and RESTORE queries. ClickHouse records all executed queries in system.query_log by default without masking sensitive information. Anyone with database access can retrieve credentials from the query log. Use parameterized queries where ClickHouse supports them, or implement server-side query masking via ClickHouse configuration (query_masking_rules in users.xml) to redact credentials before logging.

apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview-tab.tsx (1)

649-656: today* values are under-counted for hourly granularity

Using .find on analytics.events_by_date returns only the first matching entry for today, which under‑counts pageviews, visitors, and sessions when granularity === "hourly" (multiple hourly rows per day). The “X today” descriptions will be wrong in that mode.

Aggregate across all entries for today instead of taking the first:

- const todayDate = dayjs().format("YYYY-MM-DD");
- const todayEvent = analytics.events_by_date.find(
-   (event: MetricPoint) => dayjs(event.date).format("YYYY-MM-DD") === todayDate
- );
- const todayVisitors = todayEvent?.visitors ?? 0;
- const todaySessions = todayEvent?.sessions ?? 0;
- const todayPageviews = todayEvent?.pageviews ?? 0;
+ const todayDate = dayjs().format("YYYY-MM-DD");
+ const todayEvents = analytics.events_by_date.filter(
+   (event: MetricPoint) => dayjs(event.date).format("YYYY-MM-DD") === todayDate
+ );
+
+ const todayVisitors = todayEvents.reduce((sum, event) => {
+   const visitors =
+     (event.visitors as number | undefined) ??
+     (event.unique_visitors as number | undefined) ??
+     0;
+   return sum + visitors;
+ }, 0);
+
+ const todaySessions = todayEvents.reduce(
+   (sum, event) => sum + ((event.sessions as number | undefined) ?? 0),
+   0
+ );
+
+ const todayPageviews = todayEvents.reduce(
+   (sum, event) => sum + ((event.pageviews as number | undefined) ?? 0),
+   0
+ );

This keeps daily ranges correct while fixing hourly ranges.

Also applies to: 796-804, 809-809, 818-818

apps/api/src/query/utils.ts (2)

26-34: Inline cast in shouldParseReferrers is fine, but a bit noisy

Accessing type/name via repeated { type?: string; name?: string } casts works, but is slightly verbose; if this grows, consider a small helper type or local narrowing to avoid duplicating the cast. Not a blocker.


203-210: buildWhereClause can return invalid WHERE () when all conditions are filtered out

If conditions is non-empty but every entry matches UNSAFE_SQL, safe ends up empty and the function returns WHERE (), which is invalid SQL. Safer to short-circuit when safe.length === 0 and return an empty string.

 export function buildWhereClause(conditions?: string[]): string {
   if (!conditions?.length) {
     return "";
   }
   const safe = conditions.filter((c) => !UNSAFE_SQL.test(c));
-  return `WHERE (${safe.join(" AND ")})`;
+  if (!safe.length) {
+    return "";
+  }
+  return `WHERE (${safe.join(" AND ")})`;
 }
apps/basket/src/lib/producer.ts (2)

229-247: Bounded re-buffering on flush failure is a solid improvement

The new bufferHardMax check avoids unbounded growth when a table flush fails and explicitly tracks dropped events with logging, which tightens backpressure behavior. One optional refinement: when this.buffer.length + events.length slightly exceeds bufferHardMax, you currently drop the whole batch; if retaining as many events as possible matters, consider re-queueing up to the remaining capacity and only dropping the overflow.


210-216: Pass abort_signal to the ClickHouse insert call to make flushTimeout effective

The AbortController created at line 212 is never wired into the clickHouse.insert() call. The @clickhouse/client insert API accepts an abort_signal parameter (part of BaseQueryParams), so the timeout should be passed as abort_signal: controller.signal in the options object at line 221–225. Without this, the timeout mechanism has no effect on slow inserts.

apps/dashboard/app/(auth)/login/page.tsx (1)

232-248: Consider enhancing focus indication for the password toggle button.

The password show/hide button uses the ghost variant, which may not provide sufficient visual distinction when focused vs. when the input field is focused. Users navigating by keyboard might find it difficult to determine whether the button or the input has focus.

Consider adding explicit focus ring styles or using a variant with more distinct focus states.

apps/api/src/query/builders/profiles.ts (2)

153-173: total_pageviews in profile_detail currently counts all events, not page views.

Here:

COUNT(DISTINCT session_id) as total_sessions,
COUNT(*) as total_pageviews,

COUNT(*) includes all event types for the visitor, so total_pageviews will disagree with page‑view metrics elsewhere that are based on screen_view only (e.g., profile_sessions.page_views).

Either:

  • Rename this to total_events if that’s the intent, or
  • Restrict it to page views to keep semantics aligned, e.g.:
countIf(event_name = 'screen_view') as total_pageviews,

38-47: Apply time window constraints to visitor_sessions to match visitor_profiles.

The visitor_sessions CTE (lines 79-82) lacks time constraints, so sessions and their associated data (session_start, session_end, page_views, unique_pages) can extend outside the requested date range. Add AND e.time >= toDateTime({startDate:String}) and AND e.time <= toDateTime({endDate:String}) to the WHERE clause.

Additionally, combinedWhereClause is reused in both CTEs without explicit column aliases, which can cause ambiguity for filters referencing columns that exist in both analytics.events and derived data (e.g., country, region). Consider using explicitly qualified aliases in filter conditions or separate condition sets for the profile-level vs session-level filtering.

♻️ Duplicate comments (17)
apps/dashboard/app/(main)/websites/[id]/_components/tabs/performance-tab.tsx (1)

565-572: Spinner border styling remains incomplete (duplicate issue).

The spinner still uses border-primary border-b-2, which only renders the bottom border with width while other borders remain invisible. This was flagged in a previous review but hasn't been addressed.

Apply the previously suggested fix:

-				<div className="flex items-center justify-center rounded border bg-sidebar py-12">
-					<div className="text-center">
-						<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-primary border-b-2" />
+				<div className="flex items-center justify-center rounded border bg-sidebar py-12">
+					<div className="text-center">
+						<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-2 border-primary/30 border-t-primary" />
apps/dashboard/app/(main)/organizations/settings/danger/danger-zone-settings.tsx (1)

158-158: Inconsistent icon margin spacing (previously reported).

The SignOutIcon here is still missing the mr-2 className that the TrashIcon has on line 149, causing inconsistent spacing in the button layout.

Apply this diff to fix the spacing:

-								<SignOutIcon size={14} />
+								<SignOutIcon className="mr-2" size={14} />
apps/dashboard/app/(main)/billing/cost-breakdown/page.tsx (1)

76-92: Suspense boundaries are ineffective with current data fetching approach.

This was flagged in a previous review. Since data fetching uses useQuery (not useSuspenseQuery), the components render immediately and handle loading states internally via the isLoading prop. The Suspense fallbacks will never trigger.

Either:

  1. Remove the Suspense wrappers if internal loading states are sufficient
  2. Refactor to use useSuspenseQuery to leverage Suspense properly
apps/dashboard/app/(main)/websites/[id]/_components/tabs/audience-tab.tsx (1)

219-230: Performance issue: Column definitions still not memoized (previously flagged).

This issue was already identified in a previous review. The displayNames object and all five column arrays (countryColumns, regionColumns, cityColumns, timezoneColumns, languageColumns) are recreated on every render. Since these are included in the geographicTabs dependency array (lines 296-300), the tabs will unnecessarily recompute on every render, defeating the purpose of useMemo.

Apply the previously suggested fix:

+	const displayNames = useMemo(
+		() =>
+			typeof window !== "undefined"
+				? new Intl.DisplayNames([navigator.language || "en"], {
+						type: "language",
+					})
+				: null,
+		[]
+	);

-	const displayNames =
-		typeof window !== "undefined"
-			? new Intl.DisplayNames([navigator.language || "en"], {
-					type: "language",
-				})
-			: null;
-
-	const countryColumns = createGeoColumns({ type: "country" });
-	const regionColumns = createGeoColumns({ type: "region" });
-	const cityColumns = createGeoColumns({ type: "city" });
-	const timezoneColumns = createTimezoneColumns();
-	const languageColumns = createLanguageColumns(displayNames);
+	const countryColumns = useMemo(() => createGeoColumns({ type: "country" }), []);
+	const regionColumns = useMemo(() => createGeoColumns({ type: "region" }), []);
+	const cityColumns = useMemo(() => createGeoColumns({ type: "city" }), []);
+	const timezoneColumns = useMemo(() => createTimezoneColumns(), []);
+	const languageColumns = useMemo(
+		() => createLanguageColumns(displayNames),
+		[displayNames]
+	);
apps/api/src/query/expressions.ts (3)

781-783: Invalid ClickHouse syntax in quantile fallback.

The fallback quantile(0.50)(*) is invalid—ClickHouse quantile functions require a specific numeric column, not *. This will cause runtime errors when source is missing.

 		case "quantile":
 			// source should be "level)(column" e.g., "0.50)(metric_value"
-			return source ? `quantile(${source})` : "quantile(0.50)(*)";
+			if (!source) {
+				throw new Error("quantile requires a source in format 'level)(column'");
+			}
+			return `quantile(${source})`;

799-800: Invalid ClickHouse syntax in quantileIf fallback.

Same issue as quantile—the fallback quantile(0.50)(*) is invalid ClickHouse syntax.

 		case "quantileIf":
-			return source ? `quantile(${source})` : "quantile(0.50)(*)";
+			if (!source) {
+				throw new Error("quantileIf requires a source in format 'level)(column'");
+			}
+			return `quantile(${source})`;

742-748: Fragile source encoding for quantileIf with condition.

The pattern of encoding source as "level)(column" (e.g., "0.50)(metric_value") is non-obvious and error-prone. Consider adding a dedicated level property to FieldDefinitionType for quantile aggregates, or at minimum add validation to detect malformed inputs.

 		case "quantile":
 		case "quantileIf":
-			// For quantile with condition, source should be "level)(column"
-			// e.g., source = "0.50)(metric_value" produces quantileIf(0.50)(metric_value, condition)
-			return source
-				? `quantileIf(${source}, ${condition})`
-				: `quantileIf(0.50)(1, ${condition})`;
+			// Validate source format for quantile
+			if (!source || !source.includes(")(")) {
+				throw new Error(
+					`quantileIf requires source in format 'level)(column', got: ${source}`
+				);
+			}
+			return `quantileIf(${source}, ${condition})`;
apps/api/src/query/index.ts (1)

17-25: Operator set reduction already flagged - no new concerns.

The operator changes from legacy operators to the new set (eq, ne, contains, not_contains, starts_with, in, not_in) were previously identified as a breaking API change. The implementation is consistent with the updated FilterOperators in types.ts.

apps/api/src/query/builders/sessions.ts (1)

161-161: Non-deterministic ID generation already flagged.

Using generateUUIDv4() as id for custom events generates a new UUID on each query execution, meaning the same event receives different IDs across queries. This was previously flagged as a major issue. Consider using a deterministic ID based on event properties or storing/retrieving the client-provided eventId.

apps/api/src/query/builders/performance.ts (1)

163-181: GROUP BY mismatch causes incorrect path aggregation — still present.

The SELECT normalizes the path (line 164) with trimRight and decodeURLComponent, but the GROUP BY (line 181) uses raw path. This causes paths like /about/ and /about to be grouped separately but display with the same normalized name, leading to duplicate rows and incorrect metric aggregation.

Apply this diff to fix:

-					GROUP BY path
+					GROUP BY name

Or equivalently, group by the same expression:

-					GROUP BY path
+					GROUP BY decodeURLComponent(CASE WHEN trimRight(path(path), '/') = '' THEN '/' ELSE trimRight(path(path), '/') END)
apps/dashboard/app/(main)/organizations/settings/websites/website-settings.tsx (1)

164-164: Pluralization helper already suggested in previous review.

The inline pluralization logic here was previously flagged for extraction to a reusable helper function.

apps/dashboard/app/(main)/organizations/components/general-settings.tsx (1)

18-21: Fix nullable organization.slug handling to avoid runtime errors and inconsistent change detection.

organization.slug is nullable per the schema, but slug state and validation assume a non-null string:

  • const [slug, setSlug] = useState(organization.slug);
  • hasChanges compares slug !== organization.slug.
  • handleSave calls slug.trim().
  • The slug <Input> uses value={slug}.

If an org has slug === null and the user only edits the name, hasChanges becomes true, handleSave is invoked, and slug.trim() will throw. The same nullable value also makes change detection and the input value type less clear.

Recommend normalizing to a string and aligning hasChanges with that normalization:

- const [slug, setSlug] = useState(organization.slug);
+ const [slug, setSlug] = useState(organization.slug ?? "");

 // ...

- const hasChanges = name !== organization.name || slug !== organization.slug;
+ const hasChanges =
+  name !== organization.name || slug !== (organization.slug ?? "");

This keeps the input controlled with a string, prevents trim on null, and makes the “unsaved changes” banner behavior predictable when migrating orgs that don’t yet have a slug.

Also applies to: 24-36, 37-53, 99-105

apps/api/src/query/builders/profiles.ts (1)

23-47: Align unique_pages/page_views semantics in profile_list with profile_sessions.

In profile_list:

  • Line 30: COUNT(DISTINCT path) as unique_pages counts all paths, regardless of event_name.
  • Line 56: COUNT(DISTINCT e.path) as unique_pages does the same, while page_views is COUNT(*) across all events.

In profile_sessions you’ve already normalized both page_views and unique_pages to only consider screen_view events, so these endpoints will now disagree on the same conceptual metrics for the same visitor/session, which is a data integrity issue.

To keep metrics consistent, consider updating profile_list to match the profile_sessions logic, for example:

-        COUNT(DISTINCT path) as unique_pages,
+        COUNT(DISTINCT CASE WHEN event_name = 'screen_view' THEN path ELSE NULL END) as unique_pages,
...
-        COUNT(*) as page_views,
-        COUNT(DISTINCT e.path) as unique_pages,
+        countIf(e.event_name = 'screen_view') as page_views,
+        COUNT(DISTINCT CASE WHEN e.event_name = 'screen_view' THEN e.path ELSE NULL END) as unique_pages,

Also applies to: 49-56

apps/dashboard/app/(main)/websites/[id]/_components/filters/add-filters.tsx (1)

307-352: Nested FormField for operator inside value field render prop (same prior nit)

The operator FormField is nested inside the value FormField’s render callback, which is unconventional and makes the structure harder to follow.

As suggested previously, you can render operator and value as siblings inside the same flex container:

-<FormField
-  name="value"
-  render={({ field }) => (
-    <FormItem>
-      <FormControl>
-        <div className="flex">
-          <FormField name="operator" render={...} />
-          <Input {...field} />
-        </div>
-      </FormControl>
-      <FormMessage />
-    </FormItem>
-  )}
-/>

+<FormItem>
+  <FormLabel className="text-xs">Value</FormLabel>
+  <div className="flex">
+    <FormField
+      control={form.control}
+      name="operator"
+      render={({ field: operatorField }) => (
+        <Select
+          value={operatorField.value}
+          onValueChange={operatorField.onChange}
+        >
+          {/* trigger/content */}
+        </Select>
+      )}
+    />
+    <FormField
+      control={form.control}
+      name="value"
+      render={({ field }) => (
+        <Input autoFocus className="rounded-l-none text-sm" {...field} />
+      )}
+    />
+  </div>
+  <FormMessage className="text-xs" />
+</FormItem>

This keeps each field isolated and easier to reason about.

apps/dashboard/app/(main)/websites/[id]/_components/filters/filters-section.tsx (1)

20-22: getFieldLabel helper duplicated across filter components

This getFieldLabel implementation is repeated in at least filters-section.tsx, save-filter-dialog.tsx, and saved-filters-menu.tsx.

Consider extracting it once, e.g. into @/hooks/use-filters next to getOperatorLabel:

// apps/dashboard/hooks/use-filters.ts
import { filterOptions } from "@databuddy/shared/lists/filters";

export function getFieldLabel(field: string): string {
  return filterOptions.find((o) => o.value === field)?.label ?? field;
}

Then replace local helpers with:

-import { filterOptions } from "@databuddy/shared/lists/filters";
+import { getFieldLabel } from "@/hooks/use-filters";

And remove the in-file getFieldLabel definitions.

apps/dashboard/app/(main)/websites/[id]/_components/filters/save-filter-dialog.tsx (1)

37-39: getFieldLabel duplicated (same as other filter components)

This helper repeats the same logic already present in the other filter files.

Recommend centralizing getFieldLabel (e.g. in @/hooks/use-filters) and importing it here instead of re-defining it locally, as outlined in the comment on filters-section.tsx.

apps/dashboard/app/(main)/websites/[id]/_components/filters/saved-filters-menu.tsx (1)

35-37: getFieldLabel duplicated again

Same helper as in filters-section.tsx and save-filter-dialog.tsx.

Please centralize this helper (e.g. in @/hooks/use-filters) and import it here, removing the local implementation.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e1e2541 and 43464fb.

📒 Files selected for processing (44)
  • apps/api/src/index.ts (1 hunks)
  • apps/api/src/lib/api-key.test.ts (1 hunks)
  • apps/api/src/lib/clickhouse-backup.ts (6 hunks)
  • apps/api/src/query/batch-executor.ts (1 hunks)
  • apps/api/src/query/builders/custom-events.ts (10 hunks)
  • apps/api/src/query/builders/engagement.ts (1 hunks)
  • apps/api/src/query/builders/performance.ts (2 hunks)
  • apps/api/src/query/builders/profiles.ts (5 hunks)
  • apps/api/src/query/builders/sessions.ts (3 hunks)
  • apps/api/src/query/expressions.ts (11 hunks)
  • apps/api/src/query/index.ts (2 hunks)
  • apps/api/src/query/screen-resolution-to-device-type.ts (2 hunks)
  • apps/api/src/query/simple-builder.ts (11 hunks)
  • apps/api/src/query/types.ts (2 hunks)
  • apps/api/src/query/utils.ts (7 hunks)
  • apps/api/src/routes/query.ts (8 hunks)
  • apps/api/src/schemas/query-schemas.ts (2 hunks)
  • apps/api/src/types/tables.ts (1 hunks)
  • apps/basket/src/lib/event-service.ts (4 hunks)
  • apps/basket/src/lib/producer.ts (2 hunks)
  • apps/dashboard/app/(auth)/layout.tsx (1 hunks)
  • apps/dashboard/app/(auth)/login/page.tsx (4 hunks)
  • apps/dashboard/app/(main)/billing/components/usage-row.tsx (1 hunks)
  • apps/dashboard/app/(main)/billing/cost-breakdown/components/consumption-chart.tsx (11 hunks)
  • apps/dashboard/app/(main)/billing/cost-breakdown/components/usage-breakdown-table.tsx (4 hunks)
  • apps/dashboard/app/(main)/billing/cost-breakdown/page.tsx (1 hunks)
  • apps/dashboard/app/(main)/billing/hooks/use-billing.ts (1 hunks)
  • apps/dashboard/app/(main)/billing/types/billing.ts (1 hunks)
  • apps/dashboard/app/(main)/billing/utils/feature-usage.ts (1 hunks)
  • apps/dashboard/app/(main)/organizations/components/general-settings.tsx (3 hunks)
  • apps/dashboard/app/(main)/organizations/invitations/invitation-list.tsx (2 hunks)
  • apps/dashboard/app/(main)/organizations/settings/danger/danger-zone-settings.tsx (8 hunks)
  • apps/dashboard/app/(main)/organizations/settings/danger/transfer-assets.tsx (6 hunks)
  • apps/dashboard/app/(main)/organizations/settings/websites/website-settings.tsx (5 hunks)
  • apps/dashboard/app/(main)/websites/[id]/_components/filters/add-filters.tsx (1 hunks)
  • apps/dashboard/app/(main)/websites/[id]/_components/filters/filters-section.tsx (1 hunks)
  • apps/dashboard/app/(main)/websites/[id]/_components/filters/save-filter-dialog.tsx (1 hunks)
  • apps/dashboard/app/(main)/websites/[id]/_components/filters/saved-filters-menu.tsx (1 hunks)
  • apps/dashboard/app/(main)/websites/[id]/_components/shared/tracking-components.tsx (8 hunks)
  • apps/dashboard/app/(main)/websites/[id]/_components/tabs/audience-tab.tsx (11 hunks)
  • apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview-tab.tsx (6 hunks)
  • apps/dashboard/app/(main)/websites/[id]/_components/tabs/performance-tab.tsx (3 hunks)
  • apps/dashboard/app/(main)/websites/[id]/_components/tabs/performance/_components/web-vitals-chart.tsx (3 hunks)
  • apps/dashboard/app/(main)/websites/[id]/_components/website-page-header.tsx (15 hunks)
🧰 Additional context used
🧬 Code graph analysis (26)
apps/dashboard/app/(main)/websites/[id]/_components/filters/add-filters.tsx (5)
packages/shared/src/lists/filters.ts (1)
  • filterOptions (1-12)
apps/dashboard/hooks/use-funnels.ts (2)
  • AutocompleteData (68-78)
  • useAutocompleteData (364-372)
apps/dashboard/lib/utils.ts (1)
  • cn (5-7)
packages/shared/src/types/api.ts (1)
  • DynamicQueryFilter (19-30)
apps/dashboard/hooks/use-filters.ts (1)
  • operatorOptions (4-10)
apps/dashboard/app/(main)/websites/[id]/_components/filters/filters-section.tsx (5)
packages/shared/src/lists/filters.ts (1)
  • filterOptions (1-12)
packages/shared/src/types/api.ts (1)
  • DynamicQueryFilter (19-30)
apps/dashboard/stores/jotai/filterAtoms.ts (2)
  • dynamicQueryFiltersAtom (284-284)
  • removeDynamicFilterAtom (315-329)
apps/dashboard/hooks/use-saved-filters.ts (1)
  • useSavedFilters (108-409)
apps/dashboard/hooks/use-filters.ts (1)
  • getOperatorLabel (33-35)
apps/dashboard/app/(main)/organizations/invitations/invitation-list.tsx (1)
packages/db/src/drizzle/schema.ts (1)
  • invitation (138-175)
apps/dashboard/app/(main)/websites/[id]/_components/tabs/performance-tab.tsx (1)
apps/dashboard/components/table/data-table.tsx (1)
  • DataTable (83-236)
apps/api/src/query/builders/sessions.ts (1)
apps/api/src/types/tables.ts (1)
  • Analytics (13-23)
apps/api/src/query/index.ts (2)
apps/api/src/query/types.ts (1)
  • QueryRequest (230-242)
apps/api/src/query/simple-builder.ts (1)
  • SimpleQueryBuilder (206-715)
apps/dashboard/app/(main)/websites/[id]/_components/shared/tracking-components.tsx (1)
apps/dashboard/app/(main)/websites/_components/notice-banner.tsx (1)
  • NoticeBanner (5-52)
apps/dashboard/app/(main)/organizations/settings/websites/website-settings.tsx (3)
apps/dashboard/app/(main)/organizations/components/empty-state.tsx (1)
  • EmptyState (17-62)
apps/dashboard/app/(main)/websites/[id]/_components/utils/ui-components.tsx (1)
  • EmptyState (169-185)
apps/dashboard/components/right-sidebar.tsx (1)
  • RightSidebar (16-27)
apps/api/src/query/builders/custom-events.ts (2)
apps/api/src/types/tables.ts (1)
  • Analytics (13-23)
apps/api/src/query/types.ts (2)
  • Filter (41-49)
  • TimeUnit (35-35)
apps/api/src/query/simple-builder.ts (1)
apps/api/src/query/screen-resolution-to-device-type.ts (1)
  • DeviceType (1-8)
apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview-tab.tsx (3)
apps/dashboard/hooks/use-chart-preferences.ts (1)
  • useChartPreferences (99-118)
apps/dashboard/app/(main)/websites/[id]/_components/tabs/performance/_utils/performance-utils.ts (1)
  • formatNumber (14-22)
apps/dashboard/lib/formatters.ts (1)
  • formatNumber (40-46)
apps/dashboard/app/(auth)/login/page.tsx (4)
apps/dashboard/components/ui/button.tsx (1)
  • Button (59-59)
apps/docs/components/ui/separator.tsx (1)
  • Separator (28-28)
apps/dashboard/components/ui/label.tsx (1)
  • Label (24-24)
apps/dashboard/components/ui/input.tsx (1)
  • Input (95-95)
apps/dashboard/app/(main)/billing/types/billing.ts (1)
apps/dashboard/app/(main)/billing/hooks/use-billing.ts (1)
  • CustomerWithPaymentMethod (21-21)
apps/dashboard/app/(main)/organizations/components/general-settings.tsx (3)
apps/dashboard/hooks/use-organizations.ts (1)
  • Organization (457-459)
apps/dashboard/app/(main)/organizations/components/organization-logo-uploader.tsx (1)
  • OrganizationLogoUploader (32-272)
apps/dashboard/components/right-sidebar.tsx (1)
  • RightSidebar (16-27)
apps/dashboard/app/(main)/websites/[id]/_components/filters/save-filter-dialog.tsx (3)
packages/shared/src/lists/filters.ts (1)
  • filterOptions (1-12)
packages/shared/src/types/api.ts (1)
  • DynamicQueryFilter (19-30)
apps/dashboard/hooks/use-filters.ts (1)
  • getOperatorLabel (33-35)
apps/dashboard/app/(main)/websites/[id]/_components/filters/saved-filters-menu.tsx (4)
apps/dashboard/hooks/use-saved-filters.ts (1)
  • SavedFilter (8-14)
packages/shared/src/types/api.ts (1)
  • DynamicQueryFilter (19-30)
packages/shared/src/lists/filters.ts (1)
  • filterOptions (1-12)
apps/dashboard/hooks/use-filters.ts (1)
  • getOperatorLabel (33-35)
apps/dashboard/app/(main)/websites/[id]/_components/tabs/audience-tab.tsx (5)
apps/dashboard/components/table/rows/geo-row.tsx (1)
  • createGeoColumns (29-98)
apps/dashboard/components/table/rows/timezone-row.tsx (1)
  • createTimezoneColumns (28-88)
apps/dashboard/components/table/rows/language-row.tsx (1)
  • createLanguageColumns (23-89)
apps/dashboard/components/table/data-table.tsx (1)
  • DataTable (83-236)
apps/dashboard/components/ui/skeleton.tsx (1)
  • Skeleton (13-13)
apps/dashboard/app/(main)/billing/hooks/use-billing.ts (1)
apps/dashboard/app/(main)/billing/utils/feature-usage.ts (1)
  • calculateFeatureUsage (56-96)
apps/dashboard/app/(main)/billing/utils/feature-usage.ts (1)
apps/dashboard/app/(main)/billing/cost-breakdown/utils/billing-utils.ts (1)
  • calculateOverageCost (9-25)
apps/api/src/query/utils.ts (2)
apps/api/src/query/types.ts (1)
  • SimpleQueryConfig (174-228)
apps/api/src/query/screen-resolution-to-device-type.ts (1)
  • mapScreenResolutionToDeviceType (102-107)
apps/dashboard/app/(main)/billing/cost-breakdown/page.tsx (3)
apps/dashboard/components/ui/skeleton.tsx (1)
  • Skeleton (13-13)
apps/dashboard/app/(main)/billing/cost-breakdown/components/consumption-chart.tsx (1)
  • ConsumptionChart (41-398)
apps/dashboard/app/(main)/billing/cost-breakdown/components/usage-breakdown-table.tsx (1)
  • UsageBreakdownTable (56-189)
apps/dashboard/app/(auth)/layout.tsx (1)
apps/dashboard/components/ui/button.tsx (1)
  • Button (59-59)
apps/api/src/types/tables.ts (1)
packages/db/src/clickhouse/schema.ts (9)
  • AnalyticsEvent (580-643)
  • ErrorSpanRow (439-451)
  • ErrorHourlyAggregate (456-466)
  • WebVitalsSpan (478-486)
  • WebVitalsHourlyAggregate (491-502)
  • CustomEventSpan (546-554)
  • CustomEventsHourlyAggregate (559-567)
  • BlockedTraffic (504-530)
  • CustomOutgoingLink (569-578)
apps/dashboard/app/(main)/billing/components/usage-row.tsx (2)
apps/dashboard/components/ui/badge.tsx (1)
  • Badge (54-54)
apps/docs/components/ui/badge.tsx (1)
  • Badge (50-50)
apps/api/src/lib/api-key.test.ts (2)
apps/api/src/lib/api-key.ts (13)
  • ApiKeyRow (7-7)
  • isApiKeyPresent (28-32)
  • extractSecret (34-46)
  • getEffectiveScopes (84-92)
  • hasKeyScope (94-103)
  • hasKeyAnyScope (105-114)
  • hasKeyAllScopes (116-125)
  • resolveEffectiveScopesForWebsite (127-132)
  • hasWebsiteScope (134-140)
  • hasWebsiteAnyScope (142-148)
  • hasWebsiteAllScopes (150-156)
  • hasGlobalAccess (158-164)
  • getAccessibleWebsiteIds (166-177)
packages/sdk/src/core/flags/browser-storage.ts (1)
  • isExpired (82-87)
apps/api/src/query/builders/profiles.ts (1)
apps/api/src/query/types.ts (2)
  • Filter (41-49)
  • TimeUnit (35-35)
🪛 ast-grep (0.40.0)
apps/dashboard/app/(main)/websites/[id]/_components/shared/tracking-components.tsx

[warning] 104-104: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html

(react-unsafe-html-injection)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Vercel Agent Review
  • GitHub Check: Cursor Bugbot

Comment on lines +178 to +184
// Record backup start
await clickHouseOG.command({
query: `
INSERT INTO internal.databuddy_backups (backup_name, backup_path, backup_type, base_backup_path, status)
VALUES ('${backupName}', '${s3Url}', '${isIncremental ? "incremental" : "full"}', '${lastBackupPath ?? ""}', 'started')
`,
});
});

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 | 🟠 Major

SQL injection risk: Use parameterized queries for INSERT statements.

While backupName and s3Url are derived from controlled sources, string interpolation in SQL is fragile. If lastBackupPath (retrieved from the database) were ever compromised, this could lead to SQL injection.

Consider using parameterized queries consistently, as done in getBackupMetadata.

-		await clickHouseOG.command({
-			query: `
-                INSERT INTO internal.databuddy_backups (backup_name, backup_path, backup_type, base_backup_path, status)
-                VALUES ('${backupName}', '${s3Url}', '${isIncremental ? "incremental" : "full"}', '${lastBackupPath ?? ""}', 'started')
-            `,
-		});
+		await clickHouseOG.query({
+			query: `
+                INSERT INTO internal.databuddy_backups (backup_name, backup_path, backup_type, base_backup_path, status)
+                VALUES ({backupName:String}, {s3Url:String}, {backupType:String}, {baseBackupPath:String}, 'started')
+            `,
+			query_params: {
+				backupName,
+				s3Url,
+				backupType: isIncremental ? "incremental" : "full",
+				baseBackupPath: lastBackupPath ?? "",
+			},
+		});

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In apps/api/src/lib/clickhouse-backup.ts around lines 178 to 184, the INSERT
uses string interpolation which risks SQL injection; change it to a
parameterized query like the pattern used in getBackupMetadata: prepare the
INSERT with placeholders for backup_name, backup_path, backup_type,
base_backup_path and status, pass [backupName, s3Url, isIncremental ?
"incremental" : "full", lastBackupPath ?? "", "started"] (or equivalent) as the
parameters array, and execute via clickHouseOG.command with the params instead
of embedding values directly.

Comment on lines +240 to +249
// Mark backup as failed
try {
await clickHouseOG.command({
query: `
ALTER TABLE internal.databuddy_backups
UPDATE status = 'failed', error = '${errorMessage.replace(/'/g, "\\'")}'
WHERE backup_name = '${backupName}'
`,
});
} catch { }

return {
success: false,
error: errorMessage,
timestamp: timestampStr,
};
}
});
} catch {}

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 | 🔴 Critical

SQL injection vulnerability in error message handling.

The error message escaping (errorMessage.replace(/'/g, "\\'")) is insufficient. Error messages can contain arbitrary content including SQL metacharacters beyond single quotes (e.g., backslashes, semicolons). A crafted error message could potentially break out of the string context.

Use parameterized queries or at minimum sanitize more thoroughly.

 		try {
-			await clickHouseOG.command({
-				query: `
+			await clickHouseOG.query({
+				query: `
                     ALTER TABLE internal.databuddy_backups 
-                    UPDATE status = 'failed', error = '${errorMessage.replace(/'/g, "\\'")}' 
-                    WHERE backup_name = '${backupName}'
+                    UPDATE status = 'failed', error = {errorMsg:String} 
+                    WHERE backup_name = {backupName:String}
                 `,
-			});
+				query_params: {
+					errorMsg: errorMessage,
+					backupName,
+				},
+			});
 		} catch {}
🤖 Prompt for AI Agents
In apps/api/src/lib/clickhouse-backup.ts around lines 240 to 249, the
backup-failed UPDATE builds SQL by interpolating errorMessage and backupName
(with only naive single-quote escaping) which creates an SQL injection risk;
replace this interpolation with a parameterized query (bind variables) so the
driver sends the error message and backup name as parameters instead of
embedding them in the SQL string, or if the client has no parameter support,
escape all SQL metacharacters robustly and use a proper library escaping
function; update the command call to use the parameter API (passing backupName
and errorMessage as bound parameters) and remove the manual replace logic.

};
}
});
} catch {}

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 | 🟡 Minor

Empty catch block silently swallows errors.

If updating the backup status to 'failed' itself fails, there's no logging or indication. This could leave the backup record in 'started' status indefinitely.

-		} catch {}
+		} catch (updateError) {
+			logger.warn({ updateError, backupName }, "Failed to update backup status to failed");
+		}
📝 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
} catch {}
try {
// ... update backup status to 'failed' ...
} catch (updateError) {
logger.warn({ updateError, backupName }, "Failed to update backup status to failed");
}
🤖 Prompt for AI Agents
In apps/api/src/lib/clickhouse-backup.ts around line 249 the empty catch block
swallows errors when attempting to update the backup status to 'failed'; change
it to capture the caught error and log it (including backup id/context and the
error stack/message) using the module's logger (or console.error if no logger
available), and avoid silently swallowing the exception — either rethrow after
logging or ensure the failure is recorded via a secondary error record so the
backup isn't left stuck in 'started' state.

Comment on lines +327 to +357
const result = await clickHouseOG.query({
query: `
SELECT backup_name, backup_path
FROM internal.databuddy_backups
WHERE timestamp < now() - INTERVAL ${retentionDays} DAY
AND status = 'completed'
ORDER BY timestamp ASC
`,
});
});

const oldBackups = await result.json<{
backup_name: string;
backup_path: string;
}>();
const oldBackups = await result.json<{
backup_name: string;
backup_path: string;
}>();

if (oldBackups.data.length === 0) {
logger.info("No backups to clean up");
return { success: true, deletedCount: 0 };
}
if (oldBackups.data.length === 0) {
logger.info("No backups to clean up");
return { success: true, deletedCount: 0 };
}

logger.info(
`Found ${oldBackups.data.length} backups to clean up (tracking table only, manual R2 cleanup required)`
);
logger.info(
`Found ${oldBackups.data.length} backups to clean up (tracking table only, manual R2 cleanup required)`
);

// Delete from tracking table
await clickHouseOG.command({
query: `
// Delete from tracking table
await clickHouseOG.command({
query: `
DELETE FROM internal.databuddy_backups
WHERE timestamp < now() - INTERVAL ${retentionDays} DAY
`,
});

logger.info(
`Cleaned up ${oldBackups.data.length} old backups from tracking table`
);

return {
success: true,
deletedCount: oldBackups.data.length,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error({ error }, `Backup cleanup failed: ${errorMessage}`);

return {
success: false,
deletedCount: 0,
error: errorMessage,
};
}
});

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.

🧹 Nitpick | 🔵 Trivial

Validate retentionDays parameter to prevent unexpected SQL behavior.

While retentionDays has a default of 30 and TypeScript types it as number, JavaScript runtime doesn't enforce this. If a non-numeric value is passed, the generated SQL could be malformed or exploitable.

 export async function cleanupOldBackups(retentionDays = 30): Promise<{
 	success: boolean;
 	deletedCount: number;
 	error?: string;
 }> {
+	// Validate and sanitize retentionDays
+	const days = Math.max(1, Math.min(365, Math.floor(Number(retentionDays) || 30)));
+
 	try {
-		logger.info(`Starting backup cleanup (retention: ${retentionDays} days)`);
+		logger.info(`Starting backup cleanup (retention: ${days} days)`);

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In apps/api/src/lib/clickhouse-backup.ts around lines 327 to 357, the
retentionDays value is interpolated directly into SQL which can produce
malformed or unsafe queries if a non-numeric value is passed; validate and
sanitize retentionDays before building the query by coercing it to a safe
integer (e.g., const days = Number(retentionDays); if (!Number.isFinite(days) ||
Number.isNaN(days) || !Number.isInteger(days) || days <= 0) throw new
Error('Invalid retentionDays');) or use parseInt with similar checks, then use
that sanitized integer in the template; alternatively default to 30 when
validation fails and log the correction — do not interpolate unchecked user
input into the SQL.

Comment on lines +405 to 417
const database = options.database ?? "analytics";
const restoreTarget = options.tables
? `TABLE ${options.tables.map((t) => `${database}.${t}`).join(", ")}`
: `DATABASE ${database}`;

const restoreQuery = `
RESTORE ${restoreTarget}
FROM S3('${backupPath}', '${env.R2_ACCESS_KEY_ID}', '${env.R2_SECRET_ACCESS_KEY}')
SETTINGS
async = false,
s3_max_redirects = 10
s3_max_redirects = 10,
s3_use_virtual_addressing = false
`;

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 | 🔴 Critical

SQL injection vulnerability: backupPath, database, and tables are directly interpolated.

The backupPath parameter and optional tables/database values are interpolated directly into the SQL query without validation. An attacker could inject malicious SQL through these parameters.

Add input validation for all parameters:

 export async function restoreBackup(
 	backupPath: string,
 	options: {
 		tables?: string[];
 		database?: string;
 	} = {}
 ): Promise<BackupResult> {
 	const timestamp = new Date().toISOString();
+
+	// Validate backupPath format (should be an S3 URL)
+	if (!/^https?:\/\/[^'"\s]+$/.test(backupPath)) {
+		return {
+			success: false,
+			error: "Invalid backup path format",
+			timestamp,
+		};
+	}
+
+	// Validate database and table names (alphanumeric and underscores only)
+	const identifierRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
+	const database = options.database ?? "analytics";
+	if (!identifierRegex.test(database)) {
+		return {
+			success: false,
+			error: "Invalid database name",
+			timestamp,
+		};
+	}
+	if (options.tables?.some((t) => !identifierRegex.test(t))) {
+		return {
+			success: false,
+			error: "Invalid table name",
+			timestamp,
+		};
+	}

Committable suggestion skipped: line range outside the PR's diff.

Comment on lines +39 to +53
function filtersMatch(
a: DynamicQueryFilter[],
b: DynamicQueryFilter[]
): boolean {
if (a.length !== b.length) return false;
return a.every((f1, i) => {
const f2 = b[i];
return (
f2 &&
f1.field === f2.field &&
f1.operator === f2.operator &&
JSON.stringify(f1.value) === JSON.stringify(f2.value)
);
});
}

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 | 🟡 Minor

filtersMatch is order-sensitive and relies on JSON.stringify for value comparison

filtersMatch requires arrays to match in order and compares value via JSON.stringify. This is fine if:

  • filter application always preserves order, and
  • you don’t expect type-coercion equivalence (e.g. "1" vs 1) to count as equal.

If order shouldn’t matter, or you want a stricter structural comparison, consider normalizing (e.g. sorting by field/operator/value) before comparison instead of comparing by index. Otherwise, a comment explaining the order dependency would help future readers.

🤖 Prompt for AI Agents
In
apps/dashboard/app/(main)/websites/[id]/_components/filters/saved-filters-menu.tsx
around lines 39-53, filtersMatch is order-sensitive and uses JSON.stringify to
compare values; change it to normalize both arrays before comparing by sorting
their filters deterministically (e.g., by field, then operator, then a stable
string/serialized representation of value) and then compare lengths and
corresponding items using strict equality for field/operator and a deep-equality
check for value (or a stable JSON serialization) so order no longer matters and
value comparisons are robust.

Comment on lines +90 to +103
<div className="flex items-center justify-between px-2 py-1.5">
<span className="font-medium text-xs">Saved Filters</span>
<Button
className="h-6 text-xs"
onClick={(e) => {
e.preventDefault();
onDeleteAll();
}}
size="sm"
variant="ghost"
>
Clear all
</Button>
</div>

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.

🧹 Nitpick | 🔵 Trivial

Optional: close menu when “Clear all” is clicked

The “Clear all” button calls onDeleteAll but leaves the dropdown open. Given this action usually opens a confirmation dialog, you might want to close the menu for a cleaner UX.

onClick={(e) => {
  e.preventDefault();
- onDeleteAll();
+ onDeleteAll();
+ setOpen(false);
}}
📝 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
<div className="flex items-center justify-between px-2 py-1.5">
<span className="font-medium text-xs">Saved Filters</span>
<Button
className="h-6 text-xs"
onClick={(e) => {
e.preventDefault();
onDeleteAll();
}}
size="sm"
variant="ghost"
>
Clear all
</Button>
</div>
<div className="flex items-center justify-between px-2 py-1.5">
<span className="font-medium text-xs">Saved Filters</span>
<Button
className="h-6 text-xs"
onClick={(e) => {
e.preventDefault();
onDeleteAll();
setOpen(false);
}}
size="sm"
variant="ghost"
>
Clear all
</Button>
</div>
🤖 Prompt for AI Agents
In
apps/dashboard/app/(main)/websites/[id]/_components/filters/saved-filters-menu.tsx
around lines 90 to 103, the “Clear all” button invokes onDeleteAll but leaves
the dropdown open; update the click handler to also close the menu (call the
provided onClose prop or the menu state setter) after invoking onDeleteAll — if
onDeleteAll returns a Promise await it first and then call onClose to ensure any
confirmation completes before closing; keep the preventDefault and preserve
button props.

createPageTimeColumns,
createReferrerColumns,
} from "@/components/table/rows";
import { useChartPreferences } from "@/hooks/use-chart-preferences";

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.

🧹 Nitpick | 🔵 Trivial

useChartPreferences integration into StatCard looks solid

Importing useChartPreferences("overview-stats") and passing chartType/chartStepType through to all StatCard instances is consistent and keeps mini‑charts driven by user prefs. If you want the main “Traffic Trends” chart to respect the same preferences, consider threading these props into MetricsChartWithAnnotations as a follow‑up.

Also applies to: 125-125, 872-876

🤖 Prompt for AI Agents
apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview-tab.tsx lines
31, 125, and 872-876: you already import/use useChartPreferences at line 31 for
StatCard — also wire the returned chartType and chartStepType into the main
“Traffic Trends” chart by passing them as props to the
MetricsChartWithAnnotations instance(s) rendered around lines 125 and 872-876;
if MetricsChartWithAnnotations doesn’t accept those props yet, update its prop
types/signature to accept chartType and chartStepType and forward them to the
internal chart component so the main chart respects the same user preferences as
the StatCard mini-charts.

Comment on lines +64 to +71
const showSubtitleSkeleton = isLoading && !description;

if (showSubtitleSkeleton) {
return (
<div className="h-5 sm:h-6">
<Skeleton className="h-4 w-48" />
</div>
);

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.

🧹 Nitpick | 🔵 Trivial

Subtitle skeleton and truncation behavior are coherent; double‑check loading semantics

The showSubtitleSkeleton = isLoading && !description guard plus the fixed-height wrappers and truncation classes give you stable layout and good line clamping. Just be aware that while isLoading is true and description is falsy, any provided subtitle (string or node) will be hidden in favor of the skeleton; ensure that matches the intended UX for callers that might pass a static subtitle while data loads.

Also applies to: 76-81, 86-87

🤖 Prompt for AI Agents
In apps/dashboard/app/(main)/websites/[id]/_components/website-page-header.tsx
around lines 64-71 (and similarly at 76-81, 86-87), the current guard
showSubtitleSkeleton = isLoading && !description will hide any passed subtitle
while loading; update the condition and rendering so the skeleton only replaces
content when there is no explicit subtitle provided (e.g., render skeleton when
isLoading && !description && !subtitle or prefer showing a static subtitle prop
even during loading), and add a short comment documenting the chosen UX decision
so callers know whether passing a static subtitle should override the loading
skeleton.

Comment on lines +154 to +155
<div className="space-y-4">
<div className="border-b p-3 sm:p-4">

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 | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

wc -l apps/dashboard/app/\(main\)/websites/\[id\]/_components/website-page-header.tsx

Repository: databuddy-analytics/Databuddy

Length of output: 152


🏁 Script executed:

cat -n apps/dashboard/app/\(main\)/websites/\[id\]/_components/website-page-header.tsx | sed -n '140,260p'

Repository: databuddy-analytics/Databuddy

Length of output: 4286


Add disabled={isRefreshing} to the error card retry button and optionally add aria-hidden to the error icon

The header refresh button correctly uses disabled={isRefreshing} (line 203), but the retry button in the error card (line 244) lacks this flag, which could allow concurrent invocations if both are visible. Additionally, the header icon has aria-hidden="true" (line 174), but the error card icon (line 230) is rendered without it—consider adding the same treatment if it's meant to be decorative.

🤖 Prompt for AI Agents
In apps/dashboard/app/(main)/websites/[id]/_components/website-page-header.tsx
around lines 154-155 and referencing the error card area (lines ~230-244), the
retry button in the error card is missing disabled={isRefreshing} (allowing
concurrent invocations when the header refresh button already disables during
refresh) and the error card icon lacks aria-hidden="true" (inconsistent with the
decorative header icon). Update the retry button JSX to pass
disabled={isRefreshing} and add aria-hidden="true" to the error card icon
element so both controls behave/accessibly consistent with the header.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 11

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (5)
apps/basket/src/lib/event-service.ts (1)

579-586: Remove duplicate and contradictory JSDoc comments.

Two consecutive JSDoc blocks describe the same function with contradictory implementations:

  • First block (lines 579–581): "Transforms and groups metrics before storing"
  • Second block (lines 583–586): "Each metric is stored as a separate row - no aggregation"

The function code creates individual span rows without grouping, so the first description is stale. Remove the first comment block and keep only the second accurate one.

-/**
- * Insert individual vital metrics (v2.x format)
- * Transforms and groups metrics before storing
- */
/**
 * Insert individual vital metrics (v2.x format) as spans
 * Each metric is stored as a separate row - no aggregation
 */
apps/dashboard/app/(main)/websites/[id]/_components/shared/tracking-components.tsx (2)

63-66: XSS vulnerability in error fallback.

The fallback path directly interpolates code into HTML without escaping. If codeToHtml fails and code contains malicious content like <script>alert('xss')</script>, it will be rendered as executable HTML.

Apply this diff to escape the code content in the fallback:

 			} catch (error) {
 				console.error("Error highlighting code:", error);
-				setHighlightedCode(`<pre><code>${code}</code></pre>`);
+				const escaped = code
+					.replace(/&/g, "&amp;")
+					.replace(/</g, "&lt;")
+					.replace(/>/g, "&gt;");
+				setHighlightedCode(`<pre><code>${escaped}</code></pre>`);
 			}

205-216: Bug: Double toggle on Switch click.

Clicking the Switch will trigger both its onCheckedChange and the parent div's onClick, causing the option to toggle twice (effectively no change).

Stop event propagation when the switch is clicked:

-			<Switch checked={isEnabled} onCheckedChange={onToggle} />
+			<Switch
+				checked={isEnabled}
+				onCheckedChange={onToggle}
+				onClick={(e) => e.stopPropagation()}
+			/>
apps/dashboard/app/(main)/billing/cost-breakdown/components/usage-breakdown-table.tsx (1)

173-186: Unreachable code block.

This fallback is dead code. When !usageData?.eventTypeBreakdown?.length is true, the function returns early at lines 83–91. Otherwise, sortedBreakdown is derived from eventTypeBreakdown and will never be empty at this point.

Remove the unreachable block:

-			{sortedBreakdown.length === 0 && (
-				<div className="py-12 text-center">
-					<TableIcon
-						className="mx-auto mb-4 size-12 text-muted-foreground"
-						weight="duotone"
-					/>
-					<h3 className="font-semibold text-foreground text-lg">
-						No Event Data
-					</h3>
-					<p className="mt-1 text-muted-foreground text-sm">
-						No events found for the selected period
-					</p>
-				</div>
-			)}
apps/api/src/lib/clickhouse-backup.ts (1)

218-228: Consider using parameterized query for consistency.

While backupName is internally generated and numeric values are safe, using parameterized queries (like in getBackupMetadata) would be more consistent and robust against future refactoring.

♻️ Duplicate comments (46)
apps/api/src/query/batch-executor.ts (1)

123-136: Unresolved: Silent data loss in splitResults (flagged in past review).

A prior review flagged that line 132 silently discards rows with a __query_idx not present in the indices map. This issue remains unresolved in the current diff—the optional chaining byIndex.get(__query_idx)?.push(rest) will still drop unexpected indices without warning or error.

While this should not occur if the union query is correctly constructed, any data flow mismatch (e.g., a builder bug, schema drift, or edge case) would cause undetected data loss.

Please confirm:

  1. Whether this was intentionally deferred and is tracked as a separate issue.
  2. Or, if you'd like me to propose a fix (e.g., warn on unexpected indices or throw an error for early detection).

If this is critical for data integrity, consider adding validation:

 for (const { __query_idx, ...rest } of rows) {
-  byIndex.get(__query_idx)?.push(rest);
+  const bucket = byIndex.get(__query_idx);
+  if (bucket) {
+    bucket.push(rest);
+  } else {
+    console.warn(`Unexpected __query_idx: ${__query_idx}`, rest);
+  }
 }
apps/dashboard/app/(main)/organizations/settings/danger/transfer-assets.tsx (2)

171-171: Spacing inconsistency resolved.

The past review comment flagging inconsistent spacing between Personal (space-y-2) and Organization (space-y-1) lists has been addressed. Both now use space-y-2.


227-227: Spacing now consistent.

Organization list spacing now matches Personal list spacing (space-y-2), completing the fix for the previously flagged inconsistency.

apps/dashboard/app/(main)/organizations/invitations/invitation-list.tsx (2)

23-26: Derive InvitationToCancel from Invitation to avoid drift

Rather than duplicating id/email here, you can tie this helper type directly to Invitation so it stays in sync if the source changes:

-type InvitationToCancel = {
-	id: string;
-	email: string;
-};
+type InvitationToCancel = Pick<Invitation, "id" | "email">;

43-60: Strongly type statusConfig by invitation status and avoid as assertion

You can key statusConfig on Invitation["status"] to remove the type assertion and get compile‑time coverage if new statuses are added:

-	const statusConfig = {
+	const statusConfig: Record<
+		Invitation["status"],
+		{ label: string; className: string }
+	> = {
 		pending: {
 			label: "Pending",
 			className: "border-amber-500/20 bg-amber-500/10 text-amber-600",
 		},
 		accepted: {
 			label: "Accepted",
 			className: "border-green-500/20 bg-green-500/10 text-green-600",
 		},
 		expired: {
 			label: "Expired",
 			className: "border-muted bg-muted text-muted-foreground",
 		},
 	};
 
-	const status =
-		statusConfig[invitation.status as keyof typeof statusConfig] ??
-		statusConfig.expired;
+	const status =
+		statusConfig[invitation.status] ?? statusConfig.expired;

If the backend can emit additional statuses (e.g. cancelled/revoked), consider adding explicit entries here and adjusting the "Expires"/"Expired" subtitle (lines 71–74) so badges and copy stay consistent.

Also applies to: 71-74

apps/dashboard/app/(main)/billing/cost-breakdown/page.tsx (1)

76-92: Suspense boundaries are ineffective with useQuery.

This was flagged in a previous review. Since data fetching uses useQuery (not useSuspenseQuery) and both child components handle their own loading states via isLoading prop, these Suspense boundaries will never trigger their fallbacks.

Either:

  1. Remove the Suspense wrappers (simpler, since internal loading states are sufficient), or
  2. Refactor to use useSuspenseQuery within the child components
apps/dashboard/app/(main)/organizations/settings/danger/danger-zone-settings.tsx (1)

158-158: Inconsistent icon spacing (still unresolved).

As noted in a previous review, the SignOutIcon is missing the mr-2 className that the TrashIcon (line 149) has, resulting in inconsistent button layouts.

Apply this diff:

-								<SignOutIcon size={14} />
+								<SignOutIcon className="mr-2" size={14} />
apps/dashboard/app/(main)/billing/utils/feature-usage.ts (1)

98-109: Resolve: formatCompactNumber still doesn't handle Infinity input.

This issue was flagged in a previous review and remains unresolved. If Number.POSITIVE_INFINITY is passed to this function (which can occur when calculateFeatureUsage sets limit to POSITIVE_INFINITY for unlimited features), the function will return "InfinityB" instead of a user-friendly representation.

Apply this diff to add an early guard:

 export function formatCompactNumber(num: number): string {
+	if (!Number.isFinite(num)) {
+		return "∞";
+	}
 	if (num >= 1_000_000_000) {
 		return `${(num / 1_000_000_000).toFixed(1)}B`;
 	}
apps/dashboard/app/(auth)/login/page.tsx (1)

157-170: The disabled state does not prevent Link navigation.

When using asChild, the Button component merges its props with the Link child. However, the disabled attribute is not valid on <a> elements, so while the button may appear disabled visually, the Link remains fully clickable and keyboard-navigable during the loading state.

Apply this diff to fix the issue by removing the Link and using router navigation:

-							<div className="relative lg:col-span-2">
-								<Button
-									asChild
-									className="w-full"
-									disabled={isLoading}
-									size="lg"
-									type="button"
-									variant="secondary"
-								>
-									<Link href="/login/magic">
-										<SparkleIcon className="size-4" />
-										Sign in with Magic Link
-									</Link>
-								</Button>
+							<div className="relative lg:col-span-2">
+								<Button
+									className="w-full"
+									disabled={isLoading}
+									onClick={() => !isLoading && router.push('/login/magic')}
+									size="lg"
+									type="button"
+									variant="secondary"
+								>
+									<SparkleIcon className="size-4" />
+									Sign in with Magic Link
+								</Button>
 								{lastUsed === "magic" && (
apps/dashboard/app/(main)/websites/[id]/_components/filters/saved-filters-menu.tsx (3)

39-53: filtersMatch is order-sensitive.

This comparison requires arrays to match in exact order. If filter application doesn't guarantee order preservation, consider normalizing (sorting by field/operator/value) before comparison.


35-37: Duplicate getFieldLabel helper across filter components.

This helper is duplicated in saved-filters-menu.tsx, filters-section.tsx, and save-filter-dialog.tsx. Consider extracting to a shared utility alongside getOperatorLabel in @/hooks/use-filters.


90-103: Consider closing menu when "Clear all" is clicked.

The "Clear all" button triggers a confirmation dialog via onDeleteAll() but leaves the dropdown open. Closing the menu would provide cleaner UX.

apps/dashboard/app/(main)/websites/[id]/_components/filters/filters-section.tsx (4)

63-71: Removing a filter by value removes all identical entries.

handleRemoveFilter looks up the filter by index but removeFilter(filter) removes all entries matching that (field, operator, value) triple. If duplicate filters are possible, consider exposing a remove-by-index atom instead.


75-91: Saved filter mutations treated as synchronous.

saveFilter, updateFilter, etc. are used as if they return synchronous { success: boolean }. If these become async, isSaving won't correctly track in-flight state. Consider using async/await with try/finally to properly manage loading states.


112-119: Delete handlers also assume synchronous mutations.

Same concern applies to handleConfirmDelete and handleConfirmDeleteAll. The setIsDeleting(false) calls happen immediately, not after the mutation completes.

Also applies to: 160-165


20-22: Duplicate getFieldLabel helper.

Same helper exists in saved-filters-menu.tsx and save-filter-dialog.tsx. Extract to a shared utility.

apps/dashboard/app/(main)/websites/[id]/_components/filters/add-filters.tsx (4)

57-61: Whitespace-only values can bypass validation.

The schema allows whitespace-only strings like " " to pass .min(1), but onSubmit trims before sending. Apply .trim() at the schema level to validate the trimmed value:

 const filterFormSchema = z.object({
   field: z.string().min(1, "Please select a field"),
   operator: z.enum(["eq", "ne", "contains", "not_contains", "starts_with"]),
-  value: z.string().min(1, "Value is required"),
+  value: z.string().trim().min(1, "Value is required"),
 });

Then remove the .trim() call in onSubmit (line 181).


87-132: Duplicate suggestions may cause React key warnings.

If autocompleteData contains duplicates, buttons will have identical key={suggestion} values. Deduplicate before rendering:

+const uniqueSuggestions = Array.from(new Set(suggestions));
 const filteredSuggestions = searchValue.trim()
-  ? suggestions
+  ? uniqueSuggestions
       .filter((s) => s.toLowerCase().includes(searchValue.toLowerCase()))
       .slice(0, MAX_SUGGESTIONS)
-  : suggestions.slice(0, MAX_SUGGESTIONS);
+  : uniqueSuggestions.slice(0, MAX_SUGGESTIONS);

188-219: Error state blocks filter creation entirely.

When isError is true, users cannot add filters at all. Consider falling back to the normal flow with empty suggestions and a non-blocking warning banner, so users can still create filters manually.


307-352: Nested FormField structure is unconventional.

The operator FormField is nested inside the value FormField's render prop. While functional, consider moving the operator field to a sibling position for clearer component structure.

apps/dashboard/app/(main)/organizations/settings/websites/website-settings.tsx (3)

110-133: LGTM!

Clean early return pattern for loading and error states. The isEmpty variable improves readability.

Note: A previous review suggested extracting the inline props type to a shared interface if reused elsewhere.


141-146: Verify EmptyState icon prop type.

The external EmptyState component destructures icon: Icon and renders it as <Icon className="..." size={48} weight="duotone" />, which expects a component reference. However, the code passes a JSX element <GlobeIcon weight="duotone" />.

If the imported @/components/empty-state has the same signature, this would cause a runtime error. Verify the expected prop type matches the usage.

Additionally, variant="minimal" was flagged in a previous review as potentially invalid—please confirm the component supports this variant.

#!/bin/bash
# Verify EmptyState component signature and variant support
cat $(fd -t f 'empty-state.tsx' apps/dashboard/components --max-depth 1) 2>/dev/null || echo "File not found in expected location"

# Check if there's an EmptyStateProps type that clarifies icon type
rg -A 10 "interface EmptyStateProps" apps/dashboard/components/

156-171: LGTM!

Good use of RightSidebar compound components and conditional rendering.

Note: A previous review suggested extracting the inline pluralization logic on line 164 to a reusable helper.

apps/dashboard/app/(main)/websites/[id]/_components/filters/save-filter-dialog.tsx (3)

28-36: Normalize and validate the name on the trimmed value via Zod .trim()

Right now the schema validates the raw string length, but onSubmit trims before using the value. This means whitespace‑padded (or whitespace‑only) names can appear valid to the form (isValid) while submitting a shorter trimmed value than the min length implies.

You can push normalization into the schema and keep the rest of the code simpler:

-const formSchema = z.object({
-	name: z
-		.string()
-		.min(2, "Name must be at least 2 characters")
-		.max(100, "Name is too long"),
-});
+const formSchema = z.object({
+	name: z
+		.string()
+		.trim()
+		.min(2, "Name must be at least 2 characters")
+		.max(100, "Name is too long"),
+});

Then data.name in onSubmit is already trimmed, so the extra .trim() becomes unnecessary (can be removed or left as a no‑op):

-const onSubmit = (data: FormData) => {
-	const trimmed = data.name.trim();
+const onSubmit = (data: FormData) => {
+	const trimmed = data.name;

Also applies to: 85-87


37-39: Extract getFieldLabel into a shared helper to avoid duplication

This helper is (per earlier reviews) duplicated in other filter‑related components. Consider moving it to a shared utility (e.g., a small filterHelpers.ts in a common UI or hooks folder) and importing it here and in the other call sites:

// shared
export function getFieldLabel(
	field: string,
	options = filterOptions,
): string {
	return options.find((o) => o.value === field)?.label ?? field;
}

Then in this file you can import getFieldLabel instead of redefining it.


80-83: Handle Dialog’s onOpenChange boolean instead of always treating it as “close”

handleClose ignores the open boolean passed by the Dialog’s onOpenChange, so any future attempt to open the dialog via that callback would immediately trigger a reset/close as well. Safer to only run the close/reset path when open === false:

-const handleClose = () => {
-	form.reset();
-	onClose();
-};
+const handleClose = () => {
+	form.reset();
+	onClose();
+};
...
-		<Dialog onOpenChange={handleClose} open={isOpen}>
+		<Dialog
+			open={isOpen}
+			onOpenChange={(open) => {
+				if (!open) {
+					handleClose();
+				}
+			}}
+		>

Cancel can still call handleClose directly, but Dialog’s internal triggers and overlay/ESC behavior will now distinguish between open and close events correctly.

Also applies to: 101-103

apps/api/src/lib/clickhouse-backup.ts (4)

178-184: SQL injection risk persists in INSERT statement.

This issue was previously flagged. While the values are internally generated, using parameterized queries (as demonstrated in getBackupMetadata) would be more robust and consistent.


240-249: SQL injection and silent error handling issues persist.

These issues were previously flagged:

  1. The error message escaping is insufficient for SQL safety
  2. The empty catch block silently swallows failures

Apply the parameterized query pattern and add error logging as suggested in prior reviews.


327-357: retentionDays validation issue persists.

This was previously flagged. The parameter is interpolated directly into SQL without validation.


405-417: SQL injection risks persist; s3_use_virtual_addressing addition is good.

The input validation concerns for backupPath, database, and tables were previously flagged and remain unaddressed. The addition of s3_use_virtual_addressing = false is appropriate for path-style S3 URLs.

apps/dashboard/app/(auth)/layout.tsx (1)

33-41: Fix Tailwind !important syntax and avoid nesting Button inside Link.

  • px-0! is invalid Tailwind syntax; it should be !px-0 (or drop the ! if not needed).
  • Also, wrapping a Button (likely rendering a <button>) directly inside Link (which renders an <a>) produces invalid <a><button>…</button></a> markup. Prefer styling the Link as a button, or using a Button that renders the Link as its child.

Example refactor if your Button supports asChild:

-				<Link className="relative z-10" href="https://www.databuddy.cc">
-					<Button
-						className="group px-0! text-white/50 hover:bg-transparent hover:text-white/80"
-						variant="ghost"
-					>
-						<CaretLeftIcon className="size-4 transition-transform duration-200 group-hover:translate-x-[-4px]" />
-						Back
-					</Button>
-				</Link>
+				<Button
+					asChild
+					className="relative z-10 group !px-0 text-white/50 hover:bg-transparent hover:text-white/80"
+					variant="ghost"
+				>
+					<Link href="https://www.databuddy.cc">
+						<CaretLeftIcon className="size-4 transition-transform duration-200 group-hover:translate-x-[-4px]" />
+						Back
+					</Link>
+				</Button>
apps/dashboard/app/(main)/organizations/components/general-settings.tsx (2)

19-19: Normalize nullable organization.slug to a string and trim once to avoid runtime errors and stale “unsaved changes”.

organization.slug is nullable at the DB level; if it comes through as null, initializing state with it and later calling slug.trim() will throw at runtime, and passing null to <Input value> is also problematic. You can also simplify change detection by normalizing once:

- const [slug, setSlug] = useState(organization.slug);
+ const [slug, setSlug] = useState(organization.slug ?? "");

- const hasChanges = name !== organization.name || slug !== organization.slug;
+ const normalizedOrgSlug = organization.slug ?? "";
+ const hasChanges = name !== organization.name || slug !== normalizedOrgSlug;

 const handleSave = async () => {
-   if (!name.trim()) {
+   const trimmedName = name.trim();
+   const trimmedSlug = slug.trim();
+
+   if (!trimmedName) {
      toast.error("Name is required");
      return;
    }
-   if (!slug.trim()) {
+   if (!trimmedSlug) {
      toast.error("Slug is required");
      return;
    }
@@
-      await updateOrganizationAsync({
-        organizationId: organization.id,
-        data: { name: name.trim(), slug: slug.trim() },
-      });
+      await updateOrganizationAsync({
+        organizationId: organization.id,
+        data: { name: trimmedName, slug: trimmedSlug },
+      });
+      // Keep local state in sync with what we actually saved
+      setName(trimmedName);
+      setSlug(trimmedSlug);

This guarantees slug is always a string, prevents .trim() from ever running on null, keeps the Input happy, and ensures the “unsaved changes” footer clears once the server-provided organization prop reflects the saved values.

Also applies to: 35-35, 37-45, 47-52, 104-105


107-110: Avoid /null URLs in previews and prefer the cleaned slug state for sidebar/URL display.

Using organization.slug directly can render /null for legacy data and doesn’t reflect in‑progress edits; you already have the cleaned slug state:

- <p className="text-muted-foreground text-xs">
-   Used in URLs:{" "}
-   <code className="rounded bg-muted px-1 py-0.5 text-xs">
-     /{slug}
-   </code>
- </p>
+ <p className="text-muted-foreground text-xs">
+   Used in URLs:{" "}
+   <code className="rounded bg-muted px-1 py-0.5 text-xs">
+     /{slug || ""}
+   </code>
+ </p>
@@
- <RightSidebar.InfoCard
-   description={`/${organization.slug}`}
-   icon={BuildingsIcon}
-   title={organization.name}
- />
+ const effectiveSlug = slug || organization.slug || "";
+
+ <RightSidebar.InfoCard
+   description={effectiveSlug ? `/${effectiveSlug}` : "/"}
+   icon={BuildingsIcon}
+   title={organization.name}
+ />

This prevents /null from ever showing and makes the sidebar URL preview track the currently edited, cleaned slug.

Also applies to: 141-149

apps/dashboard/app/(main)/websites/[id]/_components/website-page-header.tsx (2)

229-230: Add aria-hidden to the error card icon for consistency.

The header icon at line 174 includes aria-hidden="true", but the error card icon at line 230 doesn't have this attribute. For consistency with decorative icons, add the same treatment.

This was flagged in a previous review.

 <div className="rounded-full border border-destructive/10 bg-destructive/5 p-3">
-  {icon}
+  {cloneElement(icon, {
+    ...icon.props,
+    "aria-hidden": "true",
+  })}
 </div>

241-250: Add disabled={isRefreshing} to the error card retry button.

The header refresh button at line 203 correctly uses disabled={isRefreshing}, but the retry button in the error card lacks this flag. This could allow concurrent refresh invocations if both buttons are visible.

This was flagged in a previous review.

 <Button
   className="cursor-pointer select-none gap-2 rounded transition-all duration-300 hover:border-primary/20 hover:bg-primary/10"
+  disabled={isRefreshing}
   onClick={onRefreshAction}
   size="sm"
   variant="outline"
 >
+  <ArrowClockwiseIcon className={isRefreshing ? "animate-spin" : ""} size={16} />
-  <ArrowClockwiseIcon className="h-4 w-4" size={16} />
   Retry
 </Button>
apps/api/src/query/expressions.ts (3)

742-748: The source encoding convention for quantile remains fragile.

The pattern of encoding source as "level)(column" (e.g., "0.50)(metric_value") to produce correct SQL is non-obvious and error-prone. This was flagged in a previous review.

Consider extending FieldDefinitionType to support a dedicated level field for quantile aggregates, or add validation to detect malformed quantile sources.


781-783: Quantile fallback uses invalid ClickHouse syntax.

The fallback quantile(0.50)(*) on line 783 is invalid. ClickHouse quantile functions require a specific numeric column expression—* is not valid. Either provide a sensible default column or throw an error when source is missing.

This issue was flagged in a previous review and remains unaddressed.

-		return source ? `quantile(${source})` : "quantile(0.50)(*)";
+		if (!source) {
+			throw new Error("quantile aggregate requires a source column");
+		}
+		return `quantile(${source})`;

799-800: Same invalid quantile fallback issue for quantileIf.

Line 800 has the same invalid quantile(0.50)(*) fallback when source is missing.

-		return source ? `quantile(${source})` : "quantile(0.50)(*)";
+		if (!source) {
+			throw new Error("quantileIf aggregate requires a source column");
+		}
+		return `quantile(${source})`;
apps/api/src/query/builders/profiles.ts (1)

213-213: LGTM for this line, but inconsistency remains in other queries.

The updated calculation correctly filters unique pages to only count screen_view events. However, as noted in a previous review, the same metric is calculated differently at:

  • Line 30 (visitor_profiles CTE): COUNT(DISTINCT path) — no filter
  • Line 56 (visitor_sessions CTE): COUNT(DISTINCT e.path) — no filter

This inconsistency will cause different values for the same conceptual metric across API responses.

Apply these fixes for consistency:

Line 30:

-        COUNT(DISTINCT path) as unique_pages,
+        COUNT(DISTINCT CASE WHEN event_name = 'screen_view' THEN path ELSE NULL END) as unique_pages,

Line 56:

-        COUNT(DISTINCT e.path) as unique_pages,
+        COUNT(DISTINCT CASE WHEN e.event_name = 'screen_view' THEN e.path ELSE NULL END) as unique_pages,
apps/api/src/query/index.ts (1)

17-25: Operator set reduction already flagged.

This breaking change (removal of gt, gte, lt, lte, like, ilike, notLike, isNull, isNotNull and renaming notIn to not_in) was noted in a prior review. Ensure API consumers are notified.

apps/api/src/query/builders/sessions.ts (1)

161-161: generateUUIDv4() produces non-deterministic IDs on each query execution.

This concern was raised in a prior review. The custom_event_spans table has no id column, so generating a new UUID per query means the same custom event receives a different ID every time it's queried. If the frontend or any deduplication logic relies on stable event identifiers, this will break that expectation. Consider using a deterministic hash of event attributes or the client-provided eventId if available.

apps/dashboard/app/(main)/websites/[id]/_components/tabs/performance-tab.tsx (1)

565-572: Incomplete spinner border styling.

This was flagged in a prior review. The spinner uses border-primary border-b-2 but doesn't specify a width for the non-animated borders, which may cause an invisible or inconsistent spinner appearance.

Apply this diff:

-						<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-primary border-b-2" />
+						<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-2 border-primary/30 border-t-primary" />
apps/api/src/query/builders/custom-events.ts (1)

251-298: Missing customizable: true flag on custom_events_trends.

This was flagged in a prior review. The custom_events_trends builder lacks the customizable: true flag present on sibling builders, which may prevent clients from customizing this query type.

Apply this diff:

 	custom_events_trends: {
 		customSql: (
 			// ... function body unchanged
 		),
 		timeField: "timestamp",
 		allowedFilters: ["path", "event_name"],
+		customizable: true,
 	},
apps/dashboard/app/(main)/websites/[id]/_components/tabs/audience-tab.tsx (1)

219-230: Column definitions should be memoized to prevent unnecessary re-renders.

The displayNames and column creators (createGeoColumns, createTimezoneColumns, createLanguageColumns) are called on every render, creating new array references. Since these are in geographicTabs's dependency array (lines 296-301), the tabs will recalculate on every render.

This was flagged in a previous review but remains unaddressed.

apps/api/src/query/builders/performance.ts (2)

262-272: LEFT JOIN with WHERE clause behaves as INNER JOIN.

The LEFT JOIN on line 263 combined with WHERE e.country != '' on line 272 effectively filters out all rows where the join didn't match (since e.country would be NULL). This makes it semantically equivalent to an INNER JOIN.

If the intent is to exclude rows without country data, use explicit INNER JOIN for clarity. If the intent is to include unmatched vitals with a fallback, move the filter into the JOIN condition.

-					LEFT JOIN ${Analytics.events} e ON (
+					INNER JOIN ${Analytics.events} e ON (
 						wv.session_id = e.session_id 
 						AND wv.client_id = e.client_id
 						AND abs(dateDiff('second', wv.timestamp, e.time)) < 60
+						AND e.country != ''
 					)
 					WHERE 
 						wv.client_id = {websiteId:String}
 						AND wv.timestamp >= toDateTime({startDate:String})
 						AND wv.timestamp <= toDateTime(concat({endDate:String}, ' 23:59:59'))
-						AND e.country != ''

164-181: GROUP BY mismatch causes incorrect path aggregation.

The SELECT uses a normalized path expression that removes trailing slashes and decodes URL components, but GROUP BY uses raw path. This causes /about and /about/ to be grouped separately while displaying the same normalized name, resulting in duplicate rows.

Apply this diff to fix the GROUP BY:

-					GROUP BY path
+					GROUP BY decodeURLComponent(CASE WHEN trimRight(path(path), '/') = '' THEN '/' ELSE trimRight(path(path), '/') END)

Or use an alias approach:

-						decodeURLComponent(CASE WHEN trimRight(path(path), '/') = '' THEN '/' ELSE trimRight(path(path), '/') END) as name,
+						decodeURLComponent(CASE WHEN trimRight(path(path), '/') = '' THEN '/' ELSE trimRight(path(path), '/') END) as page,
...
-					GROUP BY path
+					GROUP BY page
...
-						COUNT(*) as measurements
+						COUNT(*) as measurements,
+						page as name
📜 Review details

Configuration used: CodeRabbit UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e1e2541 and 691906b.

📒 Files selected for processing (44)
  • apps/api/src/index.ts (1 hunks)
  • apps/api/src/lib/api-key.test.ts (1 hunks)
  • apps/api/src/lib/clickhouse-backup.ts (6 hunks)
  • apps/api/src/query/batch-executor.ts (1 hunks)
  • apps/api/src/query/builders/custom-events.ts (10 hunks)
  • apps/api/src/query/builders/engagement.ts (1 hunks)
  • apps/api/src/query/builders/performance.ts (2 hunks)
  • apps/api/src/query/builders/profiles.ts (5 hunks)
  • apps/api/src/query/builders/sessions.ts (3 hunks)
  • apps/api/src/query/expressions.ts (11 hunks)
  • apps/api/src/query/index.ts (2 hunks)
  • apps/api/src/query/screen-resolution-to-device-type.ts (2 hunks)
  • apps/api/src/query/simple-builder.ts (11 hunks)
  • apps/api/src/query/types.ts (2 hunks)
  • apps/api/src/query/utils.ts (7 hunks)
  • apps/api/src/routes/query.ts (8 hunks)
  • apps/api/src/schemas/query-schemas.ts (2 hunks)
  • apps/api/src/types/tables.ts (1 hunks)
  • apps/basket/src/lib/event-service.ts (4 hunks)
  • apps/basket/src/lib/producer.ts (2 hunks)
  • apps/dashboard/app/(auth)/layout.tsx (1 hunks)
  • apps/dashboard/app/(auth)/login/page.tsx (4 hunks)
  • apps/dashboard/app/(main)/billing/components/usage-row.tsx (1 hunks)
  • apps/dashboard/app/(main)/billing/cost-breakdown/components/consumption-chart.tsx (11 hunks)
  • apps/dashboard/app/(main)/billing/cost-breakdown/components/usage-breakdown-table.tsx (4 hunks)
  • apps/dashboard/app/(main)/billing/cost-breakdown/page.tsx (1 hunks)
  • apps/dashboard/app/(main)/billing/hooks/use-billing.ts (1 hunks)
  • apps/dashboard/app/(main)/billing/types/billing.ts (1 hunks)
  • apps/dashboard/app/(main)/billing/utils/feature-usage.ts (1 hunks)
  • apps/dashboard/app/(main)/organizations/components/general-settings.tsx (3 hunks)
  • apps/dashboard/app/(main)/organizations/invitations/invitation-list.tsx (2 hunks)
  • apps/dashboard/app/(main)/organizations/settings/danger/danger-zone-settings.tsx (8 hunks)
  • apps/dashboard/app/(main)/organizations/settings/danger/transfer-assets.tsx (6 hunks)
  • apps/dashboard/app/(main)/organizations/settings/websites/website-settings.tsx (5 hunks)
  • apps/dashboard/app/(main)/websites/[id]/_components/filters/add-filters.tsx (1 hunks)
  • apps/dashboard/app/(main)/websites/[id]/_components/filters/filters-section.tsx (1 hunks)
  • apps/dashboard/app/(main)/websites/[id]/_components/filters/save-filter-dialog.tsx (1 hunks)
  • apps/dashboard/app/(main)/websites/[id]/_components/filters/saved-filters-menu.tsx (1 hunks)
  • apps/dashboard/app/(main)/websites/[id]/_components/shared/tracking-components.tsx (8 hunks)
  • apps/dashboard/app/(main)/websites/[id]/_components/tabs/audience-tab.tsx (11 hunks)
  • apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview-tab.tsx (6 hunks)
  • apps/dashboard/app/(main)/websites/[id]/_components/tabs/performance-tab.tsx (3 hunks)
  • apps/dashboard/app/(main)/websites/[id]/_components/tabs/performance/_components/web-vitals-chart.tsx (3 hunks)
  • apps/dashboard/app/(main)/websites/[id]/_components/website-page-header.tsx (15 hunks)
🧰 Additional context used
🧬 Code graph analysis (26)
apps/dashboard/app/(main)/websites/[id]/_components/filters/add-filters.tsx (6)
packages/shared/src/lists/filters.ts (1)
  • filterOptions (1-12)
apps/dashboard/hooks/use-funnels.ts (2)
  • AutocompleteData (68-78)
  • useAutocompleteData (364-372)
apps/dashboard/lib/utils.ts (1)
  • cn (5-7)
packages/shared/src/types/api.ts (1)
  • DynamicQueryFilter (19-30)
apps/dashboard/components/ui/skeleton.tsx (1)
  • Skeleton (13-13)
apps/dashboard/hooks/use-filters.ts (1)
  • operatorOptions (4-10)
apps/dashboard/app/(main)/billing/cost-breakdown/page.tsx (3)
apps/dashboard/components/ui/skeleton.tsx (1)
  • Skeleton (13-13)
apps/dashboard/app/(main)/billing/cost-breakdown/components/consumption-chart.tsx (1)
  • ConsumptionChart (41-398)
apps/dashboard/app/(main)/billing/cost-breakdown/components/usage-breakdown-table.tsx (1)
  • UsageBreakdownTable (56-189)
apps/dashboard/app/(main)/billing/hooks/use-billing.ts (1)
apps/dashboard/app/(main)/billing/utils/feature-usage.ts (1)
  • calculateFeatureUsage (56-96)
apps/dashboard/app/(main)/websites/[id]/_components/filters/save-filter-dialog.tsx (3)
packages/shared/src/lists/filters.ts (1)
  • filterOptions (1-12)
packages/shared/src/types/api.ts (1)
  • DynamicQueryFilter (19-30)
apps/dashboard/hooks/use-filters.ts (1)
  • getOperatorLabel (33-35)
apps/api/src/query/builders/sessions.ts (1)
apps/api/src/types/tables.ts (1)
  • Analytics (13-23)
apps/dashboard/app/(main)/billing/cost-breakdown/components/usage-breakdown-table.tsx (4)
apps/dashboard/components/ui/skeleton.tsx (1)
  • Skeleton (13-13)
apps/dashboard/components/empty-state.tsx (1)
  • EmptyState (43-260)
apps/dashboard/app/(main)/billing/cost-breakdown/utils/billing-utils.ts (1)
  • calculateOverageCost (9-25)
apps/dashboard/components/ui/badge.tsx (1)
  • Badge (54-54)
apps/api/src/lib/clickhouse-backup.ts (2)
packages/env/src/api.ts (1)
  • env (31-33)
packages/db/src/clickhouse/client.ts (1)
  • clickHouseOG (37-40)
apps/dashboard/app/(main)/websites/[id]/_components/tabs/performance-tab.tsx (2)
apps/dashboard/app/(main)/websites/[id]/_components/tabs/performance/_components/performance-summary-card.tsx (1)
  • PerformanceSummaryCard (92-166)
apps/dashboard/components/table/data-table.tsx (1)
  • DataTable (83-236)
apps/api/src/query/simple-builder.ts (1)
apps/api/src/query/screen-resolution-to-device-type.ts (1)
  • DeviceType (1-8)
apps/dashboard/app/(main)/organizations/settings/websites/website-settings.tsx (3)
apps/dashboard/hooks/use-organizations.ts (1)
  • Organization (457-459)
apps/dashboard/app/(main)/organizations/components/empty-state.tsx (1)
  • EmptyState (17-62)
apps/dashboard/components/right-sidebar.tsx (1)
  • RightSidebar (16-27)
apps/api/src/query/utils.ts (2)
apps/api/src/query/types.ts (1)
  • SimpleQueryConfig (174-228)
apps/api/src/query/screen-resolution-to-device-type.ts (1)
  • mapScreenResolutionToDeviceType (102-107)
apps/api/src/query/builders/custom-events.ts (2)
apps/api/src/types/tables.ts (1)
  • Analytics (13-23)
apps/api/src/query/types.ts (2)
  • Filter (41-49)
  • TimeUnit (35-35)
apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview-tab.tsx (4)
apps/dashboard/hooks/use-chart-preferences.ts (1)
  • useChartPreferences (99-118)
apps/dashboard/components/analytics/index.tsx (1)
  • EventLimitIndicator (5-5)
apps/dashboard/lib/formatters.ts (1)
  • formatNumber (40-46)
packages/db/src/drizzle/schema.ts (1)
  • chartType (631-631)
apps/dashboard/app/(main)/websites/[id]/_components/tabs/performance/_components/web-vitals-chart.tsx (1)
apps/dashboard/components/table/data-table.tsx (1)
  • DataTable (83-236)
apps/dashboard/app/(main)/billing/types/billing.ts (1)
apps/dashboard/app/(main)/billing/hooks/use-billing.ts (1)
  • CustomerWithPaymentMethod (21-21)
apps/dashboard/app/(main)/organizations/settings/danger/danger-zone-settings.tsx (3)
apps/dashboard/app/(main)/websites/_components/notice-banner.tsx (1)
  • NoticeBanner (5-52)
apps/dashboard/components/right-sidebar.tsx (1)
  • RightSidebar (16-27)
apps/dashboard/components/ui/delete-dialog.tsx (1)
  • DeleteDialog (29-113)
apps/dashboard/app/(main)/billing/cost-breakdown/components/consumption-chart.tsx (3)
apps/dashboard/components/empty-state.tsx (1)
  • EmptyState (43-260)
apps/dashboard/components/ui/button.tsx (1)
  • Button (59-59)
apps/dashboard/lib/utils.ts (1)
  • cn (5-7)
apps/api/src/query/index.ts (3)
apps/api/src/query/types.ts (1)
  • QueryRequest (230-242)
apps/api/src/query/builders/index.ts (1)
  • QueryBuilders (15-29)
apps/api/src/query/simple-builder.ts (1)
  • SimpleQueryBuilder (206-715)
apps/api/src/query/builders/performance.ts (2)
apps/api/src/query/types.ts (2)
  • Filter (41-49)
  • TimeUnit (35-35)
apps/api/src/types/tables.ts (1)
  • Analytics (13-23)
apps/dashboard/app/(main)/billing/utils/feature-usage.ts (1)
apps/dashboard/app/(main)/billing/cost-breakdown/utils/billing-utils.ts (1)
  • calculateOverageCost (9-25)
apps/dashboard/app/(main)/websites/[id]/_components/filters/saved-filters-menu.tsx (4)
apps/dashboard/hooks/use-saved-filters.ts (1)
  • SavedFilter (8-14)
packages/shared/src/types/api.ts (1)
  • DynamicQueryFilter (19-30)
packages/shared/src/lists/filters.ts (1)
  • filterOptions (1-12)
apps/dashboard/hooks/use-filters.ts (1)
  • getOperatorLabel (33-35)
apps/api/src/lib/api-key.test.ts (1)
apps/api/src/lib/api-key.ts (13)
  • ApiKeyRow (7-7)
  • isApiKeyPresent (28-32)
  • extractSecret (34-46)
  • getEffectiveScopes (84-92)
  • hasKeyScope (94-103)
  • hasKeyAnyScope (105-114)
  • hasKeyAllScopes (116-125)
  • resolveEffectiveScopesForWebsite (127-132)
  • hasWebsiteScope (134-140)
  • hasWebsiteAnyScope (142-148)
  • hasWebsiteAllScopes (150-156)
  • hasGlobalAccess (158-164)
  • getAccessibleWebsiteIds (166-177)
apps/dashboard/app/(main)/websites/[id]/_components/shared/tracking-components.tsx (1)
apps/dashboard/app/(main)/websites/_components/notice-banner.tsx (1)
  • NoticeBanner (5-52)
apps/dashboard/app/(main)/organizations/components/general-settings.tsx (4)
apps/dashboard/hooks/use-organizations.ts (1)
  • Organization (457-459)
apps/dashboard/app/(main)/organizations/components/organization-logo-uploader.tsx (1)
  • OrganizationLogoUploader (32-272)
packages/db/src/drizzle/schema.ts (1)
  • organization (531-542)
apps/dashboard/components/right-sidebar.tsx (1)
  • RightSidebar (16-27)
apps/dashboard/app/(main)/websites/[id]/_components/website-page-header.tsx (2)
apps/dashboard/components/ui/skeleton.tsx (1)
  • Skeleton (13-13)
apps/dashboard/lib/utils.ts (1)
  • cn (5-7)
apps/api/src/query/expressions.ts (1)
apps/api/src/query/types.ts (1)
  • TimeUnit (35-35)
🪛 ast-grep (0.40.0)
apps/dashboard/app/(main)/websites/[id]/_components/shared/tracking-components.tsx

[warning] 104-104: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html

(react-unsafe-html-injection)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Cursor Bugbot
  • GitHub Check: Vercel Agent Review

Comment on lines +77 to +79
} catch {
return null;
}

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.

🧹 Nitpick | 🔵 Trivial

Consider logging errors before returning null.

While returning null on error is a safe fallback (forces full backup), adding a debug/warn log would help diagnose issues with the backup chain lookup.

-	} catch {
-		return null;
+	} catch (error) {
+		logger.warn({ error }, "Failed to get last backup path, falling back to full backup");
+		return null;
 	}
📝 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
} catch {
return null;
}
} catch (error) {
logger.warn({ error }, "Failed to get last backup path, falling back to full backup");
return null;
}
🤖 Prompt for AI Agents
In apps/api/src/lib/clickhouse-backup.ts around lines 77 to 79, the catch block
currently swallows errors and returns null; change it to log the caught error
(at debug or warn level) before returning null so failures in the backup chain
lookup are recorded for diagnostics. Capture the exception in the catch clause,
call the module's logger (or inject/require one if missing) to log a clear
message including the error stack/details and context, then return null as
before.

Comment on lines +187 to +189
const baseBackupSetting = isIncremental
? `,base_backup = S3('${lastBackupPath}', '${env.R2_ACCESS_KEY_ID}', '${env.R2_SECRET_ACCESS_KEY}')`
: "";

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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find the getLastBackupPath function
rg -n "getLastBackupPath" --type ts -A 10

Repository: databuddy-analytics/Databuddy

Length of output: 1747


🏁 Script executed:

#!/bin/bash
# Check for S3 URL validation patterns
rg -n "s3.*url.*validat|backup.*path.*validat|validateUrl|URL.*validat" --type ts -i

Repository: databuddy-analytics/Databuddy

Length of output: 1247


🏁 Script executed:

#!/bin/bash
# Check where lastBackupPath is used and how it's constructed
rg -n "lastBackupPath" --type ts -B 2 -A 2

Repository: databuddy-analytics/Databuddy

Length of output: 1731


🏁 Script executed:

#!/bin/bash
# Get more context around the backup insertion and see what format backup_path should be
cat -n apps/api/src/lib/clickhouse-backup.ts | sed -n '165,210p'

Repository: databuddy-analytics/Databuddy

Length of output: 2186


🏁 Script executed:

#!/bin/bash
# Check if there are any validation or sanitization functions used for the SQL queries in this file
rg -n "escape|sanitize|parameterize|prepared" apps/api/src/lib/clickhouse-backup.ts

Repository: databuddy-analytics/Databuddy

Length of output: 186


SQL injection vulnerability: unescaped parameters in INSERT query at line 182.

The backup function interpolates backupName, s3Url, and lastBackupPath directly into the INSERT query without escaping. This allows injection through any of these parameters. While the original concern about lastBackupPath at line 188 is technically correct, the root cause is the unescaped INSERT at line 182. An attacker controlling backupName could inject a malicious backup_path into the database, which would then be used in the S3() call on subsequent backups. Use parameterized queries or proper SQL escaping for all parameters at line 182 and line 193.

Comment on lines +152 to +159
customSql: (
websiteId: string,
startDate: string,
endDate: string,
_filters?: Filter[],
_granularity?: TimeUnit,
_limit?: number
) => {

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.

🧹 Nitpick | 🔵 Trivial

Unused _filters and _granularity parameters across multiple builders.

Multiple web vitals builders accept _filters?: Filter[] and _granularity?: TimeUnit parameters but never use them. The customSql queries don't apply any filter logic from these parameters.

Either remove the unused parameters or implement filter/granularity handling within the SQL generation.

Also applies to: 193-200, 239-246, 286-293, 332-339

🤖 Prompt for AI Agents
In apps/api/src/query/builders/performance.ts around lines 152-159 (and also
apply same change at 193-200, 239-246, 286-293, 332-339): the `_filters?:
Filter[]` and `_granularity?: TimeUnit` parameters are declared but never used
in the customSql builders; either remove these unused parameters from the
function signatures (and update any callers) or implement their handling inside
the SQL generation by applying filter conditions derived from `_filters` to the
WHERE clause and mapping `_granularity` to appropriate time-binning (e.g.,
date_trunc or GROUP BY intervals) so the generated SQL respects filters and
granularity. Ensure TypeScript types stay consistent and run tests/compile after
the change.

Comment on lines +309 to +319
FROM ${Analytics.web_vitals_spans} wv
LEFT JOIN ${Analytics.events} e ON (
wv.session_id = e.session_id
AND wv.client_id = e.client_id
AND abs(dateDiff('second', wv.timestamp, e.time)) < 60
)
WHERE
wv.client_id = {websiteId:String}
AND wv.timestamp >= toDateTime({startDate:String})
AND wv.timestamp <= toDateTime(concat({endDate:String}, ' 23:59:59'))
AND e.os_name != ''

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.

🛠️ Refactor suggestion | 🟠 Major

Same LEFT JOIN issue in web_vitals_by_os.

The LEFT JOIN with WHERE e.os_name != '' has the same semantic issue as web_vitals_by_country. Apply the same fix: either use INNER JOIN or move the filter to the JOIN condition.

🤖 Prompt for AI Agents
In apps/api/src/query/builders/performance.ts around lines 309 to 319, the LEFT
JOIN to Analytics.events combined with the WHERE clause filtering e.os_name !=
'' effectively turns the LEFT JOIN into an INNER JOIN and may drop rows
unexpectedly; fix this by either changing the LEFT JOIN to an INNER JOIN if you
intend to require a matching event, or keep the LEFT JOIN and move the e.os_name
!= '' predicate into the ON clause so that rows without matching events are
preserved (choose one approach and apply consistently).

Comment on lines 253 to 266
export function normalizeGranularity(
unit: TimeUnit | undefined
unit: TimeUnit | undefined
): Granularity | undefined {
if (!unit) {
return;
}
if (unit === "hourly") {
return "hour";
}
if (unit === "daily") {
return "day";
}
return unit as Granularity;
if (!unit) {
return;
}
if (unit === "hourly") {
return "hour";
}
if (unit === "daily") {
return "day";
}
return unit as Granularity;
}

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.

🧹 Nitpick | 🔵 Trivial

Consider adding exhaustive validation for TimeUnit.

The as Granularity cast at line 265 assumes all remaining TimeUnit values (after handling "hourly"/"daily") are valid Granularity values. If TimeGranularity contains keys that don't exist in Granularity, this could silently pass invalid values.

Consider adding runtime validation or using a type assertion function:

-  return unit as Granularity;
+  const validGranularities: Granularity[] = ["minute", "hour", "day", "week", "month"];
+  if (validGranularities.includes(unit as Granularity)) {
+    return unit as Granularity;
+  }
+  return undefined;
🤖 Prompt for AI Agents
In apps/api/src/query/expressions.ts around lines 253-266, the function uses a
blind cast "return unit as Granularity" which can silently accept TimeUnit
values that are not valid Granularity; replace the cast with runtime validation:
enumerate the allowed Granularity values (or use an exhaustive switch over
TimeUnit), map/translate any aliases ("hourly" -> "hour", "daily" -> "day"), and
only return the value if it exists in the Granularity set (otherwise return
undefined or throw a clear error). Ensure the validation is explicit so
non-matching TimeUnit values are rejected instead of being cast through.

disabled={isDeleting}
onClick={handleDelete}
<DeleteDialog
confirmDisabled={confirmText !== organization.name}

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.

🧹 Nitpick | 🔵 Trivial

Ensure user guidance matches validation strictness.

The confirmDisabled validation (line 177) uses exact case-sensitive matching, but the guidance text (lines 204-206) doesn't emphasize this requirement. If you keep strict matching, consider updating the guidance text to clarify: "Type exactly {organization.name} to confirm deletion."

 						<p className="text-muted-foreground text-xs">
-							Type <span className="font-medium">{organization.name}</span> to
-							confirm deletion
+							Type <span className="font-medium">{organization.name}</span> exactly to
+							confirm deletion (case-sensitive)
 						</p>

Also applies to: 203-206

🤖 Prompt for AI Agents
In
apps/dashboard/app/(main)/organizations/settings/danger/danger-zone-settings.tsx
around line 177, the confirmDisabled check uses a case-sensitive exact match
(confirmText !== organization.name) but the guidance text shown to users (lines
~203-206) does not state that matching is exact; update the guidance copy to
explicitly instruct users to "Type exactly {organization.name} to confirm
deletion." and ensure any interpolation/escaping matches the current
organization.name variable and i18n approach used in the file so the message
clearly conveys the case-sensitive requirement.

Comment on lines +399 to +400
const { id } = useParams();
const websiteId = id as string;

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 | 🟡 Minor

Unsafe type assertion for websiteId.

useParams() returns string | string[] | undefined for dynamic segments. The assertion id as string could fail if the route param is missing or an array.

 const { id } = useParams();
-const websiteId = id as string;
+const websiteId = typeof id === "string" ? id : "";

Consider also disabling the button or showing an error state when websiteId is invalid.

📝 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
const { id } = useParams();
const websiteId = id as string;
const { id } = useParams();
const websiteId = typeof id === "string" ? id : "";
🤖 Prompt for AI Agents
In apps/dashboard/app/(main)/websites/[id]/_components/filters/add-filters.tsx
around lines 399-400, avoid the unsafe assertion `id as string`; validate the
param instead (check typeof id === 'string' and that it's not empty) and only
use a typed websiteId when valid; if id is undefined or an array, set websiteId
to null/undefined or derive an error state and update the UI to disable the
submit/add button and show a validation message or error banner; optionally
narrow useParams with a typed generic or helper that returns a safe string, but
the core fix is to replace the direct assertion with runtime validation and
guard UI/actions when websiteId is invalid.

Comment on lines +37 to +38
const { id } = useParams();
const websiteId = id as string;

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 | 🟡 Minor

Unsafe type assertion for websiteId.

Same issue as in add-filters.tsx. useParams() returns string | string[] | undefined, and the assertion id as string could fail silently.

 const { id } = useParams();
-const websiteId = id as string;
+const websiteId = typeof id === "string" ? id : "";
🤖 Prompt for AI Agents
In
apps/dashboard/app/(main)/websites/[id]/_components/filters/filters-section.tsx
around lines 37-38, the code unsafely asserts useParams().id to string; replace
the type assertion with a safe runtime check: if id is undefined, throw or
redirect; if Array, pick the relevant element (e.g., id[0]) or join if intended;
finally narrow the type so websiteId is a true string (or update the function
signature to accept undefined/string[]), ensuring you handle all cases instead
of using `as string`.

Comment on lines +69 to +72
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: { name: "" },
});

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.

🧹 Nitpick | 🔵 Trivial

Reconsider using formState.isValid with default useForm mode for disabling “Save”

disabled={... || !form.formState.isValid} combined with the default useForm config (mode: "onSubmit") can keep the Save button disabled until after a submit cycle, which is a bit awkward UX for a simple single‑field form.

If you want real‑time validity gating, explicitly opt into change‑based validation:

-const form = useForm<FormData>({
-	resolver: zodResolver(formSchema),
-	defaultValues: { name: "" },
-});
+const form = useForm<FormData>({
+	resolver: zodResolver(formSchema),
+	defaultValues: { name: "" },
+	mode: "onChange",
+});

That way formState.isValid tracks the Zod schema (with .trim() applied) as the user types, and the Save button enables as soon as the name becomes valid.

Also applies to: 188-190

Comment on lines +115 to +119
<DialogDescription className="text-muted-foreground text-xs">
{isEditing
? `Update the name for "${editingFilter?.name}"`
: `Save ${filters.length} filter${filters.length === 1 ? "" : "s"} for later`}
</DialogDescription>

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.

🧹 Nitpick | 🔵 Trivial

Tighten copy when there are no filters to avoid “Save 0 filters for later”

When filters.length === 0, the description still says Save 0 filters for later while the preview area shows “No filters applied”. Minor UX nit, but you could special‑case the description for the empty state to keep the messaging consistent, e.g.:

-						<DialogDescription className="text-muted-foreground text-xs">
-							{isEditing
-								? `Update the name for "${editingFilter?.name}"`
-								: `Save ${filters.length} filter${filters.length === 1 ? "" : "s"} for later`}
-						</DialogDescription>
+						<DialogDescription className="text-muted-foreground text-xs">
+							{isEditing
+								? `Update the name for "${editingFilter?.name}"`
+								: filters.length === 0
+									? "You don’t have any filters to save yet"
+									: `Save ${filters.length} filter${filters.length === 1 ? "" : "s"} for later`}
+						</DialogDescription>

Also applies to: 123-153

};
},
timeField: "timestamp",
customizable: true,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: Web vitals queries ignore filters despite being customizable

The vitals_by_page and multiple web_vitals_* queries are marked with customizable: true but their customSql function signatures don't include the filterConditions and filterParams parameters that SimpleQueryBuilder.compile() passes. When users pass filters to these queries, the filters are silently ignored because the generated SQL never includes them. Compare with custom_events which properly accepts filterConditions?: string[] and filterParams?: Record<string, Filter["value"]> and uses them to build a combinedWhereClause applied to the SQL.

Additional Locations (1)

Fix in Cursor Fix in Web

"utm_source",
"utm_medium",
"utm_campaign",
] as const;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: Global filters allow columns missing from target tables

The new GLOBAL_ALLOWED_FILTERS list permits filtering on columns like country, device_type, browser_name, os_name, referrer, and utm_* even when querying tables that lack these columns. For example, the error_spans table only has client_id, anonymous_id, session_id, timestamp, path, message, filename, lineno, colno, stack, and error_type. Similarly, web_vitals_spans lacks these columns. When a user applies a globally-allowed filter like country to table-based queries against these tables (e.g., error_types, error_trends), the generated SQL will reference a non-existent column, causing a ClickHouse runtime error.

Additional Locations (1)

Fix in Cursor Fix in Web

@railway-app
railway-app Bot temporarily deployed to Staging API (Databuddy / production) December 2, 2025 22:53 Inactive
@vercel
vercel Bot temporarily deployed to staging – documentation December 2, 2025 22:53 Inactive
@izadoesdev
izadoesdev merged commit ce4e0f0 into main Dec 2, 2025
9 of 12 checks passed
AND timestamp >= toDateTime({startDate:String})
AND timestamp <= toDateTime(concat({endDate:String}, ' 23:59:59'))
AND path != ''
GROUP BY path

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: GROUP BY mismatch causes incorrect page grouping

The web_vitals_by_page query has a mismatch between its SELECT and GROUP BY expressions. The SELECT transforms the path using decodeURLComponent(CASE WHEN trimRight(path(path), '/') = '' THEN '/' ELSE trimRight(path(path), '/') END) as name, but the GROUP BY uses just the raw path column. This means rows with different raw paths (e.g., /page?a=1 and /page?b=2) will be in separate groups but display the same transformed name (/page), causing duplicate entries with split metrics. The correct pattern, used in top_pages and vitals_by_page, is to GROUP BY the same transformed expression or its alias.

Fix in Cursor Fix in Web

"utm_source",
"utm_medium",
"utm_campaign",
] as const;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: Global filters allow columns missing from span tables

The new GLOBAL_ALLOWED_FILTERS list bypasses per-query allowedFilters restrictions, but several query builders use tables that don't have these columns. For example, error_types, error_trends, and similar queries use error_spans directly without joining events. The error_spans table lacks columns like browser_name, country, device_type, os_name, referrer, and UTM fields. When users filter these queries by a globally allowed column that doesn't exist in the underlying table, ClickHouse will return a column-not-found error at runtime.

Additional Locations (1)

Fix in Cursor Fix in Web

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants