Skip to content

Lite dashboard 2#1011

Merged
goldflag merged 26 commits into
masterfrom
lite-dashboard-2
May 30, 2026
Merged

Lite dashboard 2#1011
goldflag merged 26 commits into
masterfrom
lite-dashboard-2

Conversation

@goldflag
Copy link
Copy Markdown
Collaborator

@goldflag goldflag commented May 29, 2026

Summary by CodeRabbit

Release Notes

  • New Features

    • Added support for tracking React Native mobile apps alongside traditional websites
    • Introduced React Native SDK with initialization, event tracking, and navigation integration
    • Added "App Analytics" mode for mobile site dashboards with mobile-specific tracking options
  • Improvements

    • Renamed "Add Website" to "Add Site" across the application
    • Updated tracking labels for mobile platforms (e.g., "Initial Screen View" instead of "Page View")
    • Disabled Web Vitals and Session Replay tracking options for mobile app types
    • Added React Native-specific installation and configuration guidance
  • Documentation

    • Published React Native SDK integration documentation with setup instructions and code examples

goldflag added 18 commits May 28, 2026 21:46
- Added DEPLOYMENT environment variable to docker-compose and client Dockerfile for better deployment management.
- Introduced CLUSTER_WORKERS variable in docker-compose to control the number of cluster workers, defaulting to 4.
- Updated constants in client and server to include DEPLOYMENT for consistent access across the application.
- Changed Dockerfile paths in docker-compose.yml to use relative paths for better clarity and consistency across services.
- Added port mapping for PostgreSQL service to allow external connections on port 5432.
- Modified the conditional rendering in AdminLink to account for both IS_CLOUD and DEPLOYMENT variables, ensuring proper access control for admin features.
- Introduced endpoints for retrieving admin sites and organizations, as well as a service event count.
- Added a public telemetry collection endpoint to enhance monitoring capabilities.
- Changed data types for pageviews and events from UInt32 to UInt64 to accommodate larger values.
…ormance

- Reorganized SQL queries in getMetricLite, getOverviewBucketedLite, and getOverviewLite to improve readability and performance.
- Introduced subqueries to handle aggregations more effectively, ensuring compliance with ClickHouse's limitations on nested aggregates.
- Updated variable names for clarity, enhancing maintainability of the code.
- Changed the ORDER BY clause in getMetricLite from pageviews to count for both pathname and country queries, ensuring consistent metric sorting across analytics endpoints.
…tedLite and getOverviewLite

- Refactored SQL queries in getOverviewBucketedLite and getOverviewLite to utilize a refreshable materialized view for session metrics, enhancing performance and reducing data retrieval complexity.
- Introduced helper functions for building hour and day bucket queries, improving code readability and maintainability.
- Updated time statement logic to align with session hour granularity, ensuring accurate metric calculations across different time buckets.
…verviewLite for improved aggregation handling

- Refactored SQL queries to utilize subqueries for aggregations, enhancing clarity and compliance with ClickHouse's aggregation rules.
- Updated the outer SELECT statements to directly reference computed values, avoiding aliasing issues that could lead to errors.
- Improved comments for better understanding of the query structure and logic.
…sections

- Added an Expand button to both CountriesLite and PagesLite components for improved user interaction.
- Updated layout to include a flexbox for better alignment of titles and buttons.
- Integrated the Button component from the UI library for consistent styling.
- Added new keys for API endpoint and filter-related messages in Czech, German, English, Spanish, French, Italian, and Japanese localization files.
- Removed outdated keys to streamline the localization content.
- Enhanced user experience by providing clearer instructions and error messages related to API interactions.
- Updated the Chart component in MainSectionLite to include the chartXMax prop, enhancing its configurability for rendering data.
- Updated the analytics API to support device type in the lite metric fetching functionality.
- Modified relevant components and hooks to accommodate the new dimension, ensuring consistent data retrieval.
- Enhanced the ClickHouse schema and backfill scripts to include device type materialized views for improved analytics capabilities.
- Introduced a new metric for Total Time in the OverviewLite component, calculated from session duration and sessions.
- Updated localization files to include the new metric in multiple languages, ensuring consistent user experience across different locales.
- Enhanced the Stat component to handle the new metric and adjusted layout for proper display.
@vercel
Copy link
Copy Markdown

vercel Bot commented May 29, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
rybbit Ready Ready Preview, Comment May 30, 2026 12:43am

Request Review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 29, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds lite analytics backed by ClickHouse MVs and server endpoints, threads a lite flag through client hooks/components/pages (including rollup), introduces mobile site type with RN SDK UI/validation, adds org user creation, updates env/config/docker, and refreshes i18n/docs strings.

Changes

Lite Analytics Flow

Layer / File(s) Summary
Env/config flags and ClickHouse lite MVs
server/src/lib/const.ts, server/src/db/clickhouse/clickhouse.ts, server/src/scripts/backfillLiteDashboardMVs.ts, server/src/api/getConfig.ts, client/src/lib/*, client/next.config.ts, client/Dockerfile, docker-compose.yml, server/package.json, client/src/components/AppSidebar.tsx
Introduces LITE_DASHBOARD/DEPLOYMENT flags, initializes lite MVs, exposes liteDashboard via /config, wires build/runtime env, and adds backfill script.
Server lite analytics endpoints
server/src/api/analytics/lite/*, server/src/api/analytics/index.ts, server/src/index.ts
Adds overview-lite, overview-bucketed-lite, and metric-lite endpoints with helper utilities and routes.
Client fetchers, hooks, store, and cards
client/src/api/analytics/endpoints/*, client/src/api/analytics/hooks/*, client/src/lib/*, client/src/components/BucketSelection.tsx, client/src/components/SiteCard.tsx
Adds lite fetchers and hook options, clamps/filters sub-hour buckets, and threads lite into SiteCard.
Lite site dashboard UI
client/src/app/[site]/main/*
Adds MainSectionLite, OverviewLite, lite sections, and LITE_DASHBOARD layout.
Lite rollup charts and sections
client/src/app/rollup/*
Adds lite mode to rollup main/sections/hooks and conditional page layout.
Infinite metric lists and dialog lite prop
client/src/app/[site]/components/shared/StandardSection/*
Switches to infinite loading and propagates lite to dialog/hook.

Mobile Site Type and Settings

Layer / File(s) Summary
DB schema and site APIs for type
server/drizzle/*, server/src/api/sites/*, server/src/lib/siteConfig.ts, server/src/api/admin/getAdminSites.ts
Adds sites.type, validates and persists type, returns normalized fields, and disables web-only features for mobile.
Client admin types and endpoints carry type
client/src/api/admin/endpoints/*
Extends types and payloads with type; adminSites includes type.
Add Site, General, ScriptBuilder, TrackingTab, SiteSettings
client/src/app/components/AddSite.tsx, client/src/components/SiteSettings/*
Adds web/mobile toggle and validation, RN SDK script UI, and mobile-aware tracking toggles.
Sidebar copy and Add Site label
client/src/app/[site]/components/Sidebar/*
Shows App Analytics for mobile, hides performance/replay, and changes Add Site label.

React Native SDK

Layer / File(s) Summary
RN package, README, and docs page
react-native/*, docs/content/docs/sdks/*
Adds @rybbit/react-native client, types, README, and RN SDK docs with examples.

Anonymous ID in tracker

Layer / File(s) Summary
Tracker schemas and ID derivation
server/src/services/tracker/*, server/src/services/userId/userIdService.ts
Accepts anonymous_id, derives anon id from clientId, and supports provided IP/UA in identify.

Create User in Organization

Layer / File(s) Summary
Server endpoint and barrel export
server/src/api/user/*, server/src/index.ts
Adds POST /organizations/:id/users to create a user and membership.
Client endpoint, hook, and dialog
client/src/api/admin/*, client/src/app/settings/organization/components/CreateUserDialog.tsx
Adds client POST wrapper, mutation, and updates dialog to use it.

Client and Docs i18n

Layer / File(s) Summary
Client messages *.json updates
client/messages/*.json
Adds keys/placeholders and copy updates for App Analytics/Add Site/RN SDK across locales.
Docs messages updates
docs/messages/*.json
Updates “Trusted by …” strings and keys across locales.

Small formatting/misc

Layer / File(s) Summary
Cluster comment and analytics barrel
server/src/cluster.ts, client/src/api/analytics/endpoints/index.ts
Minor comment and export formatting updates.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • rybbit-io/rybbit#491 — Also changes AddSite validation, overlapping with this PR’s new mobile identifier/domain handling.
  • rybbit-io/rybbit#757 — Refactors analytics endpoints/hooks that this PR extends with lite variants.
  • rybbit-io/rybbit#978 — Introduces rollup features that this PR augments with lite rollup components and hooks.

Poem

A rabbit taps on graphs so bright,
Lite moons rise over bars of night.
Apps hop in—mobile tracks anew,
RN drums a steady queue.
ClickHouse fields of hourly grain,
Buckets bloom like gentle rain.
“Add Site,” I squeak—then bound again! 🐇✨

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch lite-dashboard-2

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

Caution

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

⚠️ Outside diff range comments (6)
client/src/components/SiteSettings/GeneralTab.tsx (1)

121-136: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Keep the failure toast consistent for mobile sites.

This branch now presents the field as an app identifier for mobile sites, but the error path still always says "Failed to update domain", which gives contradictory feedback after a rejected mobile edit.

💡 Suggested fix
     } catch (error) {
       console.error("Error changing domain:", error);
-      toast.error(t("Failed to update domain"));
+      toast.error(isMobileSite ? t("Failed to update app identifier") : t("Failed to update domain"));
     } finally {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/src/components/SiteSettings/GeneralTab.tsx` around lines 121 - 136, In
handleDomainChange, the catch branch always shows "Failed to update domain" even
for mobile sites; update the toast.error call inside the catch to mirror the
success branch by conditionally using the mobile message when isMobileSite is
true (e.g., use t("Failed to update app identifier") for mobile or t("Failed to
update domain") otherwise) so the failure toast matches the field label; adjust
the toast.error invocation accordingly and keep the existing console.error call.
client/src/api/analytics/hooks/useGetMetric.ts (2)

121-126: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix the prevSite index used by placeholderData.

With the current key shape, site is at index 3, not 2. As written, placeholder reuse always misses because it compares the current site to the previous time object.

💡 Suggested fix
-      const [, , prevSite] = prevQueryKey;
+      const [, , , prevSite] = prevQueryKey;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/src/api/analytics/hooks/useGetMetric.ts` around lines 121 - 126, The
placeholderData logic in useGetMetric.ts is grabbing the wrong element from the
previous query key: change the destructuring so prevSite comes from index 3 (not
2) — e.g., extract prevQueryKey[3] or use [, , , prevSite] = prevQueryKey — then
compare that prevSite to the current site so placeholder reuse works correctly;
update any related variable names (prevQueryKey, prevSite, placeholderData)
accordingly.

154-176: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use the effective filter set in the infinite-query key too.

useInfiniteMetric has the same lite-mode key mismatch: the request ignores filters, but the key still changes with filter UI state. That will cause unnecessary invalidations/refetches for the same lite dataset.

💡 Suggested fix
     queryKey: [
       parameter,
       customTime,
       time,
       site,
-      filters,
+      combinedFilters,
       limit,
-      additionalFilters,
-      customFilters,
       "infinite-metric",
       timezone,
       lite,
     ],

As per coding guidelines, "Keep React Query keys as descriptive arrays and include every input that affects the response."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/src/api/analytics/hooks/useGetMetric.ts` around lines 154 - 176, The
query key currently includes raw filter UI state (filters, additionalFilters,
customFilters) while the request uses the effective filters computed into
combinedFilters; update the useInfiniteQuery queryKey in useGetMetric (the call
to useInfiniteQuery) to include the effective filter set (combinedFilters)
instead of the separate filter arrays so the key matches the actual request
inputs (preserving lite behavior) and prevents unnecessary refetches; ensure any
other inputs already in the key (parameter, customTime, time, site, limit,
timezone, lite) remain unchanged.
client/src/app/components/AddSite.tsx (1)

104-137: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Update the remaining “websites” copy to match the new site types.

These tooltips still talk about adding “websites”, which is now incorrect when the user is adding a React Native app.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/src/app/components/AddSite.tsx` around lines 104 - 137, In the AddSite
component update the copy that says "websites" to the neutral site type wording
(e.g., "sites" or your canonical site type name) so the tooltips and button
title are correct for React Native apps; specifically change the Button title
strings (currently t("Upgrade to Pro to add more websites")) and the
TooltipContent texts (both t("You need to be on an active subscription to add
websites") and the limit message t("You have reached the limit of {limit}
websites. Upgrade to add more websites", { limit: String(siteLimit) })) to use
the new wording, keeping the same translation interpolation for siteLimit and
using the same t(...) function calls in the AddSite component where
subscription, IS_CLOUD, isOverSiteLimit, trigger, and siteLimit are used.
server/src/api/sites/updateSiteConfig.ts (1)

205-209: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Conflict text is now wrong for mobile sites.

A duplicate mobile app identifier will still hit this branch and return "Domain already in use", which is misleading in the new mobile flow. Please make this message type-aware or neutral, e.g. domain/app identifier.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/api/sites/updateSiteConfig.ts` around lines 205 - 209, The
duplicate-key branch in updateSiteConfig (the block using
String(error).includes("duplicate key value violates unique constraint") that
calls reply.status(409).send) returns "Domain already in use" which is wrong for
mobile sites; change this to a neutral or type-aware message: either return a
neutral message like "Domain or app identifier already in use" or inspect the
incoming payload (e.g., request.body.siteType or presence of mobile identifier
fields) and return "App identifier already in use" for mobile and "Domain
already in use" for web, keeping the response format and status code unchanged.
client/messages/uk.json (1)

1265-1304: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Wide blank-string block in Ukrainian locale will leave embed/tracking UI unlabeled

Many changed keys in this section are now empty strings, including core embed and tracking copy (starting around Line 1265 through Line 1304, plus Line 1420/1422/1430). This is likely to produce unlabeled controls and missing explanatory text in production.

Also applies to: 1306-1307, 1328-1330, 1383-1385, 1420-1431

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/messages/uk.json` around lines 1265 - 1304, This locale file shows
many message keys set to empty strings (e.g., "+CwN9C", "dmW5bt", "PkGCG1", ...,
"2YpSsF") which will render unlabeled UI; restore proper Ukrainian translations
for each empty key (or apply a fallback to the English string) so embed/tracking
controls are labeled—inspect the nearby known key "JfG49w" ("Відкрито") as a
translation example and update the same key groups flagged (around the blocks
you changed and also keys at 1306-1307, 1328-1330, 1383-1385, 1420-1431) to
non-empty Ukrainian strings or fallbacks to avoid missing UI text.
🟠 Major comments (26)
server/src/scripts/backfillLiteDashboardMVs.ts-133-137 (1)

133-137: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject duplicate entries in --tables.

--tables sessions,sessions currently backfills the same target twice, which will double-count data in a single run. That is especially risky here because this script already documents the targets as non-idempotent on re-insert.

Suggested fix
     else if (a === "--tables") {
       const list = args[++i]?.split(",").map(s => s.trim()) ?? [];
+      const unique = [...new Set(list)];
       const invalid = list.filter(t => !ALL_TABLES.includes(t as TableKey));
       if (invalid.length) throw new Error(`Unknown table(s): ${invalid.join(", ")}`);
-      tables = list as TableKey[];
+      if (unique.length !== list.length) {
+        throw new Error("--tables cannot contain duplicates");
+      }
+      tables = unique as TableKey[];
     } else if (a === "--help" || a === "-h") {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/scripts/backfillLiteDashboardMVs.ts` around lines 133 - 137, The
--tables parsing currently allows duplicate table names (e.g.,
sessions,sessions) which leads to double-processing; update the branch handling
a === "--tables" (the parsing that builds list and sets tables) to detect
duplicate entries after trimming and before assigning tables: compute duplicates
(elements whose first index !== last index or via a Set check), if any
duplicates are found throw a new Error identifying the duplicate table names
(similar format to the existing unknown table error), otherwise continue to
validate against ALL_TABLES and then assign tables = list as TableKey[]; ensure
the duplicate check runs before final assignment so the script rejects duplicate
input early.
server/src/services/userId/userIdService.ts-70-74 (1)

70-74: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't silently disable salting when config lookup fails.

siteConfig.getConfig(siteId) returns undefined on lookup errors, and this branch then hashes with "". A transient config failure would therefore generate unsalted, stable IDs for sites that enabled saltUserIds, which weakens the privacy behavior instead of failing closed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/services/userId/userIdService.ts` around lines 70 - 74, The code
in generateUserIdFromClientId currently treats a failed
siteConfig.getConfig(siteId) as if saltUserIds is false and uses an empty salt;
instead detect when config is undefined and fail closed by throwing or returning
a rejected Promise so you don't fall back to an unsalted hash. Update
generateUserIdFromClientId to check if config === undefined (from
siteConfig.getConfig) and if so propagate an error (or return a clear failure)
rather than using "" for salt; when config exists, continue using
config.saltUserIds and this.getDailySalt() to compute the SHA-256 and substring
as before, ensuring callers handle the thrown/rejected error.
server/src/lib/siteConfig.ts-11-11 (1)

11-11: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Persist the new type field on site creation.

SiteConfigData now requires type, but addSite() in this file still doesn't write it to sites. New mobile sites created through this helper will fall back to "web" when they are read back, which breaks the new site-type contract.

Suggested fix
 await db.insert(sites).values({
   id: config.id,
   name: "", // This would need to be provided
+  type: config.type,
   domain: config.domain,
   public: config.public,
   saltUserIds: config.saltUserIds,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/lib/siteConfig.ts` at line 11, The SiteConfigData type now
requires the new `type` field but `addSite()` is not persisting it—update the
`addSite` implementation to include the incoming SiteConfigData.type when
inserting into the `sites` store/row so new mobile sites are saved with their
correct type; locate the `addSite` function in this file and ensure the
insert/object written to `sites` contains the `type` property (from the
`SiteConfigData` parameter) rather than defaulting to `"web"`.
server/src/db/clickhouse/clickhouse.ts-306-324 (1)

306-324: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Fix nondeterministic session-rollup fields in AggregatingMergeTree

server/src/db/clickhouse/clickhouse.ts (sessions rollup around lines 306-355): sessions_mv computes argMin(pathname, timestamp) for entry_pathname (and any(...) for country, hostname, etc.) per inserted batch, but sessions_mv_target stores these as plain scalar columns (String / LowCardinality(...)). When AggregatingMergeTree merges multiple rows for the same (site_id, session_id), these scalars have no deterministic merge behavior, so values can drift (with entry_pathname being the landing-page casualty). Store these fields as AggregateFunction/SimpleAggregateFunction with corresponding mergeable states (e.g., argMinState) or switch to a recomputation/refresh approach instead of streaming MV.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/db/clickhouse/clickhouse.ts` around lines 306 - 324, The
sessions_mv_target table currently stores nondeterministic scalar columns (e.g.,
entry_pathname, country, hostname) derived from per-batch aggregates in
sessions_mv and then merged by AggregatingMergeTree, causing drift; change those
scalar columns to appropriate aggregate-state types (e.g., use
AggregateFunction(argMin, String, DateTime) or argMinState(String, DateTime) /
SimpleAggregateFunction-like state types for entry_pathname and
AggregateFunction/LowCardinality states for country/hostname) so merges use
deterministic mergeable states (or alternatively adopt a recompute/refresh
approach instead of streaming MV); update the CREATE TABLE for
sessions_mv_target to replace plain String/LowCardinality(...) columns with the
corresponding AggregateFunction/State types that match the argMin/any semantics
used in sessions_mv (reference sessions_mv_target, sessions_mv,
AggregatingMergeTree, entry_pathname, argMin, country, hostname).
server/src/api/analytics/lite/getOverviewLite.ts-16-25 (1)

16-25: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate siteId and query params before building the query.

This handler trusts route/query input at runtime. A bad siteId becomes NaN here and will fall through to ClickHouse as a server error instead of returning a clean 4xx response.

As per coding guidelines, server/src/api/**/*.ts: Validate untrusted request bodies and query params with Zod before using them in route handlers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/api/analytics/lite/getOverviewLite.ts` around lines 16 - 25, The
handler getOverviewLite currently converts req.params.siteId to Number and uses
req.query without validation, allowing NaN or malformed queries to reach
ClickHouse; add Zod schemas to validate the route params (siteId as a
numeric/stringable integer) and the Querystring shape used by
getLiteTimeStatement before any conversion or query construction, then
parse/transform the validated values (e.g., convert siteId to number) and return
a 4xx Fastify reply (bad request) when validation fails; locate getOverviewLite
and the call to getLiteTimeStatement to insert this validation and error
handling.
server/src/api/analytics/lite/getMetricLite.ts-30-33 (1)

30-33: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clamp and validate these inputs before interpolating the query.

Math.min(Number(req.query.limit) || 100, 500) still allows negative limits, and siteId can become NaN. Both cases turn malformed client input into backend/query failures.

As per coding guidelines, server/src/api/**/*.ts: Validate untrusted request bodies and query params with Zod before using them in route handlers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/api/analytics/lite/getMetricLite.ts` around lines 30 - 33,
Validate and clamp request inputs using Zod in getMetricLite.ts: create a Zod
schema for req.params.siteId (coerce to number/integer), req.query.limit (coerce
to number, default 100, clamp between 1 and 500) and req.query.parameter
(validate allowed strings), then replace direct uses of
Number(req.params.siteId), req.query.limit and req.query.parameter with the
parsed/validated values; ensure you handle schema.parse/ safeParse errors by
returning a 400 response before calling getLiteTimeStatement or interpolating
into queries so site is never NaN and limit is never negative/out-of-range.
client/src/app/rollup/components/sections/PagesLite.tsx-10-14 (1)

10-14: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

This rollup conflates unrelated pages across sites.

The inline note here describes a real data bug: grouping only by pathname means every selected site's /about, /, etc. are merged into the same bucket. That makes the section misleading for multi-site rollups.

A safer interim behavior would be to hide this section when siteIds.length > 1 until the lite endpoint/materialized view can include a hostname/site dimension.

Also applies to: 31-41

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/src/app/rollup/components/sections/PagesLite.tsx` around lines 10 -
14, PagesLite currently groups metrics only by pathname and therefore conflates
pages across multiple sites; update the PagesLite component (function PagesLite
and its siteIds prop) to not render the section when multiple sites are selected
by checking siteIds.length > 1 and returning null (or an explanatory
placeholder) in that case, so the section is hidden for multi-site rollups until
the lite endpoint/materialized view includes a hostname/site dimension; apply
the same guard where similar logic is present in the related block referenced
(the other PagesLite usage around the same component).
docker-compose.yml-65-66 (1)

65-66: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid exposing Postgres on all interfaces by default.

"5432:5432" publishes the database externally on every host interface. That is a risky default for a production-ish compose file, especially since the other app services already bind to localhost by default.

Safer default
-    ports:
-      - "5432:5432"
+    ports:
+      - "${HOST_POSTGRES_PORT:-127.0.0.1:5432:5432}"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docker-compose.yml` around lines 65 - 66, The docker-compose service
currently exposes Postgres on all interfaces via the ports mapping "5432:5432";
change the ports mapping for the Postgres service (look for the "ports:" entry
and the "5432:5432" string) to bind only to localhost (e.g. use
"127.0.0.1:5432:5432") or remove the host-side mapping entirely and rely on the
internal Docker network so the database is not published externally by default.
server/src/api/sites/addSite.ts-63-85 (1)

63-85: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject unknown type values instead of coercing them to web.

FastifyRequest only gives you TypeScript types here. At runtime, any unexpected string falls through to "web", which silently creates the wrong site type and bypasses the mobile-only guardrails in this handler.

💡 Suggested fix
-  const siteType = type === "mobile" ? "mobile" : "web";
+  if (type != null && type !== "web" && type !== "mobile") {
+    return reply.status(400).send({
+      error: "Invalid site type. Expected 'web' or 'mobile'",
+    });
+  }
+
+  const siteType = type === "mobile" ? "mobile" : "web";

As per coding guidelines, "Validate untrusted request bodies and query params with Zod before using them in route handlers."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/api/sites/addSite.ts` around lines 63 - 85, The handler currently
coerces any unknown type value into "web" via const siteType = type === "mobile"
? "mobile" : "web", which can create incorrect sites and skip mobile guards;
change the logic to explicitly accept only "web" or "mobile" (reject any other
string with a 400) or validate the request body with Zod before using it;
specifically update the code around the siteType/type usage (and the early
validation block that follows) to return a 400 error for unknown type values
and/or replace the manual check with a Zod schema that enforces type: "web" |
"mobile" so downstream checks (domainRegex/appIdentifierRegex and mobile-only
feature guards) cannot be bypassed.
react-native/index.js-383-392 (1)

383-392: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Deduplicate navigation events by path, not only route name.

This skips valid pageviews when one screen component is reused for multiple URLs or param sets, e.g. /product/1/product/2 on the same route name.

💡 Suggested fix
-    let previousRouteName = null;
+    let previousRouteKey = null;
@@
-      const routeName = getRouteName(route);
-      if (!routeName || routeName === previousRouteName) return;
-
-      previousRouteName = routeName;
+      const routeName = getRouteName(route);
+      const path = asPathname(getPath(route));
+      const routeKey = `${routeName}:${path}`;
+      if (!routeName || routeKey === previousRouteKey) return;
+
+      previousRouteKey = routeKey;
       await client.screen(routeName, includeRouteParams && route?.params ? { routeParams: route.params } : undefined, {
-        pathname: asPathname(getPath(route)),
+        pathname: path,
         screen: routeName,
       });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@react-native/index.js` around lines 383 - 392, trackCurrentRoute currently
deduplicates by route name only (previousRouteName) which skips legitimate
navigations between different paths using the same component; compute the
current pathname (using asPathname(getPath(route)) or including route.params if
needed) into a variable (e.g., currentPath), compare against a new previousPath
variable instead of previousRouteName, return early if currentPath is unchanged,
and then set previousPath = currentPath before calling client.screen (keep
routeName for the screen label but use pathname/currentPath for deduplication).
client/src/api/analytics/hooks/useGetMetric.ts-97-110 (1)

97-110: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Remove filter-only inputs from the lite query key.

combinedFilters is correctly dropped from the request when lite is true, but filters, additionalFilters, and customFilters still participate in the key. That makes identical lite responses refetch and split cache entries whenever UI filters change.

💡 Suggested fix
-  return useQuery({
-    queryKey: [parameter, customTime, time, site, filters, limit, page, additionalFilters, customFilters, timezone, lite],
+  return useQuery({
+    queryKey: [parameter, customTime, time, site, combinedFilters, limit, page, timezone, lite],
     queryFn: async () => {

As per coding guidelines, "Keep React Query keys as descriptive arrays and include every input that affects the response."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/src/api/analytics/hooks/useGetMetric.ts` around lines 97 - 110, The
queryKey for useQuery still includes filter-only inputs even when lite is true;
update the key to match the actual request inputs by excluding filters,
additionalFilters, and customFilters when lite is true (since combinedFilters is
already undefined for lite and used in buildApiParams). Concretely, adjust the
queryKey construction in the useGetMetric hook (the useQuery call) to
conditionally include the filter entries (filters, additionalFilters,
customFilters) only when lite is false (or derive a keyFilters variable from
combinedFilters and include that), so identical lite queries don’t produce
separate cache entries.
client/src/api/analytics/hooks/useGetOverviewLite.ts-11-20 (1)

11-20: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Pass timezone into buildApiParams() in both lite hooks.

Both hooks vary their query key by timezone, but the request params do not. That makes date-based lite queries use the wrong backend window while still fragmenting the cache by timezone.

Suggested fix
 export function useGetOverviewLite(site?: number | string) {
   const { time, timezone } = useStore();
-  const params = buildApiParams(time);
+  const params = buildApiParams(time, timezone);
@@
 export function useGetOverviewBucketedLite({
@@
 }) {
   const { time, timezone } = useStore();
-  const params = buildApiParams(time);
+  const params = buildApiParams(time, timezone);

As per coding guidelines, "Use 'buildApiParams(time, timezone, filters?)' utility to convert time store state into API-ready parameters"

Also applies to: 23-38

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/src/api/analytics/hooks/useGetOverviewLite.ts` around lines 11 - 20,
The queryKey includes timezone but buildApiParams is called without it, causing
requests to use the wrong date window while still fragmenting the cache; update
useGetOverviewLite to call buildApiParams(time, timezone) (and do the same in
the sibling lite hook referenced at lines 23-38) so the API params include
timezone, leaving fetchOverviewLite(site!, params) and the queryKey unchanged.
client/src/app/[site]/main/components/MainSection/OverviewLite.tsx-57-71 (1)

57-71: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Honor pastMinutesEnd when trimming sparkline points.

This filter always uses now as the upper bound. For ranges like “120 to 60 minutes ago”, the chart can still show the trailing bucket from the last hour even though it is outside the selected window.

Suggested fix
       ?.filter(d => {
         if (time.mode === "past-minutes") {
           const timestamp = new Date(d.time);
           const now = new Date();
           const startTime = new Date(now.getTime() - time.pastMinutesStart * 60 * 1000);
-          return timestamp >= startTime && timestamp <= now;
+          const endTime = new Date(now.getTime() - time.pastMinutesEnd * 60 * 1000);
+          return timestamp >= startTime && timestamp <= endTime;
         }
         return true;
       })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/src/app/`[site]/main/components/MainSection/OverviewLite.tsx around
lines 57 - 71, The sparklinesData filtering always uses now as the upper bound,
ignoring the selected pastMinutesEnd, so update the filter in the sparklinesData
computation (where time.mode, time.pastMinutesStart and getBucketValue are used)
to compute both startTime and endTime based on now minus pastMinutesStart and
now minus pastMinutesEnd (or use now if pastMinutesEnd is zero/null) and compare
timestamp >= startTime && timestamp <= endTime; ensure you still map each entry
to { value: getBucketValue ? getBucketValue(d) : d[id], time: d.time } and
preserve existing behavior when time.mode !== "past-minutes".
server/src/services/tracker/identifyService.ts-82-88 (1)

82-88: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Do not derive identity from client-supplied IP or user agent.

Preferring ip_address / user_agent from the request body makes the anonymous fingerprint spoofable. A caller can forge another visitor's fingerprint, create an alias for it, and backfill that user's anonymous history onto an arbitrary user_id.

Suggested fix
     const anonymousId = anonymous_id
       ? await userIdService.generateUserIdFromClientId(anonymous_id, siteId)
       : await userIdService.generateUserId(
-          ip_address || getIpAddress(request),
-          user_agent || request.headers["user-agent"] || "",
+          getIpAddress(request),
+          request.headers["user-agent"] || "",
           siteId
         );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/services/tracker/identifyService.ts` around lines 82 - 88, The
current anonymousId construction uses client-supplied ip_address/user_agent from
the request body which allows spoofing; update the logic in identifyService.ts
where anonymous_id, generateUserIdFromClientId, generateUserId, ip_address,
user_agent, getIpAddress, and request are referenced so that when anonymous_id
is not provided you always call userIdService.generateUserId with server-derived
values only (use getIpAddress(request) and request.headers["user-agent"] or ""),
and ignore any ip_address or user_agent fields from the request body; keep
generateUserIdFromClientId usage unchanged for the anonymous_id path.
server/src/api/analytics/lite/utils.ts-55-64 (1)

55-64: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Align past-minute filtering to the MV's hour buckets.

This branch filters an hour-truncated column with minute-resolution timestamps. For recent windows like “last 30 minutes”, the current hour bucket gets excluded entirely, so lite charts/metrics can come back empty even though the MV has data.

Suggested fix
   if (sanitized.pastMinutesRange) {
     const { start, end } = sanitized.pastMinutesRange;
     const now = new Date();
     const startTimestamp = new Date(now.getTime() - start * 60 * 1000);
     const endTimestamp = new Date(now.getTime() - end * 60 * 1000);
     const startIso = startTimestamp.toISOString().slice(0, 19).replace("T", " ");
     const endIso = endTimestamp.toISOString().slice(0, 19).replace("T", " ");
 
-    return `AND ${column} > toDateTime(${SqlString.escape(startIso)}) AND ${column} <= toDateTime(${SqlString.escape(endIso)})`;
+    return `AND ${column} >= toStartOfHour(toDateTime(${SqlString.escape(startIso)}))
+      AND ${column} < toStartOfHour(toDateTime(${SqlString.escape(endIso)})) + INTERVAL 1 HOUR`;
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/api/analytics/lite/utils.ts` around lines 55 - 64, The
pastMinutesRange branch builds minute-resolution timestamps but the MV uses
hour-truncated buckets, so floor both computed timestamps to their hour
boundaries before creating the SQL predicate: compute startTimestamp and
endTimestamp from sanitized.pastMinutesRange as before, then for each do
timestamp.setMinutes(0,0,0) (or otherwise zero minutes/seconds) to get
hour-truncated startHour and endHour, format those to ISO strings and use
SqlString.escape(toHourIso) in the returned predicate (e.g. AND ${column} >=
toDateTime(escapedStartHour) AND ${column} <= toDateTime(escapedEndHour)) so the
filter aligns with the MV hour buckets.
client/Dockerfile-33-40 (1)

33-40: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Declare ARG NEXT_PUBLIC_DEPLOYMENT in the builder stage before using it.

client/Dockerfile sets ENV NEXT_PUBLIC_DEPLOYMENT=${NEXT_PUBLIC_DEPLOYMENT} in the builder stage, but ARG NEXT_PUBLIC_DEPLOYMENT is not declared earlier in that stage—so the value expands to empty during the image build.

Suggested fix
 ARG NEXT_PUBLIC_BACKEND_URL
 ARG NEXT_PUBLIC_DISABLE_SIGNUP
 ARG NEXT_PUBLIC_CLOUD
 ARG NEXT_PUBLIC_TURNSTILE_SITE_KEY
 ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
+ARG NEXT_PUBLIC_DEPLOYMENT
 ARG NEXT_PUBLIC_LITE_DASHBOARD
 ENV NEXT_PUBLIC_BACKEND_URL=${NEXT_PUBLIC_BACKEND_URL}
 ENV NEXT_PUBLIC_DISABLE_SIGNUP=${NEXT_PUBLIC_DISABLE_SIGNUP}
 ENV NEXT_PUBLIC_CLOUD=${NEXT_PUBLIC_CLOUD}
 ENV NEXT_PUBLIC_TURNSTILE_SITE_KEY=${NEXT_PUBLIC_TURNSTILE_SITE_KEY}
 ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY}
 ENV NEXT_PUBLIC_DEPLOYMENT=${NEXT_PUBLIC_DEPLOYMENT}
 ENV NEXT_PUBLIC_LITE_DASHBOARD=${NEXT_PUBLIC_LITE_DASHBOARD}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/Dockerfile` around lines 33 - 40, The builder stage in
client/Dockerfile uses ENV NEXT_PUBLIC_DEPLOYMENT=${NEXT_PUBLIC_DEPLOYMENT} but
never declares ARG NEXT_PUBLIC_DEPLOYMENT there, so the value expands empty; add
an ARG NEXT_PUBLIC_DEPLOYMENT declaration in the builder stage (next to ARG
NEXT_PUBLIC_LITE_DASHBOARD or other ARGs) before the ENV NEXT_PUBLIC_DEPLOYMENT
line so the build-time variable is available when setting the environment
variable.
client/messages/pt.json-836-850 (1)

836-850: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Don’t ship blank Portuguese strings for the new settings UI.

These new locale entries are still empty, so parts of the site settings/embed/tracking flows will show blank text in Portuguese. Please fill them in or intentionally fall back to English until the locale is ready.

As per coding guidelines, "Keep locale keys consistent across messages/*.json."

Also applies to: 1265-1282, 1383-1430

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/messages/pt.json` around lines 836 - 850, Several Portuguese locale
entries are empty (e.g., keys "KI57sV", "AZEGID", "JkLHGw", "CXI0EW", "VQxsRK",
"W35dz6" and other empty keys in the ranges noted) causing blank UI text;
populate these keys with proper Portuguese translations or, if translation not
ready, copy the English strings as intentional fallbacks to keep keys consistent
across messages/*.json and prevent blank UI in the site settings/embed/tracking
flows.
client/messages/es.json-836-850 (1)

836-850: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Don’t ship blank Spanish strings for the new settings UI.

These newly added Spanish entries are empty, so the site settings/embed/tracking flows will show missing text instead of localized copy. Please translate them or fall back to English rather than shipping blank strings.

As per coding guidelines, "Keep locale keys consistent across messages/*.json."

Also applies to: 1265-1282, 1383-1430

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/messages/es.json` around lines 836 - 850, Several message keys in the
Spanish locale (e.g., "KI57sV", "AZEGID", "JkLHGw", "CXI0EW", "VQxsRK",
"W35dz6") were left as empty strings and will render missing text in the
settings/embed/tracking UI; for each of those keys in the messages JSON add
proper Spanish translations or, if you cannot translate now, replace the empty
value with the English source string as a fallback so the UI shows text, and
ensure you keep the same locale keys consistent across other messages/*.json
files as noted (also address the same empty keys at the other ranges mentioned).
client/messages/cs.json-836-850 (1)

836-850: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Don’t ship blank Czech strings for the new settings UI.

Several newly added entries in these sections are still "", so labels and helper text in site settings/embed/tracking will render blank for Czech users. Please either provide translations here or fall back to the English copy until this locale is complete.

As per coding guidelines, "Keep locale keys consistent across messages/*.json."

Also applies to: 1265-1282, 1383-1430

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/messages/cs.json` around lines 836 - 850, Several Czech localization
entries for the new settings UI are empty and will render blank; fill in
translations (or temporarily copy the English strings) for the empty keys such
as "KI57sV", "AZEGID", "JkLHGw", "CXI0EW", "VQxsRK", and "W35dz6" (and the
similar blank entries in the other ranges you flagged) so labels/helper text for
site settings/embed/tracking are visible; keep the locale keys consistent with
other messages/*.json files while updating the same symbolic keys across
locales.
server/src/api/analytics/lite/getOverviewBucketedLite.ts-117-151 (1)

117-151: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate the querystring before building the SQL.

This handler trusts bucket, time_zone, and the date-range fields at runtime, but the FastifyRequest type only helps TypeScript. A malformed query will currently flow into liteBucket(...)/getLiteTimeStatement(...) and fail as a 500 instead of a 400.

As per coding guidelines, "Validate untrusted request bodies and query params with Zod before using them in route handlers."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/api/analytics/lite/getOverviewBucketedLite.ts` around lines 117 -
151, The handler getOverviewBucketedLite is using unvalidated req.query values
(bucket, time_zone, start_date/end_date/past_minutes_*) and passing them into
liteBucket, getLiteTimeStatement, TimeBucketToFn and SqlString.escape which can
cause 500s; add a Zod schema for the expected query params (bucket: TimeBucket
enum, optional time_zone string, optional
start_date/end_date/past_minutes_start/past_minutes_end) and validate req.query
at the start of getOverviewBucketedLite, returning a 400 on validation failure,
then use the parsed/typed values for liteBucket(...), getLiteTimeStatement(...),
getLiteFillClause(...) and TimeBucketToFn lookups so only validated inputs reach
those functions.
client/messages/pl.json-836-850 (1)

836-850: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Don’t ship blank Polish strings for the new settings UI.

A large chunk of the new locale entries is still "", which means the Polish version of site settings/embed/tracking will render with missing copy. Please complete these translations or use a fallback instead of blank values.

As per coding guidelines, "Keep locale keys consistent across messages/*.json."

Also applies to: 1265-1282, 1383-1430

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/messages/pl.json` around lines 836 - 850, Several locale entries in
the Polish messages file are empty (e.g., keys KI57sV, AZEGID, JkLHGw, CXI0EW,
VQxsRK, W35dz6 and the other ranges noted) causing missing UI copy for
settings/embed/tracking; populate each empty string with an appropriate Polish
translation or, if untranslated, copy the canonical English fallback value from
the default messages file so the UI displays correctly. Ensure you update all
empty keys in the other indicated ranges (around 1265-1282 and 1383-1430) and
keep the locale keys identical to other messages/*.json files so the message
lookup remains consistent across locales.
client/src/components/SiteSettings/ScriptBuilder.tsx-79-112 (1)

79-112: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid using globalThis.location.origin during render in ScriptBuilder
client/src/components/SiteSettings/ScriptBuilder.tsx builds trackingScript and reactNativeSnippet using globalThis.location.origin directly in the component render path. Even with "use client", Next.js renders client components on the server for the initial HTML, where location may be unavailable—this can throw before hydration and break the settings page. Compute origin only on the client (e.g., useEffect + mounted gate) or pass it in from server/config, then render/build the snippets once origin is defined.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/src/components/SiteSettings/ScriptBuilder.tsx` around lines 79 - 112,
The component ScriptBuilder currently uses globalThis.location.origin directly
when constructing trackingScript and reactNativeSnippet which can run during
server render; change ScriptBuilder to obtain origin only on the client (e.g.
add a mounted gate using useState/useEffect or accept an origin prop from
server) and defer building trackingScript and reactNativeSnippet until origin is
defined; ensure you reference the same identifiers (trackingScript,
reactNativeSnippet, siteId, appIdentifier) and guard any JSX that uses these
strings so nothing accesses globalThis.location during SSR.
client/messages/ko.json-1265-1283 (1)

1265-1283: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Blank translations in active settings/embed flows will surface empty UI text

Several changed keys in this block are now "" (for example around Line 1271, Line 1276, Line 1282, Line 1304, Line 1306, Line 1328, Line 1420, Line 1422, Line 1430). These are user-visible labels/help text in dashboard embed and tracking settings, so the UI will show missing text in Korean.

Also applies to: 1304-1307, 1328-1330, 1383-1385, 1420-1431

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/messages/ko.json` around lines 1265 - 1283, Some localization keys in
client/messages/ko.json have been left as empty strings (e.g., "+CwN9C",
"dmW5bt", "PkGCG1", "UcHF+A", "6NIu/x", "QRvU5C", "Ygzo5N", "KOXkeN", "PRAz+o",
"WNF1J6", "oJEVob", "0uO8I3" and others in the embed/tracking block) which will
render blank UI text; open the ko.json block around the dashboard embed/tracking
keys and replace each "" with the correct Korean translation or a fallback
string matching the English source so user-visible labels/help text (the
dashboard embed and tracking settings) are not empty; ensure every affected key
mentioned in the diff is populated and verify similar empty keys at the other
ranges called out (1304-1307, 1328-1330, 1383-1385, 1420-1431).
client/messages/de.json-1265-1283 (1)

1265-1283: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

German locale has newly blank strings in embed and tracking settings

A number of changed German messages in these ranges are now empty strings (not placeholders in dead code), which will surface as missing text in the UI. Please restore translations or fallback-safe values before merge.

Also applies to: 1304-1307, 1328-1330, 1383-1385, 1420-1431

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/messages/de.json` around lines 1265 - 1283, Several German locale
entries were left as empty strings causing missing UI text; for each empty key
shown (e.g. "+CwN9C", "dmW5bt", "PkGCG1", "UcHF+A", "6NIu/x", "QRvU5C",
"Ygzo5N", "KOXkeN", "PRAz+o", "WNF1J6", "0uO8I3", "CHzo7t", etc.) and the other
empty items in the mentioned ranges (1304-1307, 1328-1330, 1383-1385, 1420-1431)
restore proper German translations or safe fallbacks (e.g. the English string)
so the UI won’t show blanks; update the values in the de.json entries for those
keys and verify that keys like "mBCW/X" ("Dashboard-Einbettung"), "FsIP28"
("Seitenleiste ausblenden"), "dZAp5K", "JfG49w", "TJo5E6", and "Pe0ogR" remain
correct while populating the empty strings.
client/messages/it.json-1265-1283 (1)

1265-1283: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Italian translations were cleared in active UI flows

Changed entries across embed/domain/tracking settings are now "" (for example Line 1271, Line 1276, Line 1282, Line 1304, Line 1306, Line 1328, Line 1420, Line 1422, Line 1430). These should not ship empty because they drive visible labels and helper copy.

Also applies to: 1304-1307, 1328-1330, 1383-1385, 1420-1431

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/messages/it.json` around lines 1265 - 1283, Several Italian
translation keys used in embed/domain/tracking UI were accidentally cleared
(e.g., "mBCW/X","FsIP28","dZAp5K","CHzo7t" and other empty keys like
"+CwN9C","dmW5bt","PkGCG1"), causing visible labels/helper copy to be empty;
restore accurate Italian strings for each emptied key in the same JSON structure
(fill the empty values with the appropriate translations for embed/dashboard
labels, toggles, helper text and "Embed code"/"Preview"/"Open" actions) and
verify the related groups around the embed/domain/tracking settings (the blocks
containing those keys) so no UI-facing label remains "" before merging.
client/messages/fr.json-1265-1283 (1)

1265-1283: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

French locale regresses to blank strings in critical configuration screens

A large set of changed French messages is now empty ("") in embed/domain/tracking sections (e.g., Line 1271, Line 1276, Line 1282, Line 1304, Line 1306, Line 1328, Line 1420, Line 1422, Line 1430). This will render blank text in active UI paths.

Also applies to: 1304-1307, 1328-1330, 1383-1385, 1420-1431

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/messages/fr.json` around lines 1265 - 1283, Several French locale
entries in client/messages/fr.json have been accidentally cleared to empty
strings (e.g., keys "+CwN9C", "dmW5bt", "PkGCG1", "UcHF+A", "6NIu/x", "QRvU5C",
"Ygzo5N", "KOXkeN", "PRAz+o", "oJEVob", "0uO8I3", etc.) causing blank UI labels
in embed/domain/tracking and related screens; restore the original French
translations for those keys (or provide appropriate new translations) so entries
like those around "mBCW/X":"Intégration du tableau de bord", "Pe0ogR":"Thème",
"FsIP28":"Masquer la barre latérale", "dZAp5K":"Seule la page principale du
tableau de bord peut être consultée depuis cette intégration.",
"JfG49w":"Ouverte", "TJo5E6":"Aperçu", "CHzo7t":"Code d'intégration" remain
consistent and ensure no empty string values remain in the embed/domain/tracking
sections before merging.
🧹 Nitpick comments (1)
client/src/app/[site]/main/components/sections/CountriesLite.tsx (1)

24-25: ⚡ Quick win

Consider toggling the icon based on expanded state.

The button always shows the Expand icon, even when the section is expanded. For better UX, consider using a conditional icon that switches between Expand and Shrink (or similar) based on the expanded state.

♻️ Proposed enhancement
+import { Expand, Shrink } from "lucide-react";
-import { Expand } from "lucide-react";
 
 ...
 
 <Button size="smIcon" onClick={() => setExpanded(!expanded)}>
-  <Expand className="w-4 h-4" />
+  {expanded ? <Shrink className="w-4 h-4" /> : <Expand className="w-4 h-4" />}
 </Button>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/src/app/`[site]/main/components/sections/CountriesLite.tsx around
lines 24 - 25, The toggle button currently always renders the Expand icon;
update the onClick render to choose the icon based on the expanded state (e.g.,
render Expand when !expanded and Shrink (or Collapse) when expanded) by changing
the JSX inside the Button in CountriesLite.tsx to use a conditional like
expanded ? <Shrink /> : <Expand /> and keep the existing onClick={() =>
setExpanded(!expanded)}; also add or adjust the import for the Shrink/Collapse
icon component if it isn't already imported so the conditional icon renders
correctly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d8979bf7-f761-4438-90b6-5c76a9f68e9d

📥 Commits

Reviewing files that changed from the base of the PR and between 2a54f6c and 0cf1696.

📒 Files selected for processing (91)
  • client/Dockerfile
  • client/messages/cs.json
  • client/messages/de.json
  • client/messages/en.json
  • client/messages/es.json
  • client/messages/fr.json
  • client/messages/it.json
  • client/messages/ja.json
  • client/messages/ko.json
  • client/messages/pl.json
  • client/messages/pt.json
  • client/messages/uk.json
  • client/messages/zh.json
  • client/next.config.ts
  • client/src/api/admin/endpoints/adminSites.ts
  • client/src/api/admin/endpoints/sites.ts
  • client/src/api/analytics/endpoints/index.ts
  • client/src/api/analytics/endpoints/overview.ts
  • client/src/api/analytics/hooks/useGetMetric.ts
  • client/src/api/analytics/hooks/useGetOverviewLite.ts
  • client/src/app/[site]/components/Sidebar/Sidebar.tsx
  • client/src/app/[site]/components/Sidebar/SiteSelector.tsx
  • client/src/app/[site]/components/shared/StandardSection/StandardSection.tsx
  • client/src/app/[site]/components/shared/StandardSection/StandardSectionDialog.tsx
  • client/src/app/[site]/main/components/MainSection/MainSectionLite.tsx
  • client/src/app/[site]/main/components/MainSection/OverviewLite.tsx
  • client/src/app/[site]/main/components/sections/CountriesLite.tsx
  • client/src/app/[site]/main/components/sections/DevicesLite.tsx
  • client/src/app/[site]/main/components/sections/PagesLite.tsx
  • client/src/app/[site]/main/page.tsx
  • client/src/app/components/AddSite.tsx
  • client/src/app/rollup/components/MainSection/MainSection.tsx
  • client/src/app/rollup/components/sections/CountriesLite.tsx
  • client/src/app/rollup/components/sections/PagesLite.tsx
  • client/src/app/rollup/components/sections/RollupSection.tsx
  • client/src/app/rollup/components/sections/RollupSectionDialog.tsx
  • client/src/app/rollup/hooks/useRollupBucketed.ts
  • client/src/app/rollup/hooks/useRollupMetric.ts
  • client/src/app/rollup/page.tsx
  • client/src/components/AppSidebar.tsx
  • client/src/components/SiteSettings/GeneralTab.tsx
  • client/src/components/SiteSettings/ScriptBuilder.tsx
  • client/src/components/SiteSettings/SiteSettings.tsx
  • client/src/components/SiteSettings/TrackingTab.tsx
  • client/src/lib/configs.ts
  • client/src/lib/const.ts
  • docker-compose.yml
  • docs/content/docs/sdks/meta.json
  • docs/content/docs/sdks/react-native.mdx
  • docs/messages/de.json
  • docs/messages/en.json
  • docs/messages/es.json
  • docs/messages/fr.json
  • docs/messages/it.json
  • docs/messages/ja.json
  • docs/messages/ko.json
  • docs/messages/pl.json
  • docs/messages/pt.json
  • docs/messages/zh.json
  • react-native/README.md
  • react-native/index.d.ts
  • react-native/index.js
  • react-native/package.json
  • server/drizzle/0007_site_type.sql
  • server/drizzle/meta/0006_snapshot.json
  • server/drizzle/meta/0007_snapshot.json
  • server/drizzle/meta/_journal.json
  • server/package.json
  • server/src/api/admin/getAdminSites.ts
  • server/src/api/analytics/index.ts
  • server/src/api/analytics/lite/getMetricLite.ts
  • server/src/api/analytics/lite/getOverviewBucketedLite.ts
  • server/src/api/analytics/lite/getOverviewLite.ts
  • server/src/api/analytics/lite/utils.ts
  • server/src/api/getConfig.ts
  • server/src/api/sites/addSite.ts
  • server/src/api/sites/getSite.ts
  • server/src/api/sites/getSitesFromOrg.ts
  • server/src/api/sites/getTrackingConfig.ts
  • server/src/api/sites/updateSiteConfig.ts
  • server/src/cluster.ts
  • server/src/db/clickhouse/clickhouse.ts
  • server/src/db/postgres/schema.ts
  • server/src/index.ts
  • server/src/lib/const.ts
  • server/src/lib/siteConfig.ts
  • server/src/scripts/backfillLiteDashboardMVs.ts
  • server/src/services/tracker/identifyService.ts
  • server/src/services/tracker/trackEvent.ts
  • server/src/services/tracker/utils.ts
  • server/src/services/userId/userIdService.ts

Comment thread server/src/api/analytics/lite/getMetricLite.ts Outdated
Comment thread server/src/index.ts
- Introduced a new API endpoint for creating users within an organization, enhancing user management capabilities.
- Updated the frontend to utilize the new create user functionality, replacing the previous add user method.
- Added necessary types and hooks to support the new feature, ensuring a seamless integration into the existing workflow.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

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

⚠️ Outside diff range comments (2)
server/src/api/analytics/lite/utils.ts (1)

89-124: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reuse validateTimeStatementParams in getLiteFillClause to prevent runtime RangeError and malformed WITH FILL SQL

getOverviewBucketedLite calls getLiteFillClause(req.query, bucket) directly (the publicSite preHandler only resolves/authorizes siteId), while getLiteFillClause itself does not sanitize/parse. As a result:

  • Invalid past_minutes_start/past_minutes_end (e.g., non-numeric strings) make Number(...) produce NaN, so new Date(NaN).toISOString() can throw RangeError: Invalid time value → 500 (server/src/api/analytics/lite/utils.ts).
  • Invalid start_date/end_date/time_zone can lead to a ClickHouse-rejected WITH FILL fragment instead of being safely rejected/sanitized (server/src/api/analytics/lite/utils.ts, call site server/src/api/analytics/lite/getOverviewBucketedLite.ts).

Refactor getLiteFillClause to use validateTimeStatementParams (parity with getLiteTimeStatement) and build the fill clause only from the sanitized result.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/api/analytics/lite/utils.ts` around lines 89 - 124, Call
validateTimeStatementParams at the start of getLiteFillClause and use its
sanitized return values instead of reading raw params directly: replace direct
access to
params.{start_date,end_date,time_zone,past_minutes_start,past_minutes_end} and
any Number()/new Date()/toISOString() calls with the validated/sanitized fields
returned by validateTimeStatementParams so malformed inputs won't produce
NaN/RangeError or invalid WITH FILL fragments; keep the same SQL construction
logic and references to TimeBucketToFn and bucketIntervalMap but build the
FROM/TO/STEP clauses from the validated values (mirroring how
getLiteTimeStatement is implemented) so getOverviewBucketedLite can safely call
getLiteFillClause.
client/src/api/analytics/hooks/useGetMetric.ts (1)

107-108: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fix React Query cache keys to include useFilters.

In usePaginatedMetric (around lines 107–108) and useInfiniteMetric (around 161–174), useFilters changes request params via combinedFilters, but neither queryKey includes useFilters, so filtered/unfiltered calls can reuse the wrong cache. Add useFilters to both query keys.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/src/api/analytics/hooks/useGetMetric.ts` around lines 107 - 108, Both
usePaginatedMetric and useInfiniteMetric build request params with
combinedFilters from useFilters but the queryKey arrays (in the
useQuery/useInfiniteQuery calls) don't include useFilters, causing cached
results to be reused incorrectly; update the queryKey entries in
usePaginatedMetric (the useQuery call around the returned queryKey currently
listing parameter, customTime, time, site, filters, limit, page,
additionalFilters, customFilters, timezone, lite) and in useInfiniteMetric (the
useInfiniteQuery call around lines 161–174) to include useFilters so the cache
differentiates filtered vs unfiltered requests.
🧹 Nitpick comments (1)
server/src/api/user/createUserInOrganization.ts (1)

63-76: ⚡ Quick win

Prefer Zod for body validation.

The body is validated manually. Validating request.body with a Zod schema (email/name/password/role) would centralize the rules and guard against missing/malformed bodies before destructuring at Line 46. As per coding guidelines, "Validate untrusted request bodies and query params with Zod before using them in route handlers."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/api/user/createUserInOrganization.ts` around lines 63 - 76,
Replace the manual validation in createUserInOrganization with a Zod schema:
define a schema (e.g., CreateUserBodySchema) that requires email (email()), name
(optional/string), password (min length 8), and role (enum of
"admin","member","owner"), then validate request.body with schema.parse or
schema.safeParse at the top of the handler before you read
rawEmail/password/role; on validation failure return reply.status(400) with the
Zod error details so subsequent code (where rawEmail, password, role are used)
can safely assume the types/constraints.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@server/src/api/analytics/lite/getMetricLite.ts`:
- Line 39: The current limit calculation (const limit =
Math.min(Number(req.query.limit) || 100, 500)) allows negative values and
non-integers; change it to parse and clamp to a positive integer:
parseInt(req.query.limit, 10) (fall back to 100 if NaN), then apply Math.max(1,
Math.min(parsed, 500)) so limit is an integer between 1 and 500. Update the
const limit in getMetricLite (the line using Number(req.query.limit)) to use
this parsing and clamping approach.

In `@server/src/api/user/createUserInOrganization.ts`:
- Around line 84-114: The current flow creates a user via
ctx.internalAdapter.createUser and ctx.internalAdapter.linkAccount then inserts
the org member via db.insert(member); if the member insert fails the created
user/account are orphaned—wrap the member insert in a try/catch and on failure
perform compensating cleanup by calling the appropriate adapter methods to
remove the created credential and user (e.g., reverse
ctx.internalAdapter.linkAccount and ctx.internalAdapter.deleteUser or a provided
remove method for account/user) and then return a 500; alternatively, detect an
existing user with no membership earlier and attach the new member record to
that user instead of creating a new account (check in the duplicate-email branch
for a membership-less user and insert member for that existing user) so retries
do not leave unusable accounts.
- Around line 115-118: In the catch block of createUserInOrganization
(server/src/api/user/createUserInOrganization.ts) stop returning String(error)
to clients; instead log the full error server-side (e.g., console.error or your
app logger with the caught error object) and return a generic 500 response like
{ error: "Internal server error" } to the client; update the catch clause around
the createUserInOrganization logic to use the logger for detailed error output
and change reply.status(500).send(...) to a non-sensitive generic message.
- Around line 49-72: The handler createUserInOrganization currently allows
org-level admins to create a new owner; after the existing role validation, add
a guard that if role === "owner" then only permit it when the requester is a
system admin (isAdmin === true) or the requester's userMembership.role ===
"owner"; otherwise return an appropriate 403/401 response. Use the existing
isAdmin and userMembership variables (ensure userMembership is checked for
existence when isAdmin is false) to enforce this restriction.

---

Outside diff comments:
In `@client/src/api/analytics/hooks/useGetMetric.ts`:
- Around line 107-108: Both usePaginatedMetric and useInfiniteMetric build
request params with combinedFilters from useFilters but the queryKey arrays (in
the useQuery/useInfiniteQuery calls) don't include useFilters, causing cached
results to be reused incorrectly; update the queryKey entries in
usePaginatedMetric (the useQuery call around the returned queryKey currently
listing parameter, customTime, time, site, filters, limit, page,
additionalFilters, customFilters, timezone, lite) and in useInfiniteMetric (the
useInfiniteQuery call around lines 161–174) to include useFilters so the cache
differentiates filtered vs unfiltered requests.

In `@server/src/api/analytics/lite/utils.ts`:
- Around line 89-124: Call validateTimeStatementParams at the start of
getLiteFillClause and use its sanitized return values instead of reading raw
params directly: replace direct access to
params.{start_date,end_date,time_zone,past_minutes_start,past_minutes_end} and
any Number()/new Date()/toISOString() calls with the validated/sanitized fields
returned by validateTimeStatementParams so malformed inputs won't produce
NaN/RangeError or invalid WITH FILL fragments; keep the same SQL construction
logic and references to TimeBucketToFn and bucketIntervalMap but build the
FROM/TO/STEP clauses from the validated values (mirroring how
getLiteTimeStatement is implemented) so getOverviewBucketedLite can safely call
getLiteFillClause.

---

Nitpick comments:
In `@server/src/api/user/createUserInOrganization.ts`:
- Around line 63-76: Replace the manual validation in createUserInOrganization
with a Zod schema: define a schema (e.g., CreateUserBodySchema) that requires
email (email()), name (optional/string), password (min length 8), and role (enum
of "admin","member","owner"), then validate request.body with schema.parse or
schema.safeParse at the top of the handler before you read
rawEmail/password/role; on validation failure return reply.status(400) with the
Zod error details so subsequent code (where rawEmail, password, role are used)
can safely assume the types/constraints.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 430186f2-a3c2-45eb-8249-610c28827302

📥 Commits

Reviewing files that changed from the base of the PR and between 0cf1696 and f44628f.

📒 Files selected for processing (13)
  • client/src/api/admin/endpoints/index.ts
  • client/src/api/admin/endpoints/organizations.ts
  • client/src/api/admin/hooks/useOrganizations.ts
  • client/src/api/analytics/hooks/useGetMetric.ts
  • client/src/api/analytics/hooks/useGetOverviewLite.ts
  • client/src/app/settings/organization/components/CreateUserDialog.tsx
  • server/src/api/analytics/lite/getMetricLite.ts
  • server/src/api/analytics/lite/getOverviewBucketedLite.ts
  • server/src/api/analytics/lite/getOverviewLite.ts
  • server/src/api/analytics/lite/utils.ts
  • server/src/api/user/createUserInOrganization.ts
  • server/src/api/user/index.ts
  • server/src/index.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • server/src/api/analytics/lite/getOverviewLite.ts
  • client/src/api/analytics/hooks/useGetOverviewLite.ts
  • server/src/api/analytics/lite/getOverviewBucketedLite.ts


const site = Number(req.params.siteId);
const { parameter } = req.query;
const limit = Math.min(Number(req.query.limit) || 100, 500);
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 | ⚡ Quick win

Clamp limit to a positive integer.

Number(req.query.limit) || 100 lets negatives and fractions through (e.g. ?limit=-5LIMIT -5, ?limit=50.5LIMIT 50.5), which ClickHouse rejects and surfaces as a 500.

🔧 Proposed fix
-  const limit = Math.min(Number(req.query.limit) || 100, 500);
+  const limit = Math.min(Math.max(Math.floor(Number(req.query.limit)) || 100, 1), 500);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/api/analytics/lite/getMetricLite.ts` at line 39, The current limit
calculation (const limit = Math.min(Number(req.query.limit) || 100, 500)) allows
negative values and non-integers; change it to parse and clamp to a positive
integer: parseInt(req.query.limit, 10) (fall back to 100 if NaN), then apply
Math.max(1, Math.min(parsed, 500)) so limit is an integer between 1 and 500.
Update the const limit in getMetricLite (the line using Number(req.query.limit))
to use this parsing and clamping approach.

Comment on lines +49 to +72
// Authorization: system admin OR owner/admin of this organization
const isAdmin = await getIsUserAdmin(request);
if (!isAdmin) {
if (!userId) {
return reply.status(401).send({ error: "Unauthorized" });
}
const userMembership = await db.query.member.findFirst({
where: and(eq(member.userId, userId), eq(member.organizationId, organizationId)),
});
if (!userMembership || (userMembership.role !== "admin" && userMembership.role !== "owner")) {
return reply.status(401).send({ error: "Unauthorized" });
}
}

// Validate input
if (!rawEmail || !password || !role) {
return reply.status(400).send({ error: "Missing required fields: email, password and role" });
}

const email = rawEmail.toLowerCase();

if (role !== "admin" && role !== "member" && role !== "owner") {
return reply.status(400).send({ error: "Role must be either admin, member, or owner" });
}
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 | ⚡ Quick win

Privilege escalation: an org admin can mint an owner.

The authorization at Line 58 admits any admin or owner member, but the role check at Line 70 permits creating an owner regardless of the requester's role. A non-system, org-level admin can therefore create a new owner account they control and escalate privileges. Restrict assigning the owner role to system admins and existing owners.

🔒 Proposed fix to gate the owner role
     const isAdmin = await getIsUserAdmin(request);
+    let requesterRole: string | undefined;
     if (!isAdmin) {
       if (!userId) {
         return reply.status(401).send({ error: "Unauthorized" });
       }
       const userMembership = await db.query.member.findFirst({
         where: and(eq(member.userId, userId), eq(member.organizationId, organizationId)),
       });
       if (!userMembership || (userMembership.role !== "admin" && userMembership.role !== "owner")) {
         return reply.status(401).send({ error: "Unauthorized" });
       }
+      requesterRole = userMembership.role;
     }

…then, after the role validation:

     if (role !== "admin" && role !== "member" && role !== "owner") {
       return reply.status(400).send({ error: "Role must be either admin, member, or owner" });
     }
+
+    // Only system admins or existing owners may grant the owner role.
+    if (role === "owner" && !isAdmin && requesterRole !== "owner") {
+      return reply.status(403).send({ error: "Only owners can assign the owner role" });
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/api/user/createUserInOrganization.ts` around lines 49 - 72, The
handler createUserInOrganization currently allows org-level admins to create a
new owner; after the existing role validation, add a guard that if role ===
"owner" then only permit it when the requester is a system admin (isAdmin ===
true) or the requester's userMembership.role === "owner"; otherwise return an
appropriate 403/401 response. Use the existing isAdmin and userMembership
variables (ensure userMembership is checked for existence when isAdmin is false)
to enforce this restriction.

Comment on lines +84 to +114
// Create the user + credential account (mirrors better-auth's admin createUser)
const createdUser = await ctx.internalAdapter.createUser({
email,
name: name || email,
emailVerified: false,
});

if (!createdUser) {
return reply.status(500).send({ error: "Failed to create user" });
}

const hashedPassword = await ctx.password.hash(password);
await ctx.internalAdapter.linkAccount({
accountId: createdUser.id,
providerId: "credential",
password: hashedPassword,
userId: createdUser.id,
});

// Add the new user to the organization
await db.insert(member).values([
{
userId: createdUser.id,
organizationId: organizationId,
role: role,
id: generateId(),
createdAt: new Date().toISOString(),
},
]);

return reply.status(201).send({ message: "User created and added to organization successfully" });
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 | 🏗️ Heavy lift

Partial failure leaves an orphaned user that can never be retried.

User creation (createUser + linkAccount) and the member insert are not atomic and span two systems. If the db.insert(member) at Line 104 fails, the user/credential account already exists, so every retry now short-circuits at the duplicate check (Lines 80-82) with "A user with this email already exists" — yet the user was never added to the org. Add compensating cleanup (or reconcile an existing membership-less user) so failures don't leave an unusable account.

Want me to draft a version that rolls back the created user/account on member-insert failure (or links membership to a pre-existing membership-less user)?

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/api/user/createUserInOrganization.ts` around lines 84 - 114, The
current flow creates a user via ctx.internalAdapter.createUser and
ctx.internalAdapter.linkAccount then inserts the org member via
db.insert(member); if the member insert fails the created user/account are
orphaned—wrap the member insert in a try/catch and on failure perform
compensating cleanup by calling the appropriate adapter methods to remove the
created credential and user (e.g., reverse ctx.internalAdapter.linkAccount and
ctx.internalAdapter.deleteUser or a provided remove method for account/user) and
then return a 500; alternatively, detect an existing user with no membership
earlier and attach the new member record to that user instead of creating a new
account (check in the duplicate-email branch for a membership-less user and
insert member for that existing user) so retries do not leave unusable accounts.

Comment on lines +115 to +118
} catch (error: any) {
console.error(String(error));
return reply.status(500).send({ error: String(error) });
}
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 | ⚡ Quick win

Don't return raw error strings to the client.

String(error) can surface internal details (DB/driver messages, query fragments) to the caller. Log the full error server-side and return a generic message.

🛡️ Proposed fix
   } catch (error: any) {
-    console.error(String(error));
-    return reply.status(500).send({ error: String(error) });
+    console.error("Error creating user in organization:", error);
+    return reply.status(500).send({ error: "Failed to create user" });
   }
📝 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 (error: any) {
console.error(String(error));
return reply.status(500).send({ error: String(error) });
}
} catch (error: any) {
console.error("Error creating user in organization:", error);
return reply.status(500).send({ error: "Failed to create user" });
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/api/user/createUserInOrganization.ts` around lines 115 - 118, In
the catch block of createUserInOrganization
(server/src/api/user/createUserInOrganization.ts) stop returning String(error)
to clients; instead log the full error server-side (e.g., console.error or your
app logger with the caught error object) and return a generic 500 response like
{ error: "Internal server error" } to the client; update the catch clause around
the createUserInOrganization logic to use the logger for detailed error output
and change reply.status(500).send(...) to a non-sensitive generic message.

…o use StandardSectionTabs

- Replaced the previous expandable section layout with StandardSectionTabs for a more streamlined user experience.
- Updated the components to define tabs for countries, devices, and pages, enhancing organization and accessibility of information.
- Removed unnecessary state management and UI elements, simplifying the component structure.
- Changed the default limit for metrics in getMetricLite from 100 to 250, enhancing the data retrieval process.
- This adjustment aims to provide a more comprehensive dataset while still capping the maximum limit at 500.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (2)
server/src/api/analytics/lite/getMetricLite.ts (2)

130-189: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Remove the committed benchmark block with the live bearer token.

This block still contains a real-looking API key and internal endpoint details. Even inside a comment, that's a credential exposure and should not ship. Move the recipe to internal docs and replace every secret with obvious placeholders before keeping any of it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/api/analytics/lite/getMetricLite.ts` around lines 130 - 189,
Delete the committed benchmark block that contains the live bearer token and
internal endpoints (the commented section containing BASE_URL, API_KEY, SITE_ID,
START, END, TZ and the curl bench/warmup scripts); either remove it entirely
from getMetricLite.ts or replace every sensitive value (API_KEY, BASE_URL,
SITE_ID) with obvious placeholders like "<API_KEY_PLACEHOLDER>" and add a short
comment pointing to an internal docs location for the recipe; ensure no real
secrets remain in the file.

39-39: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clamp limit to a positive integer before interpolating it into SQL.

Number(req.query.limit) || 250 still accepts values like -5 or 50.5, which then flow into LIMIT ${limit} and turn a bad request into a 500. Please parse and bound this to an integer in the 1..500 range first. As per coding guidelines, "Validate untrusted request bodies and query params with Zod before using them in route handlers."

🔧 Proposed fix
-  const limit = Math.min(Number(req.query.limit) || 250, 500);
+  const parsedLimit = Number.parseInt(String(req.query.limit ?? 250), 10);
+  const limit = Math.max(1, Math.min(Number.isNaN(parsedLimit) ? 250 : parsedLimit, 500));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/api/analytics/lite/getMetricLite.ts` at line 39, The current line
computing limit allows negative or non-integer values into the SQL LIMIT; update
the request validation so req.query.limit is parsed as an integer and clamped to
the 1..500 range before interpolation. Specifically, convert req.query.limit to
a numeric integer (e.g., parseInt/Math.floor), handle NaN by defaulting to 250,
then bound it with Math.max(1, Math.min(parsedLimit, 500)) and use that variable
(still named limit) in the SQL; additionally, add a Zod schema for the query (or
validate early in the route handler) to ensure limit is an integer within 1..500
before executing getMetricLite logic.
🧹 Nitpick comments (3)
client/src/app/[site]/main/components/sections/PagesLite.tsx (1)

3-36: ⚡ Quick win

Use useTranslations() for the new user-facing labels.

This new section also uses useExtracted() for visible copy. The client/src convention is useTranslations(), so it would be better to align this file now rather than introduce a second i18n pattern. As per coding guidelines, "Use next-intl's 'useTranslations()' hook for i18n; maintain translation files in messages/ directory (en.json, de.json, etc.)."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/src/app/`[site]/main/components/sections/PagesLite.tsx around lines 3
- 36, Replace useExtracted with next-intl's useTranslations in PagesLite: change
the import and the hook call (remove useExtracted, import and call
useTranslations in PagesLite), then update t("...") usage to match
useTranslations semantics (same string keys) for labels in the tabs definition
(label, section.title, getLabel fallback). Ensure imports at top reflect
useTranslations and remove unused useExtracted; keep siteMetadata usage and
StandardSectionTabs integration unchanged.
client/src/app/[site]/main/components/sections/CountriesLite.tsx (1)

3-36: ⚡ Quick win

Use useTranslations() for the new user-facing labels.

This component uses useExtracted() for visible copy, but the client/src i18n guideline standardizes on useTranslations() for user-facing strings. Please switch this new section to the shared pattern before it spreads further. As per coding guidelines, "Use next-intl's 'useTranslations()' hook for i18n; maintain translation files in messages/ directory (en.json, de.json, etc.)."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/src/app/`[site]/main/components/sections/CountriesLite.tsx around
lines 3 - 36, The component CountriesLite currently imports and calls
useExtracted() for visible UI text; replace that with next-intl's
useTranslations() hook to follow client/src i18n guidelines: import
useTranslations, replace const t = useExtracted() with const t =
useTranslations(/* optional namespace */) and keep all uses of t("...")
unchanged, ensure translation keys exist in messages/*.json (e.g., en.json) and
remove the useExtracted import; update any tests or type usages if they
reference useExtracted in CountriesLite.
client/src/app/[site]/main/components/sections/DevicesLite.tsx (1)

3-34: ⚡ Quick win

Use useTranslations() for the new user-facing labels.

This file introduces another useExtracted() call on visible UI text. Please keep the new lite sections on the standard useTranslations() path used in client/src. As per coding guidelines, "Use next-intl's 'useTranslations()' hook for i18n; maintain translation files in messages/ directory (en.json, de.json, etc.)."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/src/app/`[site]/main/components/sections/DevicesLite.tsx around lines
3 - 34, The component DevicesLite is using useExtracted() for visible UI text;
replace it with next-intl's useTranslations() to follow the project's i18n
convention: import useTranslations from "next-intl", change the t =
useExtracted() to t = useTranslations(/* appropriate namespace or key prefix
used across client/src */), and keep all existing uses of t (label, title,
getLabel fallback) intact; ensure the translation keys referenced (e.g.,
"Devices" and "Other") exist in the messages/ JSON files so the
StandardSectionTabs (tabs, getLabel, lite) renders with the correct localized
strings.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@client/src/components/BucketSelection.tsx`:
- Around line 77-82: availableOptions filtering can yield an empty list in lite
mode because the LITE_DASHBOARD branch removes sub-hour options and the existing
durationMinutes check still blocks "hour"; update the logic in the
availableOptions calculation (the filter that uses LITE_DASHBOARD and
bucketDurationMinutes(option)) to provide a lite-mode fallback: when
LITE_DASHBOARD is true and the filtered result would be empty, include at least
"hour" (or the first valid coarser bucket from getOptions()) so the
Select/SelectItem list never renders empty; use the existing helpers
bucketDurationMinutes(option) and getOptions()/durationMinutes to pick the
fallback option and ensure the <Select> always has at least one SelectItem.

---

Duplicate comments:
In `@server/src/api/analytics/lite/getMetricLite.ts`:
- Around line 130-189: Delete the committed benchmark block that contains the
live bearer token and internal endpoints (the commented section containing
BASE_URL, API_KEY, SITE_ID, START, END, TZ and the curl bench/warmup scripts);
either remove it entirely from getMetricLite.ts or replace every sensitive value
(API_KEY, BASE_URL, SITE_ID) with obvious placeholders like
"<API_KEY_PLACEHOLDER>" and add a short comment pointing to an internal docs
location for the recipe; ensure no real secrets remain in the file.
- Line 39: The current line computing limit allows negative or non-integer
values into the SQL LIMIT; update the request validation so req.query.limit is
parsed as an integer and clamped to the 1..500 range before interpolation.
Specifically, convert req.query.limit to a numeric integer (e.g.,
parseInt/Math.floor), handle NaN by defaulting to 250, then bound it with
Math.max(1, Math.min(parsedLimit, 500)) and use that variable (still named
limit) in the SQL; additionally, add a Zod schema for the query (or validate
early in the route handler) to ensure limit is an integer within 1..500 before
executing getMetricLite logic.

---

Nitpick comments:
In `@client/src/app/`[site]/main/components/sections/CountriesLite.tsx:
- Around line 3-36: The component CountriesLite currently imports and calls
useExtracted() for visible UI text; replace that with next-intl's
useTranslations() hook to follow client/src i18n guidelines: import
useTranslations, replace const t = useExtracted() with const t =
useTranslations(/* optional namespace */) and keep all uses of t("...")
unchanged, ensure translation keys exist in messages/*.json (e.g., en.json) and
remove the useExtracted import; update any tests or type usages if they
reference useExtracted in CountriesLite.

In `@client/src/app/`[site]/main/components/sections/DevicesLite.tsx:
- Around line 3-34: The component DevicesLite is using useExtracted() for
visible UI text; replace it with next-intl's useTranslations() to follow the
project's i18n convention: import useTranslations from "next-intl", change the t
= useExtracted() to t = useTranslations(/* appropriate namespace or key prefix
used across client/src */), and keep all existing uses of t (label, title,
getLabel fallback) intact; ensure the translation keys referenced (e.g.,
"Devices" and "Other") exist in the messages/ JSON files so the
StandardSectionTabs (tabs, getLabel, lite) renders with the correct localized
strings.

In `@client/src/app/`[site]/main/components/sections/PagesLite.tsx:
- Around line 3-36: Replace useExtracted with next-intl's useTranslations in
PagesLite: change the import and the hook call (remove useExtracted, import and
call useTranslations in PagesLite), then update t("...") usage to match
useTranslations semantics (same string keys) for labels in the tabs definition
(label, section.title, getLabel fallback). Ensure imports at top reflect
useTranslations and remove unused useExtracted; keep siteMetadata usage and
StandardSectionTabs integration unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1d3ea7dc-397f-4939-9d49-6b2cf0a6677c

📥 Commits

Reviewing files that changed from the base of the PR and between f44628f and 884d419.

📒 Files selected for processing (6)
  • client/src/app/[site]/main/components/sections/CountriesLite.tsx
  • client/src/app/[site]/main/components/sections/DevicesLite.tsx
  • client/src/app/[site]/main/components/sections/PagesLite.tsx
  • client/src/components/BucketSelection.tsx
  • client/src/lib/store.ts
  • server/src/api/analytics/lite/getMetricLite.ts

Comment on lines 77 to +82
const availableOptions = options.filter(
option => durationMinutes === null || durationMinutes >= bucketDurationMinutes(option) * 2
option =>
// The lite dashboard is backed by hourly materialized views, so buckets
// finer than an hour have no underlying data.
(!LITE_DASHBOARD || bucketDurationMinutes(option) >= 60) &&
(durationMinutes === null || durationMinutes >= bucketDurationMinutes(option) * 2)
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 | ⚡ Quick win

Avoid rendering an empty bucket selector in lite mode.

For past-minutes ranges of 120 minutes or less, getOptions() only passes ["minute"]. This new lite filter removes that option, and the existing durationMinutes >= bucketDurationMinutes(option) * 2 check still blocks "hour" for shorter windows, so the <Select> can end up with no SelectItems at all. Please add a lite fallback here instead of applying the normal sub-hour filter blindly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/src/components/BucketSelection.tsx` around lines 77 - 82,
availableOptions filtering can yield an empty list in lite mode because the
LITE_DASHBOARD branch removes sub-hour options and the existing durationMinutes
check still blocks "hour"; update the logic in the availableOptions calculation
(the filter that uses LITE_DASHBOARD and bucketDurationMinutes(option)) to
provide a lite-mode fallback: when LITE_DASHBOARD is true and the filtered
result would be empty, include at least "hour" (or the first valid coarser
bucket from getOptions()) so the Select/SelectItem list never renders empty; use
the existing helpers bucketDurationMinutes(option) and
getOptions()/durationMinutes to pick the fallback option and ensure the <Select>
always has at least one SelectItem.

…ination support

- Updated the analytics API to support session filters in lite metric queries, allowing for more granular data retrieval based on session-related dimensions.
- Implemented pagination in the getMetricLite function to improve data handling and user experience when fetching metrics.
- Refactored SQL queries in getOverviewBucketedLite and getOverviewLite to accommodate session filters, ensuring efficient data processing and accurate results.
- Enhanced utility functions for better filter management, streamlining the overall analytics workflow.
- Introduced a new LazySection for the Events component in MainPageContent, improving the overall layout and user experience.
- Set a specific height for the Events section to ensure consistent display alongside other sections.
…r readability

- Reformatted the LazySection components in MainPageContent to enhance code readability and maintainability.
- Adjusted the height of the Events section for consistent display across different screen sizes.
- Updated useGetOverview and useGetOverviewBucketed hooks to include a new 'lite' option, allowing for the use of MV-backed lite endpoints.
- Removed the deprecated useGetOverviewLite and useGetOverviewBucketedLite hooks to streamline the codebase.
- Adjusted components to utilize the new lite functionality, ensuring consistent data retrieval across the application.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (1)
server/src/api/analytics/lite/getMetricLite.ts (1)

36-38: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Clamp limit and page to positive integers.

Number(...) lets negatives and fractions through. ?limit=-5/?limit=50.5 yields LIMIT -5/LIMIT 50.5, and a fractional page (e.g. ?page=1.3) produces a fractional OFFSET — both rejected by ClickHouse and surfaced as a 500. The limit issue echoes earlier feedback and remains unaddressed; page/offset have the same gap.

🔧 Proposed fix
-  const limit = Math.min(Number(req.query.limit) || 250, 500);
-  const page = Math.max(Number(req.query.page) || 1, 1);
+  const limit = Math.min(Math.max(Math.floor(Number(req.query.limit)) || 250, 1), 500);
+  const page = Math.max(Math.floor(Number(req.query.page)) || 1, 1);
   const offsetStatement = page > 1 ? `OFFSET ${(page - 1) * limit}` : "";

As per coding guidelines, "Validate untrusted request bodies and query params ... before using them in route handlers."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/api/analytics/lite/getMetricLite.ts` around lines 36 - 38, The
limit and page query params (variables limit, page, and offsetStatement in
getMetricLite.ts) must be coerced to positive integers and clamped before use:
parse/convert the incoming req.query.limit and req.query.page to integers (e.g.,
via parseInt or Math.floor after Number()), default to 250 and 1 respectively,
clamp limit to the range [1, 500] and ensure page is >=1, and then compute
offset using these integer values so OFFSET and LIMIT are always whole positive
numbers; update the offsetStatement construction to use the sanitized page and
limit variables.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@server/src/api/analytics/lite/utils.ts`:
- Around line 50-56: wrapLiteLikeValue currently inserts % wildcards around raw
input without escaping LIKE metacharacters, causing overmatching; update
wrapLiteLikeValue to first escape backslashes, percent and underscore in the
incoming string (e.g., v = v.replace(/\\/g, "\\\\").replace(/%/g,
"\\%").replace(/_/g, "\\_")), then apply the existing wrapping rules
(%prefix/suffix) based on type, and finally return the escaped+wrapped string
(or pass it through the same escape flow used by wrapLikeValue/SqlString.escape
to remain consistent with the standard analytics path).
- Around line 82-89: getLiteSessionFilter currently builds negated multi-value
clauses with OR which is wrong; update the LITE_FILTER_OPERATORS handling in
getLiteSessionFilter so that for negated string operators (not_equals,
not_contains) when filter.value.length > 1 you join individual column
comparisons with AND instead of OR. Mirror the same semantics in the shared
builder by changing buildStringFilterCondition in
server/src/api/analytics/utils/getFilterStatement.ts to use AND for negated
multi-value (except where user_id semantics differ), and update
wrapLiteLikeValue and wrapLikeValue to escape LIKE metacharacters (% and _) and
append an ESCAPE clause so SqlString.escape is safe for literal matching. Ensure
you reference the operator mapping (LITE_FILTER_OPERATORS), the function
getLiteSessionFilter, the shared buildStringFilterCondition, and the helper
wrapLiteLikeValue/wrapLikeValue when making the changes.

---

Duplicate comments:
In `@server/src/api/analytics/lite/getMetricLite.ts`:
- Around line 36-38: The limit and page query params (variables limit, page, and
offsetStatement in getMetricLite.ts) must be coerced to positive integers and
clamped before use: parse/convert the incoming req.query.limit and
req.query.page to integers (e.g., via parseInt or Math.floor after Number()),
default to 250 and 1 respectively, clamp limit to the range [1, 500] and ensure
page is >=1, and then compute offset using these integer values so OFFSET and
LIMIT are always whole positive numbers; update the offsetStatement construction
to use the sanitized page and limit variables.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0082d658-96b6-45fd-a3fe-cec7185a74f1

📥 Commits

Reviewing files that changed from the base of the PR and between 884d419 and f96c89f.

📒 Files selected for processing (6)
  • client/src/app/[site]/components/shared/StandardSection/StandardSection.tsx
  • client/src/app/[site]/main/page.tsx
  • server/src/api/analytics/lite/getMetricLite.ts
  • server/src/api/analytics/lite/getOverviewBucketedLite.ts
  • server/src/api/analytics/lite/getOverviewLite.ts
  • server/src/api/analytics/lite/utils.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • client/src/app/[site]/main/page.tsx

Comment on lines +50 to +56
function wrapLiteLikeValue(type: string, value: string | number): string {
const v = String(value);
if (type === "contains" || type === "not_contains") return `%${v}%`;
if (type === "starts_with") return `${v}%`;
if (type === "ends_with") return `%${v}`;
return v;
}
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 | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP --type=ts -C4 'starts_with|ends_with|LIKE'

Repository: rybbit-io/rybbit

Length of output: 23872


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the main analytics LIKE value wrapper and how it's used
FILE="server/src/api/analytics/utils/getFilterStatement.ts"
echo "=== wrapLikeValue + surrounding ==="
rg -n "wrapLikeValue|buildStringFilterCondition|getSqlParam|LIKE" "$FILE" -n
echo
# Print relevant sections with line numbers
python3 - <<'PY'
import re,sys
path="server/src/api/analytics/utils/getFilterStatement.ts"
with open(path,'r',encoding='utf-8') as f:
    lines=f.readlines()
# find wrapLikeValue function start
start=None
for i,l in enumerate(lines):
    if 'const wrapLikeValue' in l:
        start=i; break
if start is None:
    print("wrapLikeValue not found"); sys.exit(1)
for j in range(start, min(start+120, len(lines))):
    print(f"{j+1:5d}:{lines[j].rstrip()}")
PY

echo
# Inspect tests about LIKE/wildcards
TEST="server/src/api/analytics/utils/getFilterStatement.test.ts"
echo "=== tests mentioning LIKE wildcards ==="
rg -n "LIKE|wildcards|%\\$\\{v\\}|wrapLikeValue|contains filter" "$TEST"
echo
python3 - <<'PY'
path="server/src/api/analytics/utils/getFilterStatement.test.ts"
with open(path,'r',encoding='utf-8') as f:
    lines=f.readlines()
# show around "should handle contains filter with LIKE and wildcards"
for idx,l in enumerate(lines):
    if "should handle contains filter with LIKE and wildcards" in l:
        start=max(0,idx-20); end=min(len(lines),idx+40)
        for j in range(start,end):
            print(f"{j+1:5d}:{lines[j].rstrip()}")
        break
PY

Repository: rybbit-io/rybbit

Length of output: 10006


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="server/src/api/analytics/utils/getFilterStatement.ts"
echo "=== wrapLikeValue + surrounding ==="
python3 - <<'PY'
import sys
path="server/src/api/analytics/utils/getFilterStatement.ts"
with open(path,'r',encoding='utf-8') as f:
    lines=f.readlines()
start=None
for i,l in enumerate(lines):
    if 'const wrapLikeValue' in l:
        start=i; break
if start is None:
    print("wrapLikeValue not found"); sys.exit(1)
for j in range(start, min(start+160, len(lines))):
    print(f"{j+1:5d}:{lines[j].rstrip()}")
PY

echo
echo "=== occurrences of wrapLikeValue usage ==="
rg -n "wrapLikeValue\\(" "$FILE"

echo
echo "=== tests mentioning LIKE wildcards ==="
TEST="server/src/api/analytics/utils/getFilterStatement.test.ts"
rg -n "contains filter|LIKE|wildcards|entry_page contains" "$TEST"
echo
python3 - <<'PY'
path="server/src/api/analytics/utils/getFilterStatement.test.ts"
with open(path,'r',encoding='utf-8') as f:
    lines=f.readlines()
needle="should handle contains filter with LIKE and wildcards"
for idx,l in enumerate(lines):
    if needle in l:
        start=max(0,idx-15); end=min(len(lines),idx+45)
        for j in range(start,end):
            print(f"{j+1:5d}:{lines[j].rstrip()}")
        break
PY

Repository: rybbit-io/rybbit

Length of output: 10295


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="server/src/api/analytics/utils/getFilterStatement.ts"

echo "=== wrapLikeValue + surrounding (approx) ==="
python3 - <<'PY'
path="server/src/api/analytics/utils/getFilterStatement.ts"
with open(path,'r',encoding='utf-8') as f:
    lines=f.readlines()
start=None
for i,l in enumerate(lines):
    if 'const wrapLikeValue' in l:
        start=i; break
if start is None:
    print("wrapLikeValue not found"); raise SystemExit(1)
for j in range(start, min(start+160, len(lines))):
    print(f"{j+1:5d}:{lines[j].rstrip()}")
PY

echo
echo "=== wrapLikeValue usage ==="
rg -n "wrapLikeValue\\(" "$FILE"

echo
echo "=== tests mentioning LIKE wildcards ==="
TEST="server/src/api/analytics/utils/getFilterStatement.test.ts"
rg -n "LIKE|wildcards|entry_page contains filter|contains filter" "$TEST"

echo
python3 - <<'PY'
path="server/src/api/analytics/utils/getFilterStatement.test.ts"
with open(path,'r',encoding='utf-8') as f:
    lines=f.readlines()
needle="should handle contains filter with LIKE and wildcards"
for idx,l in enumerate(lines):
    if needle in l:
        start=max(0,idx-15); end=min(len(lines),idx+45)
        for j in range(start,end):
            print(f"{j+1:5d}:{lines[j].rstrip()}")
        break
PY

Repository: rybbit-io/rybbit

Length of output: 10289


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="server/src/api/analytics/utils/getFilterStatement.ts"

python3 - <<'PY'
path="server/src/api/analytics/utils/getFilterStatement.ts"
with open(path,'r',encoding='utf-8') as f:
    lines=f.readlines()
start=None
for i,l in enumerate(lines):
    if 'const wrapLikeValue' in l:
        start=i; break
if start is None:
    print("wrapLikeValue not found"); raise SystemExit(1)
for j in range(start, min(start+170, len(lines))):
    print(f"{j+1:5d}:{lines[j].rstrip()}")
PY

echo "WRAP-LIKE-USE:"
rg -n "wrapLikeValue\\(" "$FILE"

TEST="server/src/api/analytics/utils/getFilterStatement.test.ts"
echo "TEST HITS:"
rg -n "wildcards|contains filter with LIKE|entry_page contains filter|LIKE" "$TEST"

python3 - <<'PY'
path="server/src/api/analytics/utils/getFilterStatement.test.ts"
with open(path,'r',encoding='utf-8') as f:
    lines=f.readlines()
for idx,l in enumerate(lines):
    if "contains filter with LIKE and wildcards" in l:
        start=max(0,idx-10); end=min(len(lines),idx+40)
        for j in range(start,end):
            print(f"{j+1:5d}:{lines[j].rstrip()}")
        break
PY

Repository: rybbit-io/rybbit

Length of output: 9894


Escape LIKE wildcard metacharacters in LIKE filter values

wrapLiteLikeValue wraps the raw input with % (e.g., %${v}%, ${v}%, %${v}) without escaping %/_ (and \). This means user values containing LIKE wildcards will over-match. The standard analytics path (server/src/api/analytics/utils/getFilterStatement.ts) uses the same LIKE wrapping logic (via wrapLikeValue(...) + SqlString.escape(...)), so the issue isn’t lite-specific.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/api/analytics/lite/utils.ts` around lines 50 - 56,
wrapLiteLikeValue currently inserts % wildcards around raw input without
escaping LIKE metacharacters, causing overmatching; update wrapLiteLikeValue to
first escape backslashes, percent and underscore in the incoming string (e.g., v
= v.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_")), then
apply the existing wrapping rules (%prefix/suffix) based on type, and finally
return the escaped+wrapped string (or pass it through the same escape flow used
by wrapLikeValue/SqlString.escape to remain consistent with the standard
analytics path).

Comment on lines +82 to +89
const op = LITE_FILTER_OPERATORS[filter.type];
if (!op || filter.value.length === 0) return { supported: false, sql: "" };

const parts = filter.value.map(
value => `${column} ${op} ${SqlString.escape(wrapLiteLikeValue(filter.type, value))}`
);
conditions.push(parts.length === 1 ? parts[0] : `(${parts.join(" OR ")})`);
}
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 | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find standard filter-statement builder and inspect join semantics for negated ops.
rg -nP --type=ts -C6 'not_contains|not_equals'

Repository: rybbit-io/rybbit

Length of output: 49212


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Inspect lite getLiteSessionFilter join semantics
echo "---- lite: server/src/api/analytics/lite/utils.ts (getLiteSessionFilter) ----"
rg -n "function getLiteSessionFilter|getLiteSessionFilter" server/src/api/analytics/lite/utils.ts
python3 - <<'PY'
import itertools,sys,os, pathlib, re
path="server/src/api/analytics/lite/utils.ts"
with open(path,'r',encoding='utf-8') as f:
    lines=f.readlines()
for i,l in enumerate(lines, start=1):
    if "getLiteSessionFilter" in l or "wrapLiteLikeValue" in l or "LITE_FILTER_OPERATORS" in l:
        pass
start=70; end=100
for i in range(start,end+1):
    print(f"{i}:{lines[i-1].rstrip()}")
PY

# 2) Inspect standard getFilterStatement implementation for join semantics
echo "---- standard: server/src/api/analytics/utils/getFilterStatement.ts (string filter condition builder) ----"
python3 - <<'PY'
path="server/src/api/analytics/utils/getFilterStatement.ts"
with open(path,'r',encoding='utf-8') as f:
    lines=f.readlines()
# Print around buildStringFilterCondition and any join logic near not_equals/not_contains
for i in range(130,230):
    if "joiner" in lines[i-1] or "not_equals" in lines[i-1] or "not_contains" in lines[i-1] or "buildStringFilterCondition" in lines[i-1]:
        pass
start=250; end=380
for i in range(start,end+1):
    print(f"{i}:{lines[i-1].rstrip()}")
PY

# 3) Inspect bots builder joiner (already seen in grep, but show exact function)
echo "---- bots: server/src/api/analytics/bots/utils.ts (buildStringFilterCondition) ----"
python3 - <<'PY'
path="server/src/api/analytics/bots/utils.ts"
with open(path,'r',encoding='utf-8') as f:
    lines=f.readlines()
start=120; end=180
for i in range(start,end+1):
    print(f"{i}:{lines[i-1].rstrip()}")
PY

# 4) Search tests for LIKE wildcard escaping expectations
echo "---- tests: LIKE wildcard coverage in getFilterStatement.test.ts ----"
rg -n "LIKE|not_contains|contains" server/src/api/analytics/utils/getFilterStatement.test.ts
rg -n "'%|\"%|_'|\\_%|\\\\_|wildcard" server/src/api/analytics/utils/getFilterStatement.test.ts || true

Repository: rybbit-io/rybbit

Length of output: 7290


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "---- lite: server/src/api/analytics/lite/utils.ts (wrapLiteLikeValue + getLiteSessionFilter) ----"
nl -ba server/src/api/analytics/lite/utils.ts | sed -n '45,105p'

echo "---- standard: find buildStringFilterCondition in getFilterStatement.ts ----"
rg -n "buildStringFilterCondition" server/src/api/analytics/utils/getFilterStatement.ts

echo "---- standard: server/src/api/analytics/utils/getFilterStatement.ts (buildStringFilterCondition body) ----"
# Show around the function definition (best-effort with a window)
python3 - <<'PY'
import re
path="server/src/api/analytics/utils/getFilterStatement.ts"
lines=open(path,'r',encoding='utf-8').read().splitlines()
for i,l in enumerate(lines):
    if "function buildStringFilterCondition" in l:
        start=max(0,i-5); end=min(len(lines), i+120)
        for j in range(start,end):
            print(f"{j+1}:{lines[j]}")
        break
else:
    # fallback: show from first occurrence of "const buildStringFilterCondition"
    for i,l in enumerate(lines):
        if "buildStringFilterCondition" in l and ("const " in l or "export" in l):
            start=max(0,i-5); end=min(len(lines), i+120)
            for j in range(start,end):
                print(f"{j+1}:{lines[j]}")
            break
PY

echo "---- standard tests around LIKE + (not_)contains ----"
nl -ba server/src/api/analytics/utils/getFilterStatement.test.ts | sed -n '120,190p'

echo "---- search tests for escaping of LIKE metacharacters (% and _) ----"
rg -n "escape|wildcard|LIKE|%|_|not_contains|contains" server/src/api/analytics/utils/getFilterStatement.test.ts -S | head -n 200

Repository: rybbit-io/rybbit

Length of output: 219


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "---- lite: wrapLiteLikeValue + getLiteSessionFilter ----"
python3 - <<'PY'
path="server/src/api/analytics/lite/utils.ts"
lines=open(path,'r',encoding='utf-8').read().splitlines()
for i,l in enumerate(lines, start=1):
    if "function wrapLiteLikeValue" in l:
        start=i-10; end=i+40
        break
else:
    start=1; end=120
for j in range(max(1,start), min(len(lines),end)+1):
    print(f"{j}:{lines[j-1]}")
print("---- getLiteSessionFilter ----")
for i,l in enumerate(lines, start=1):
    if "export function getLiteSessionFilter" in l:
        start=i-5; end=i+60
        for j in range(max(1,start), min(len(lines),end)+1):
            print(f"{j}:{lines[j-1]}")
        break
PY

echo "---- standard: buildStringFilterCondition implementation ----"
python3 - <<'PY'
path="server/src/api/analytics/utils/getFilterStatement.ts"
lines=open(path,'r',encoding='utf-8').read().splitlines()
idx=None
for i,l in enumerate(lines, start=1):
    if "buildStringFilterCondition" in l and "function" in l:
        idx=i; break
# fallback to const
if idx is None:
    for i,l in enumerate(lines, start=1):
        if "const buildStringFilterCondition" in l:
            idx=i; break
if idx is None:
    raise SystemExit("Could not locate buildStringFilterCondition")
start=idx-10; end=idx+120
for j in range(max(1,start), min(len(lines),end)+1):
    print(f"{j}:{lines[j-1]}")
PY

echo "---- standard tests: getFilterStatement.test.ts (contains/not_contains + LIKE) ----"
python3 - <<'PY'
import re
path="server/src/api/analytics/utils/getFilterStatement.test.ts"
lines=open(path,'r',encoding='utf-8').read().splitlines()
# print tests containing not_contains/contains and any LIKE expectations
pat=re.compile(r"not_contains|contains.*LIKE|LIKE.*%|pathname LIKE|NOT LIKE")
hits=[i for i,l in enumerate(lines, start=1) if pat.search(l)]
for i in hits[:80]:
    start=max(1,i-10); end=min(len(lines), i+10)
    print(f"\n--- around line {i} ---")
    for j in range(start,end+1):
        print(f"{j}:{lines[j-1]}")
PY

echo "---- standard tests: search for LIKE metacharacter escaping (% or _) ----"
rg -n --hidden -S "%'|LIKE '%|LIKE \"%|NOT LIKE|\\_%|\\\\_%|\\_|wildcard|escape" server/src/api/analytics/utils/getFilterStatement.test.ts || true

Repository: rybbit-io/rybbit

Length of output: 17518


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "---- find lite session filter usage ----"
rg -n "getLiteSessionFilter\(" server/src/api/analytics/lite -S

echo "---- find unit tests for lite utils ----"
rg -n "lite/utils|getLiteSessionFilter|wrapLiteLikeValue" server/src -S

echo "---- search standard builder tests for multiple not_contains ----"
rg -n "not_contains.*value: \\[.*,.+\\]" server/src/api/analytics/utils/getFilterStatement.test.ts -S || true

echo "---- search standard builder tests for multiple not_equals ----"
rg -n "not_equals.*value: \\[.*,.+\\]" server/src/api/analytics/utils/getFilterStatement.test.ts -S || true

Repository: rybbit-io/rybbit

Length of output: 2197


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "---- lite: LITE_SESSION_FILTER_COLUMNS mapping ----"
python3 - <<'PY'
path="server/src/api/analytics/lite/utils.ts"
lines=open(path,'r',encoding='utf-8').read().splitlines()
for i,l in enumerate(lines, start=1):
    if "const LITE_SESSION_FILTER_COLUMNS" in l:
        start=i-2
        end=i+60
        break
else:
    raise SystemExit("mapping not found")
for j in range(start,end+1):
    print(f"{j}:{lines[j-1]}")
PY

echo "---- standard tests: look for any mention of literal '%' or '_' in expected LIKE strings ----"
rg -n "LIKE '(%|.*%|.*_).*'|NOT LIKE '(%|.*%|.*_).*'|LIKE \"%|NOT LIKE \"%|NOT LIKE '%|LIKE '%|LIKE .*_.*|NOT LIKE .*_.*" server/src/api/analytics/utils/getFilterStatement.test.ts -S || true

echo "---- standard tests: look for patterns that include underscores in LIKE expectations ----"
rg -n "LIKE '.*_.*'|NOT LIKE '.*_.*'" server/src/api/analytics/utils/getFilterStatement.test.ts -S || true

echo "---- standard builder: confirm wrapLikeValue does not escape LIKE metacharacters ----"
python3 - <<'PY'
path="server/src/api/analytics/utils/getFilterStatement.ts"
lines=open(path,'r',encoding='utf-8').read().splitlines()
# print around wrapLikeValue
for i,l in enumerate(lines, start=1):
    if "const wrapLikeValue" in l:
        start=i-5; end=i+30
        break
else:
    raise SystemExit("wrapLikeValue not found")
for j in range(start,end+1):
    print(f"{j}:{lines[j-1]}")
PY

Repository: rybbit-io/rybbit

Length of output: 4414


Fix negated multi-value filters in lite (must AND, not OR).

getLiteSessionFilter currently builds negated multi-value clauses with OR (e.g. col != 'a' OR col != 'b' / col NOT LIKE ... OR ...), which is always true and effectively disables the filter for not_equals and not_contains.

  • Update the lite join semantics to use AND for not_equals / not_contains when filter.value.length > 1.
  • The raw analytics builder (server/src/api/analytics/utils/getFilterStatement.ts, buildStringFilterCondition) also uses join(" OR ") for multi-value negated filters for non-user_id parameters (unit test asserts this for not_equals), so changing lite alone would create lite/raw inconsistencies—fix the shared semantics too.
  • Minor: wrapLiteLikeValue (and the standard wrapLikeValue) inserts % but only uses SqlString.escape (quotes); %/_ in user values will behave as LIKE wildcards. Escape LIKE metacharacters (and add ESCAPE) if literal matching is intended.
🐛 Proposed fix
     const op = LITE_FILTER_OPERATORS[filter.type];
     if (!op || filter.value.length === 0) return { supported: false, sql: "" };

+    const negated = filter.type === "not_equals" || filter.type === "not_contains";
+    const joiner = negated ? " AND " : " OR ";
     const parts = filter.value.map(
       value => `${column} ${op} ${SqlString.escape(wrapLiteLikeValue(filter.type, value))}`
     );
-    conditions.push(parts.length === 1 ? parts[0] : `(${parts.join(" OR ")})`);
+    conditions.push(parts.length === 1 ? parts[0] : `(${parts.join(joiner)})`);
📝 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 op = LITE_FILTER_OPERATORS[filter.type];
if (!op || filter.value.length === 0) return { supported: false, sql: "" };
const parts = filter.value.map(
value => `${column} ${op} ${SqlString.escape(wrapLiteLikeValue(filter.type, value))}`
);
conditions.push(parts.length === 1 ? parts[0] : `(${parts.join(" OR ")})`);
}
const op = LITE_FILTER_OPERATORS[filter.type];
if (!op || filter.value.length === 0) return { supported: false, sql: "" };
const negated = filter.type === "not_equals" || filter.type === "not_contains";
const joiner = negated ? " AND " : " OR ";
const parts = filter.value.map(
value => `${column} ${op} ${SqlString.escape(wrapLiteLikeValue(filter.type, value))}`
);
conditions.push(parts.length === 1 ? parts[0] : `(${parts.join(joiner)})`);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/api/analytics/lite/utils.ts` around lines 82 - 89,
getLiteSessionFilter currently builds negated multi-value clauses with OR which
is wrong; update the LITE_FILTER_OPERATORS handling in getLiteSessionFilter so
that for negated string operators (not_equals, not_contains) when
filter.value.length > 1 you join individual column comparisons with AND instead
of OR. Mirror the same semantics in the shared builder by changing
buildStringFilterCondition in
server/src/api/analytics/utils/getFilterStatement.ts to use AND for negated
multi-value (except where user_id semantics differ), and update
wrapLiteLikeValue and wrapLikeValue to escape LIKE metacharacters (% and _) and
append an ESCAPE clause so SqlString.escape is safe for literal matching. Ensure
you reference the operator mapping (LITE_FILTER_OPERATORS), the function
getLiteSessionFilter, the shared buildStringFilterCondition, and the helper
wrapLiteLikeValue/wrapLikeValue when making the changes.

- Removed unused admin routes from the Stripe admin routes section to streamline the API.
- Cleaned up the getMetricLite function by removing outdated comments and unnecessary code, enhancing readability.
- Updated ClickHouse materialized views and backfill scripts to exclude session attributes that are not session-invariant, ensuring data integrity.
@goldflag goldflag merged commit 519a655 into master May 30, 2026
8 checks passed
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.

1 participant