Skip to content

fix(admin/analytics): use sql.raw for date_trunc period to avoid parameter mismatch#2354

Merged
andrew-bierman merged 3 commits into
mainfrom
fix/analytics-date-trunc-parameterization
Apr 27, 2026
Merged

fix(admin/analytics): use sql.raw for date_trunc period to avoid parameter mismatch#2354
andrew-bierman merged 3 commits into
mainfrom
fix/analytics-date-trunc-parameterization

Conversation

@andrew-bierman
Copy link
Copy Markdown
Collaborator

@andrew-bierman andrew-bierman commented Apr 27, 2026

Summary

  • date_trunc(${period}, col) in Drizzle's sql tag parameterizes period as $1, $2, etc. for each occurrence
  • SELECT and GROUP BY each get a different parameter slot, so PostgreSQL can't match them as the same expression → query fails with ANALYTICS_GROWTH_ERROR
  • period is Zod-validated to ['day', 'week', 'month'] so it's safe to inline as a literal via sql.raw(\'${period}'`)`
  • Fixed all 18 occurrences across /growth and /activity routes

Test plan

  • Hit /api/admin/analytics/platform/growth?period=month — should return data instead of 500
  • Hit /api/admin/analytics/platform/activity?period=week — same

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Improvements

    • AllTrails preview endpoint is now publicly accessible, allowing users to easily share trail information without authentication requirements.
  • Chores

    • Optimized analytics query handling for growth and activity metrics performance.

Copilot AI review requested due to automatic review settings April 27, 2026 03:31
@github-actions github-actions Bot added the api label Apr 27, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 27, 2026

Coverage Report for API Unit Tests Coverage (./packages/api)

Status Category Percentage Covered / Total
🔵 Lines 75.74% 609 / 804
🔵 Statements 75.74% (🎯 65%) 609 / 804
🔵 Functions 95.91% 47 / 49
🔵 Branches 88.23% 270 / 306
File CoverageNo changed files found.
Generated in workflow #886 for commit 3e2c9d3 by the Vitest Coverage Report Action

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 27, 2026

Coverage Report for Expo Unit Tests Coverage (./apps/expo)

Status Category Percentage Covered / Total
🔵 Lines 81.4% 521 / 640
🔵 Statements 81.4% (🎯 75%) 521 / 640
🔵 Functions 92.85% 52 / 56
🔵 Branches 92.55% 199 / 215
File CoverageNo changed files found.
Generated in workflow #886 for commit 3e2c9d3 by the Vitest Coverage Report Action

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 27, 2026

Warning

.coderabbit.yaml has a parsing error

The CodeRabbit configuration file in this repository has a parsing error and default settings were used instead. Please fix the error(s) in the configuration file. You can initialize chat with CodeRabbit to get help with the configuration file.

💥 Parsing errors (1)
Validation error: String must contain at most 250 character(s) at "tone_instructions"
⚙️ Configuration instructions
  • Please see the configuration documentation for more information.
  • You can also validate your configuration using the online YAML validator.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
📝 Walkthrough

Walkthrough

Two API route files were modified: analytics queries for growth and activity metrics now use parameterized SQL injection for date truncation, and the /alltrails/preview endpoint removed its authentication requirement to operate as a public link-preview proxy.

Changes

Cohort / File(s) Summary
Analytics Query Parameter Handling
packages/api/src/routes/admin/analytics/platform.ts
Updated six analytics queries (users, packs, catalogItems, trips, trailConditionReports, posts) to inject the period parameter via sql.raw(\'${period}'`)instead of direct parameter usage, affectingdate_trunc(), GROUP BY, and ORDER BY` clauses. Minor line-wrapping adjustment included.
AllTrails Preview Endpoint Auth
packages/api/src/routes/alltrails.ts
Removed isAuthenticated: true from route options to allow unauthenticated access to the /alltrails/preview endpoint. URL validation, SSRF protection, and OG tag extraction logic remain intact.

Possibly related PRs

Suggested labels

api

Poem

🐰 Through SQL we hop with parameters bound,
No auth walls block our preview's ground,
Date_trunc dances to the SQL song,
While AllTrails pathways flow along!

🎯 2 (Simple) | ⏱️ ~15 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects the primary change: fixing analytics queries by using sql.raw for date_trunc period parameterization to resolve a parameter mismatch issue.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/analytics-date-trunc-parameterization

Warning

Review ran into problems

🔥 Problems

Git: Failed to clone repository. Please run the @coderabbitai full review command to re-trigger a full review. If the issue persists, set path_filters to include or exclude specific files.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

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.

Caution

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

⚠️ Outside diff range comments (2)
packages/api/src/routes/alltrails.ts (2)

18-22: ⚠️ Potential issue | 🟠 Major

Public unauthenticated proxy — add rate limiting / abuse controls.

Removing isAuthenticated: true turns /alltrails/preview into an open server-side fetcher. Even with the alltrails.com allowlist, an unauthenticated caller can drive arbitrary outbound traffic from the worker (link-preview floods, scraping-as-a-service, cost amplification on Cloudflare). Before shipping, please confirm one of: per-IP/origin rate limiting (e.g. Cloudflare WAF rule or a Durable-Object/KV limiter), a CORS/Origin allowlist for first-party clients, or a lightweight signed token from the app — otherwise this endpoint is trivially abusable.

Also worth noting: this route change is unrelated to the stated PR objective (analytics date_trunc parameterization). Consider splitting into its own PR so the auth/abuse implications get reviewed independently.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/api/src/routes/alltrails.ts` around lines 18 - 22, The new public
route alltrailsRoutes.post('/preview') removed authentication (isAuthenticated)
and can be abused as an open server-side fetch proxy; restore abuse controls by
implementing one of: (1) re-introduce authentication (set isAuthenticated: true)
or require a lightweight signed token check in the route handler, (2) enforce
per-client rate-limiting (per-IP or per-origin) via a Durable Object/KV limiter
or Cloudflare WAF rule before allowing requests to alltrailsRoutes or
specifically the '/preview' handler, or (3) add a CORS/Origin allowlist check
for first-party clients inside the '/preview' handler to reject unknown origins;
also consider splitting this change into a separate PR from the
analytics/date_trunc work so the auth/abuse implications (alltrailsRoutes and
'/preview') are reviewed independently.

82-82: ⚠️ Potential issue | 🟡 Minor

Cap the response body size before response.text().

With no size limit, a large (or hostile mirror returning a huge body within the alltrails.com hostname class) response can balloon worker memory; combined with the now-public route this is an easy DoS vector. Consider streaming with a hard cap (e.g. 1–2 MB) and rejecting oversize responses.

🛡️ Sketch of a bounded reader
-    const html = await response.text();
+    const MAX_BYTES = 2 * 1024 * 1024; // 2 MB
+    const reader = response.body?.getReader();
+    if (!reader) {
+      return status(502, { error: 'Empty response from AllTrails' });
+    }
+    const chunks: Uint8Array[] = [];
+    let received = 0;
+    while (true) {
+      const { value, done } = await reader.read();
+      if (done) break;
+      received += value.byteLength;
+      if (received > MAX_BYTES) {
+        await reader.cancel();
+        return status(502, { error: 'AllTrails response too large' });
+      }
+      chunks.push(value);
+    }
+    const html = new TextDecoder('utf-8').decode(
+      chunks.length === 1 ? chunks[0] : Buffer.concat(chunks.map((c) => Buffer.from(c))),
+    );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/api/src/routes/alltrails.ts` at line 82, The call to response.text()
in the alltrails route can consume an unbounded body and must be replaced with a
bounded streaming read: use response.body.getReader() (or a streaming reader) to
read chunks into a buffer and stop/abort once a hard limit (e.g. 1–2 MB) is
reached, rejecting the request or returning an error; locate the usage of "const
html = await response.text()" and replace it with a chunked read that
accumulates into a string/Uint8Array up to the cap, closes the reader/stream on
overflow, and throws or returns a 413/appropriate error when the body exceeds
the limit.
🧹 Nitpick comments (1)
packages/api/src/routes/admin/analytics/platform.ts (1)

47-71: Fix is correct; consider extracting the date_trunc expression to remove 18× duplication and harden the literal inlining.

The root-cause analysis is right: Drizzle's sql tag binds each ${period} to a fresh $N placeholder, and PostgreSQL treats each placeholder as a distinct expression, so SELECT's $1 and GROUP BY's $2 aren't structurally equal — hence column "..." must appear in the GROUP BY clause. Inlining the literal sidesteps that.

Two non-blocking suggestions for both /growth and /activity handlers:

  1. Deduplicate: bind the date_trunc(...) fragment per column once and reuse it in select, groupBy, and orderBy — currently the same expression is written three times per metric (18 total), which is fragile if anyone later edits one site without the others.
  2. Defense-in-depth: sql.raw(\'${period}'`)is safe today only becausePeriodSchemaconstrainsperiodto'day' | 'week' | 'month'. Decouple the safety from a remote validator by mapping the enum to a fixed sql` fragment, so a future schema relaxation can't silently turn this into a SQL injection sink.
♻️ Sketch combining both improvements (apply analogously in `/activity`)
       const db = createDb();
       const { period = 'month', range = 12 } = query;
       const startDate = getStartDate(period, range);

+      const periodLiteral = { day: sql`'day'`, week: sql`'week'`, month: sql`'month'` }[period];
+      const truncBy = (col: AnyPgColumn) => sql`date_trunc(${periodLiteral}, ${col})`;
+
       try {
         const [userGrowth, packGrowth, catalogGrowth] = await Promise.all([
           db
             .select({
-              date: sql<string>`date_trunc(${sql.raw(`'${period}'`)}, ${users.createdAt})::date::text`,
+              date: sql<string>`${truncBy(users.createdAt)}::date::text`,
               count: count(),
             })
             .from(users)
             .where(gte(users.createdAt, startDate))
-            .groupBy(sql`date_trunc(${sql.raw(`'${period}'`)}, ${users.createdAt})`)
-            .orderBy(sql`date_trunc(${sql.raw(`'${period}'`)}, ${users.createdAt})`),
+            .groupBy(truncBy(users.createdAt))
+            .orderBy(truncBy(users.createdAt)),

This keeps the literal-inlining behavior (each truncBy(col) produces fresh, identical SQL text in all three positions), while the only place the period value crosses into SQL is the static map.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/api/src/routes/admin/analytics/platform.ts` around lines 47 - 71,
Extract the repeated date_trunc fragment into a single reusable SQL expression
and replace the inline sql.raw(`'${period}'`) usage with a safe enum-to-fragment
map: create a const map (e.g. TRUNC_PERIOD_SQL) that maps allowed period values
('day'|'week'|'month') to prebuilt sql fragments, then build a single truncExpr
per metric (e.g. const usersTrunc = sql`date_trunc(${TRUNC_PERIOD_SQL[period]},
${users.createdAt})::date::text`) and reuse usersTrunc in .select, .groupBy and
.orderBy; apply the same pattern for packs and catalogItems and replicate the
change in both the /growth and /activity handlers so the literal is centralized
and duplication is removed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@packages/api/src/routes/alltrails.ts`:
- Around line 18-22: The new public route alltrailsRoutes.post('/preview')
removed authentication (isAuthenticated) and can be abused as an open
server-side fetch proxy; restore abuse controls by implementing one of: (1)
re-introduce authentication (set isAuthenticated: true) or require a lightweight
signed token check in the route handler, (2) enforce per-client rate-limiting
(per-IP or per-origin) via a Durable Object/KV limiter or Cloudflare WAF rule
before allowing requests to alltrailsRoutes or specifically the '/preview'
handler, or (3) add a CORS/Origin allowlist check for first-party clients inside
the '/preview' handler to reject unknown origins; also consider splitting this
change into a separate PR from the analytics/date_trunc work so the auth/abuse
implications (alltrailsRoutes and '/preview') are reviewed independently.
- Line 82: The call to response.text() in the alltrails route can consume an
unbounded body and must be replaced with a bounded streaming read: use
response.body.getReader() (or a streaming reader) to read chunks into a buffer
and stop/abort once a hard limit (e.g. 1–2 MB) is reached, rejecting the request
or returning an error; locate the usage of "const html = await response.text()"
and replace it with a chunked read that accumulates into a string/Uint8Array up
to the cap, closes the reader/stream on overflow, and throws or returns a
413/appropriate error when the body exceeds the limit.

---

Nitpick comments:
In `@packages/api/src/routes/admin/analytics/platform.ts`:
- Around line 47-71: Extract the repeated date_trunc fragment into a single
reusable SQL expression and replace the inline sql.raw(`'${period}'`) usage with
a safe enum-to-fragment map: create a const map (e.g. TRUNC_PERIOD_SQL) that
maps allowed period values ('day'|'week'|'month') to prebuilt sql fragments,
then build a single truncExpr per metric (e.g. const usersTrunc =
sql`date_trunc(${TRUNC_PERIOD_SQL[period]}, ${users.createdAt})::date::text`)
and reuse usersTrunc in .select, .groupBy and .orderBy; apply the same pattern
for packs and catalogItems and replicate the change in both the /growth and
/activity handlers so the literal is centralized and duplication is removed.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: fecdaea9-75ff-4fd0-aff6-42efe50ff17b

📥 Commits

Reviewing files that changed from the base of the PR and between fb23674 and bbc6b8f.

📒 Files selected for processing (2)
  • packages/api/src/routes/admin/analytics/platform.ts
  • packages/api/src/routes/alltrails.ts

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR addresses a PostgreSQL query failure in admin platform analytics by ensuring date_trunc uses a consistent, non-parameterized period literal, avoiding placeholder mismatches between SELECT and GROUP BY/ORDER BY.

Changes:

  • Inline period safely into date_trunc() via sql.raw(...) for analytics growth/activity queries.
  • Update the /alltrails/preview route configuration/commenting (including auth behavior).

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 7 comments.

File Description
packages/api/src/routes/admin/analytics/platform.ts Switches date_trunc(period, ...) to use a raw SQL literal for period across growth/activity aggregations.
packages/api/src/routes/alltrails.ts Removes isAuthenticated: true from the AllTrails preview endpoint and adds a “public-route” comment.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +135 to +138
.groupBy(sql`date_trunc(${sql.raw(`'${period}'`)}, ${trailConditionReports.createdAt})`)
.orderBy(
sql`date_trunc(${sql.raw(`'${period}'`)}, ${trailConditionReports.createdAt})`,
),
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

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

groupBy(sql...) is missing its closing backtick/parenthesis here, which will cause a syntax error. Close the sql template and the groupBy(...) call before the multi-line .orderBy(...).

Copilot uses AI. Check for mistakes.
Comment on lines +146 to +147
.groupBy(sql`date_trunc(${sql.raw(`'${period}'`)}, ${posts.createdAt})`)
.orderBy(sql`date_trunc(${sql.raw(`'${period}'`)}, ${posts.createdAt})`),
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

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

This groupBy call is missing its closing backtick/parenthesis, which breaks parsing/compilation. Close groupBy(sql...) before chaining .orderBy(...).

Copilot uses AI. Check for mistakes.
Comment thread packages/api/src/routes/alltrails.ts Outdated
Comment on lines 18 to 20
// public-route: link-preview proxy; fetches OG metadata from AllTrails, no user data involved
export const alltrailsRoutes = new Elysia({ prefix: '/alltrails' }).post(
'/preview',
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

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

This PR is scoped to fixing admin analytics date_trunc parameterization, but it also changes the /alltrails/preview route to be unauthenticated. If this auth change is intended, please call it out in the PR description (or split into a separate PR) and confirm that making this endpoint public is acceptable from an abuse/traffic perspective.

Copilot uses AI. Check for mistakes.
Comment on lines +52 to +53
.groupBy(sql`date_trunc(${sql.raw(`'${period}'`)}, ${users.createdAt})`)
.orderBy(sql`date_trunc(${sql.raw(`'${period}'`)}, ${users.createdAt})`),
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

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

The groupBy call is missing the closing template-literal backtick/parenthesis, which will cause a TypeScript syntax error and prevent this route from compiling. Close the sql template literal and the groupBy(...) call before chaining .orderBy(...).

Copilot uses AI. Check for mistakes.
Comment on lines +61 to +62
.groupBy(sql`date_trunc(${sql.raw(`'${period}'`)}, ${packs.createdAt})`)
.orderBy(sql`date_trunc(${sql.raw(`'${period}'`)}, ${packs.createdAt})`),
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

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

The groupBy call is not properly closed (missing closing backtick/)), which breaks the query builder chain and results in invalid TypeScript. Ensure groupBy(sql...) is fully closed before .orderBy(...).

Copilot uses AI. Check for mistakes.
Comment on lines +70 to +71
.groupBy(sql`date_trunc(${sql.raw(`'${period}'`)}, ${catalogItems.createdAt})`)
.orderBy(sql`date_trunc(${sql.raw(`'${period}'`)}, ${catalogItems.createdAt})`),
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

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

This groupBy call appears to be missing its closing backtick/parenthesis, which will make the file fail to parse/compile. Close the sql template literal and groupBy(...) invocation.

Copilot uses AI. Check for mistakes.
Comment on lines +121 to +122
.groupBy(sql`date_trunc(${sql.raw(`'${period}'`)}, ${trips.createdAt})`)
.orderBy(sql`date_trunc(${sql.raw(`'${period}'`)}, ${trips.createdAt})`),
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

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

The groupBy invocation isn’t closed (missing closing backtick/)), so the subsequent .orderBy(...) is currently attached to an unterminated expression. Close groupBy(sql...) before chaining.

Copilot uses AI. Check for mistakes.
@cloudflare-workers-and-pages
Copy link
Copy Markdown
Contributor

cloudflare-workers-and-pages Bot commented Apr 27, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
packrat-admin 3e2c9d3 Commit Preview URL

Branch Preview URL
Apr 27 2026, 03:39 AM

@andrew-bierman andrew-bierman merged commit d5d35f4 into main Apr 27, 2026
9 of 10 checks passed
andrew-bierman added a commit that referenced this pull request May 14, 2026
…ameterization

fix(admin/analytics): use sql.raw for date_trunc period to avoid parameter mismatch
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants