Skip to content

feat(analytics): account leaderboard with tracked affiliates - #646

Closed
mezotv wants to merge 10 commits into
feat/social-analyticsfrom
feat/analytics-leaderboard
Closed

feat(analytics): account leaderboard with tracked affiliates#646
mezotv wants to merge 10 commits into
feat/social-analyticsfrom
feat/analytics-leaderboard

Conversation

@mezotv

@mezotv mezotv commented Aug 2, 2026

Copy link
Copy Markdown
Member

Adds the account leaderboard so an org can rank the accounts it posts from alongside affiliate accounts it only tracks.

  • New tracked_social_accounts table (own migration) for X accounts tracked without an OAuth connection.
  • account_leaderboard Tinybird pipe: per-account posts/interactions/impressions for a trailing window plus the window before it, so rank movement is computable.
  • oRPC analytics.leaderboard / trackAccount / untrackAccount; tracking an account resolves it through the X API and does an immediate first sync.
  • The analytics sync workflow now unions connected and tracked accounts, deduping by provider account id and username.
  • LeaderboardCard on the analytics page with a new "Accounts" section and 7/30 day windows.

Stack

  1. feat(analytics): X + LinkedIn analytics on ClickHouse #645 feat(analytics): X + LinkedIn analytics on ClickHouse
  2. feat(analytics): account leaderboard with tracked affiliates #646 feat(analytics): account leaderboard with tracked affiliates 👈
  3. feat(experiments): social A/B tests #647 feat(experiments): social A/B tests
  4. feat(geo): AI visibility tracking #648 feat(geo): AI visibility tracking
  5. feat(agents): analytics + GEO tools for eve and Iris #649 feat(agents): analytics + GEO tools for eve and Iris

Summary by cubic

Adds an account leaderboard that ranks connected and tracked X accounts by interactions, plus a 30‑day impressions share donut. Speeds up analytics with a @upstash/redis cache over Tinybird and purges data when accounts are untracked.

  • New Features

    • Leaderboard on Analytics > Accounts with 7/30‑day windows, verification badges, square business avatars, and a simplified header (no rank‑change column or track button; window select pinned to module header height).
    • Impressions share donut (last 30 days) using new @notra/ui dither‑kit pie/radar chart primitives.
    • Untrack any X handle from the leaderboard; resolves via the X API and purges ClickHouse on untrack. Header connect buttons for X and LinkedIn.
    • Chart tooltips show the hovered day label; pointer cursors on interactive controls.
    • Instrument panel UI: instrument modules/grid with reveal animations; updated skeletons for the new layout.
    • Redis‑backed Tinybird query cache (6‑hour TTL) with per‑scope/org version bumps to invalidate on ingest.
  • Migration

    • New table tracked_social_accounts (with unique org/provider/account index).
    • Tinybird pipe account_leaderboard to aggregate current and prior window totals.
    • Analytics sync now unions connected and tracked accounts, deduped by provider account id and username.

Written for commit 92389da. Summary will update on new commits.

Review in cubic

Summary by Comp AI

2 issues found.

Written for commit 92389da. New commits will trigger a re-review. Generated by Comp AI.

@cursor

cursor Bot commented Aug 2, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@comp-ai-code-review

comp-ai-code-review Bot commented Aug 2, 2026

Copy link
Copy Markdown

Comp AI code review complete — 2 issues found.

Commit 92389da · Posted by Comp AI Code Reviews.

@vercel

vercel Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

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

Project Deployment Actions Updated (UTC)
notra Ready Ready Preview Aug 3, 2026 12:31pm
4 Skipped Deployments
Project Deployment Actions Updated (UTC)
notra-agent Skipped Skipped Aug 3, 2026 12:31pm
notra-console Skipped Skipped Aug 3, 2026 12:31pm
notra-onboarding-agent Skipped Skipped Aug 3, 2026 12:31pm
notra-web Skipped Skipped Aug 3, 2026 12:31pm

Request Review

@capy-ai

capy-ai Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Capy auto-review is paused for this organization because the usage-cycle auto-review limit has been reached. Increase the limit or turn it off in billing settings to resume automatic reviews.

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown

React Doctor found 6 new issues in 4 files · 6 warnings · score 77 / 100 (Needs work) · 0 fixed · vs feat/social-analytics

6 warnings

src/components/analytics/impressions-share-card.tsx

  • ⚠️ L37 Manual memoization in compiler-managed code react-compiler-no-manual-memoization
  • ⚠️ L38 Chained array iterations js-combine-iterations

src/lib/analytics/leaderboard.ts

  • ⚠️ L21 Spread copy before sort() js-tosorted-immutable

src/lib/orpc/routers/analytics.ts

  • ⚠️ L376 Chained array iterations js-combine-iterations

src/workflows/steps/social-analytics-steps.ts

  • ⚠️ L41 Sequential independent awaits server-sequential-independent-await
  • ⚠️ L74 Chained array iterations js-combine-iterations

Reviewed by React Doctor for commit 538f7c3. See inline comments for fixes.

@greptile-apps

greptile-apps Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds an organization-scoped account leaderboard combining connected and tracked X accounts, including tracking mutations, immediate and scheduled ingestion, Tinybird window aggregation, and dashboard UI.

  • Adds the tracked_social_accounts schema and migration.
  • Adds leaderboard, track, and untrack oRPC procedures plus Tinybird endpoint wiring.
  • Extends analytics synchronization to include deduplicated tracked accounts.
  • Adds 7/30-day leaderboard UI with rank movement and expandable account details.

Confidence Score: 4/5

The tracked-account-only experience and tracked-row detail behavior need to be fixed before merging because key advertised flows are currently unreachable or permanently misleading.

The page derives its empty state and detail payload solely from connected accounts, which prevents organizations without OAuth connections from rendering the leaderboard and prevents tracked entries from ever showing their synchronized lifetime statistics.

Files Needing Attention: apps/dashboard/src/app/(dashboard)/[slug]/analytics/page-client.tsx and apps/dashboard/src/components/analytics/leaderboard-card.tsx

Important Files Changed

Filename Overview
apps/dashboard/src/app/(dashboard)/[slug]/analytics/page-client.tsx Adds the leaderboard section, but the existing connected-account early return makes it unavailable to tracked-only organizations and supplies connected-only detail data.
apps/dashboard/src/components/analytics/leaderboard-card.tsx Implements ranking, tracking controls, and expanded details; tracked rows cannot receive the lifetime detail shape supplied by the parent.
apps/dashboard/src/lib/orpc/routers/analytics.ts Adds organization-authorized leaderboard and tracked-account mutations with connected/tracked deduplication and immediate ingestion.
apps/dashboard/src/workflows/steps/social-analytics-steps.ts Extends scheduled synchronization to union connected and tracked accounts while deduplicating by provider identity and username.
packages/analytics/src/tinybird/endpoints.ts Adds current and preceding window aggregation for per-account posts, interactions, and impressions.
packages/db/migrations/0062_brave_menace.sql Adds the organization-scoped tracked-account table, foreign key, lookup index, and provider-account uniqueness constraint.
packages/db/src/schema.ts Defines the trackedSocialAccounts table consistently with the generated migration and organization relationship.

Sequence Diagram

sequenceDiagram
  participant User
  participant UI as LeaderboardCard
  participant RPC as Analytics oRPC
  participant X as X API
  participant DB as Postgres
  participant TB as Tinybird
  User->>UI: "Track @affiliate"
  UI->>RPC: trackAccount(org, username)
  RPC->>X: Resolve username
  X-->>RPC: Account metadata
  RPC->>DB: Insert tracked_social_accounts
  RPC->>X: Fetch account and posts
  RPC->>TB: Ingest dimensions, stats, and posts
  RPC-->>UI: Tracking succeeded
  UI->>RPC: leaderboard(org, days)
  RPC->>DB: Load connected and tracked accounts
  RPC->>TB: Query current and previous windows
  RPC-->>UI: Ranked entries
Loading

Comments Outside Diff (1)

  1. apps/dashboard/src/app/(dashboard)/[slug]/analytics/page-client.tsx, line 174 (link)

    P1 Connected-only empty state blocks tracking

    When an organization has no OAuth-connected accounts, overview.accounts is empty and this early return prevents LeaderboardCard from rendering. Organizations with only tracked accounts cannot see those accounts, and new organizations cannot access the only control for tracking their first affiliate.

    Knowledge Base Used: Dashboard App Core Structure

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Reviews (1): Last reviewed commit: "feat(analytics): add account leaderboard..." | Re-trigger Greptile

Comment on lines +233 to +235
<LeaderboardCard
accountDetails={accounts}
organizationId={organizationId}

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.

P1 Tracked rows never receive details

When a user expands a tracked-only account after synchronization, accountDetails still contains only the connected-account overview. The username lookup therefore always misses and permanently displays “Lifetime stats appear after this account's first sync” instead of the synchronized account statistics.

@comp-ai-code-review comp-ai-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

4 issues found across 3 files.

Prompt for AI agents (all issues)
Check whether each issue below is valid; if so, find the root cause and fix it. Read the referenced code to confirm the problem before changing it, and use sub-agents to handle independent issues in parallel.

<file name="apps/dashboard/src/lib/orpc/routers/analytics.ts">
<issue n="1" at="apps/dashboard/src/lib/orpc/routers/analytics.ts:58-480" severity="MEDIUM">trackAccount endpoint calls shared paid Twitter API with no rate limiting — The `trackAccount` procedure (L417) is authenticated and org-membership-checked, but has no rate limiting before invoking expensive external API calls. Every invocation calls `resolveTwitterAccount(input.username)` (L426) — a Twitter API v2 call using the shared app-level `TWITTER_BEARER_TOKEN` — BEFORE the existing-account dedup checks at L432/L448. If the account is new, it then triggers `syncTrackedAccountNow` (L480), whose body (L58-63) calls `collectTwitterRows`, which performs a batched `fetchTwitterUsersBatch` call plus `fetchUserTweets` for the user (up to TWITTER_TIMELINE_MAX_PAGES=5 paginated timeline requests, per apps/dashboard/src/lib/analytics/twitter-sync.ts and constants.ts). So a single trackAccount call burns 1-7+ Twitter API requests against a token shared across ALL organizations on the platform. Because the bearer token is app-level (not per-org), any authenticated member of any organization can repeatedly POST trackAccount with arbitrary usernames (Zod only constrains length<=30) to exhaust the shared Twitter API rate-limit/quota for every tenant simultaneously — a cross-tenant availability/cost-abuse impact. The call happens even for usernames that are already tracked (resolveTwitterAccount runs before the early-return at L448), so an attacker need not find new accounts to amplify the abuse. No framework-level rate limiting wraps this handler (the only gating is auth + membership). Fix: Add per-user/per-org rate limiting to the trackAccount (and any sync-triggering) procedure, e.g. an oRPC middleware or Upstash/Redis sliding-window limiter keyed on the authenticated user id and organizationId. Also reorder the handler to check the existing-connected/existing-tracked dedup (DB-only) BEFORE calling resolveTwitterAccount, so already-tracked usernames don't consume a Twitter API call. Consider a separate background job (queue) for the initial sync rather than doing it inline in the request.</issue>
<issue n="2" at="apps/dashboard/src/lib/orpc/routers/analytics.ts:448-467" severity="MEDIUM">Concurrent trackAccount requests for the same username can 500 on unique-index conflict — In `trackAccount`, the existence check `db.query.trackedSocialAccounts.findFirst` (L448) and the subsequent `db.insert` (L467) are not atomic. Two concurrent requests for the same new Twitter handle will both observe no existing tracked row, both proceed past the checks, and both attempt an insert. The second insert collides with the `trackedSocialAccounts_org_provider_account_uidx` unique index and throws a database constraint error, which propagates as an unhandled 500 rather than gracefully returning the existing trackedAccountId (the way the single-request path does at L452-455). The unique index prevents data corruption, so this is a correctness/UX bug rather than a security issue. Fix: Catch the unique-constraint violation on insert and re-read the existing row to return its id (idempotent behavior), or use an upsert (`onConflictDoNothing().returning()`) against the unique index, mirroring the existing-tracked early-return path.</issue>
</file>

<file name="packages/db/migrations/meta/0062_snapshot.json">
<issue n="3" at="packages/db/migrations/meta/0062_snapshot.json:36-71" severity="MEDIUM">OAuth access/refresh/id tokens stored as plaintext in accounts table, inconsistent with codebase-wide at-rest encryption — The `accounts` table (Better Auth account-linking table) stores third-party OAuth credentials as plain `text` columns: `access_token` (snapshot line 36), `refresh_token` (line 42), and `id_token` (line 48), with no `encrypted_` prefix and no indication of at-rest encryption. This is confirmed against the schema source in `packages/db/src/schema.ts:183-185` where `accessToken`/`refreshToken`/`idToken` are defined as plain `text()`. This directly conflicts with the codebase's own deliberate convention of encrypting every other integration secret at rest, as evidenced by the `encrypted_` column naming used throughout the same schema: `linear_integrations.encrypted_access_token`/`encrypted_webhook_secret`, `slack_integrations.encrypted_bot_token`, `granola_integrations.encrypted_api_key`, `github_integrations.encrypted_token`, and the app's own OAuth server tables (`oauth_clients.encrypted_tokens`, `oauth_authorization_codes.encrypted_*`). Because the `accounts` table holds LIVE, immediately-usable OAuth tokens for external identity providers (Google, GitHub, etc.), a separate database-compromise vector (SQL injection elsewhere in the app, a leaked DB backup, exposed DB credentials, or an insider with read access) would expose these tokens in cleartext, enabling account takeover across every linked provider for every user, plus PII disclosure from `id_token` payloads. The same compromise of the `*_integrations` tables would NOT yield usable secrets because those are encrypted at rest. Note: the `password` column (line 72) is not the concern — Better Auth stores a password hash there, not a plaintext password. Caveat: this is a generated snapshot reflecting the schema; the underlying decision lives in `schema.ts` and the Better Auth library's default account storage, which writes tokens directly to these columns without application-level encryption. Fix: Encrypt the `access_token`, `refresh_token`, and `id_token` columns at rest using the same encryption utility already used for the `encrypted_*` columns in the integration tables. If Better Auth writes these columns directly and cannot be easily intercepted, consider either (a) post-processing tokens through a Better Auth hook/database adapter to encrypt before write and decrypt on read, (b) enabling database-level encryption (e.g., Postgres TDE / pgcrypto) for the `accounts` table, or (c) at minimum, shortening token retention and periodically purging `access_token`/`refresh_token` once they are no longer needed for active sessions. Apply the encryption retroactively to existing rows via a migration.</issue>
</file>

<file name="apps/dashboard/src/workflows/steps/social-analytics-steps.ts">
<issue n="4" at="apps/dashboard/src/workflows/steps/social-analytics-steps.ts:40-83" severity="MEDIUM">Tracked-only social accounts always synced to Tinybird with verified=false — In `listSyncableAccounts`, the query against `trackedSocialAccounts` (lines 40-53) selects id, organizationId, provider, providerAccountId, username, displayName, profileImageUrl but omits the `verified` column. The `tracked_social_accounts` table does have a `verified` column (per migration 0062_brave_menace.sql), and the `trackAccount` handler in lib/orpc/routers/analytics.ts populates it from the Twitter API (`verified: resolved.verified`). However, the `trackedOnly` mapping at line 83 hardcodes `verified: false`. These accounts flow through `snapshotAccountDimensions` -> `buildAccountRow` (which sets `verified: account.verified`) and are ingested into Tinybird as account dimensions. As a result, every tracked-only (non-OAuth-connected) account is persisted to the analytics warehouse with `verified=false` regardless of its real verification status. This is inconsistent with the `leaderboard` handler, which reads `verified`/`verifiedType` from the same `trackedSocialAccounts` table and returns the correct value. Any analytics view or downstream consumer relying on Tinybird's `verified` dimension will incorrectly display tracked accounts as unverified. This is a data-correctness logic bug, not a security vulnerability. Note: the `connected` accounts query (line 30) correctly selects `verified` and the `connected` mapping uses `verified: account.verified ?? false`, so only tracked-only accounts are affected. Fix: Add `verified: true` to the `trackedSocialAccounts.findMany` columns list in `listSyncableAccounts`, and change the `trackedOnly` mapping to use `verified: account.verified ?? false` instead of the hardcoded `false`. If the Tinybird `SocialAccountRow` datasource supports a verified-type field and it is desired, also select `verifiedType: true` and propagate it.</issue>
</file>

Commit b0b9c20 · Posted by Comp AI Code Reviews.

});

try {
await syncTrackedAccountNow({

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

MEDIUM: trackAccount endpoint calls shared paid Twitter API with no rate limiting

The trackAccount procedure (L417) is authenticated and org-membership-checked, but has no rate limiting before invoking expensive external API calls. Every invocation calls resolveTwitterAccount(input.username) (L426) — a Twitter API v2 call using the shared app-level TWITTER_BEARER_TOKEN — BEFORE the existing-account dedup checks at L432/L448. If the account is new, it then triggers syncTrackedAccountNow (L480), whose body (L58-63) calls collectTwitterRows, which performs a batched fetchTwitterUsersBatch call plus fetchUserTweets for the user (up to TWITTER_TIMELINE_MAX_PAGES=5 paginated timeline requests, per apps/dashboard/src/lib/analytics/twitter-sync.ts and constants.ts). So a single trackAccount call burns 1-7+ Twitter API requests against a token shared across ALL organizations on the platform.

Because the bearer token is app-level (not per-org), any authenticated member of any organization can repeatedly POST trackAccount with arbitrary usernames (Zod only constrains length<=30) to exhaust the shared Twitter API rate-limit/quota for every tenant simultaneously — a cross-tenant availability/cost-abuse impact. The call happens even for usernames that are already tracked (resolveTwitterAccount runs before the early-return at L448), so an attacker need not find new accounts to amplify the abuse. No framework-level rate limiting wraps this handler (the only gating is auth + membership).

Suggestion: Add per-user/per-org rate limiting to the trackAccount (and any sync-triggering) procedure, e.g. an oRPC middleware or Upstash/Redis sliding-window limiter keyed on the authenticated user id and organizationId. Also reorder the handler to check the existing-connected/existing-tracked dedup (DB-only) BEFORE calling resolveTwitterAccount, so already-tracked usernames don't consume a Twitter API call. Consider a separate background job (queue) for the initial sync rather than doing it inline in the request.

Prompt for AI agents
Check whether this issue is valid; if so, find the root cause and fix it. Read the referenced code to confirm the problem before changing it.

<issue at="apps/dashboard/src/lib/orpc/routers/analytics.ts:58-480" severity="MEDIUM">trackAccount endpoint calls shared paid Twitter API with no rate limiting — The `trackAccount` procedure (L417) is authenticated and org-membership-checked, but has no rate limiting before invoking expensive external API calls. Every invocation calls `resolveTwitterAccount(input.username)` (L426) — a Twitter API v2 call using the shared app-level `TWITTER_BEARER_TOKEN` — BEFORE the existing-account dedup checks at L432/L448. If the account is new, it then triggers `syncTrackedAccountNow` (L480), whose body (L58-63) calls `collectTwitterRows`, which performs a batched `fetchTwitterUsersBatch` call plus `fetchUserTweets` for the user (up to TWITTER_TIMELINE_MAX_PAGES=5 paginated timeline requests, per apps/dashboard/src/lib/analytics/twitter-sync.ts and constants.ts). So a single trackAccount call burns 1-7+ Twitter API requests against a token shared across ALL organizations on the platform. Because the bearer token is app-level (not per-org), any authenticated member of any organization can repeatedly POST trackAccount with arbitrary usernames (Zod only constrains length<=30) to exhaust the shared Twitter API rate-limit/quota for every tenant simultaneously — a cross-tenant availability/cost-abuse impact. The call happens even for usernames that are already tracked (resolveTwitterAccount runs before the early-return at L448), so an attacker need not find new accounts to amplify the abuse. No framework-level rate limiting wraps this handler (the only gating is auth + membership). Fix: Add per-user/per-org rate limiting to the trackAccount (and any sync-triggering) procedure, e.g. an oRPC middleware or Upstash/Redis sliding-window limiter keyed on the authenticated user id and organizationId. Also reorder the handler to check the existing-connected/existing-tracked dedup (DB-only) BEFORE calling resolveTwitterAccount, so already-tracked usernames don't consume a Twitter API call. Consider a separate background job (queue) for the initial sync rather than doing it inline in the request.</issue>

Commit b0b9c20.

Comment on lines +36 to +71
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

MEDIUM: OAuth access/refresh/id tokens stored as plaintext in accounts table, inconsistent with codebase-wide at-rest encryption

The accounts table (Better Auth account-linking table) stores third-party OAuth credentials as plain text columns: access_token (snapshot line 36), refresh_token (line 42), and id_token (line 48), with no encrypted_ prefix and no indication of at-rest encryption. This is confirmed against the schema source in packages/db/src/schema.ts:183-185 where accessToken/refreshToken/idToken are defined as plain text(). This directly conflicts with the codebase's own deliberate convention of encrypting every other integration secret at rest, as evidenced by the encrypted_ column naming used throughout the same schema: linear_integrations.encrypted_access_token/encrypted_webhook_secret, slack_integrations.encrypted_bot_token, granola_integrations.encrypted_api_key, github_integrations.encrypted_token, and the app's own OAuth server tables (oauth_clients.encrypted_tokens, oauth_authorization_codes.encrypted_*). Because the accounts table holds LIVE, immediately-usable OAuth tokens for external identity providers (Google, GitHub, etc.), a separate database-compromise vector (SQL injection elsewhere in the app, a leaked DB backup, exposed DB credentials, or an insider with read access) would expose these tokens in cleartext, enabling account takeover across every linked provider for every user, plus PII disclosure from id_token payloads. The same compromise of the *_integrations tables would NOT yield usable secrets because those are encrypted at rest. Note: the password column (line 72) is not the concern — Better Auth stores a password hash there, not a plaintext password. Caveat: this is a generated snapshot reflecting the schema; the underlying decision lives in schema.ts and the Better Auth library's default account storage, which writes tokens directly to these columns without application-level encryption.

Suggestion: Encrypt the access_token, refresh_token, and id_token columns at rest using the same encryption utility already used for the encrypted_* columns in the integration tables. If Better Auth writes these columns directly and cannot be easily intercepted, consider either (a) post-processing tokens through a Better Auth hook/database adapter to encrypt before write and decrypt on read, (b) enabling database-level encryption (e.g., Postgres TDE / pgcrypto) for the accounts table, or (c) at minimum, shortening token retention and periodically purging access_token/refresh_token once they are no longer needed for active sessions. Apply the encryption retroactively to existing rows via a migration.

Prompt for AI agents
Check whether this issue is valid; if so, find the root cause and fix it. Read the referenced code to confirm the problem before changing it.

<issue at="packages/db/migrations/meta/0062_snapshot.json:36-71" severity="MEDIUM">OAuth access/refresh/id tokens stored as plaintext in accounts table, inconsistent with codebase-wide at-rest encryption — The `accounts` table (Better Auth account-linking table) stores third-party OAuth credentials as plain `text` columns: `access_token` (snapshot line 36), `refresh_token` (line 42), and `id_token` (line 48), with no `encrypted_` prefix and no indication of at-rest encryption. This is confirmed against the schema source in `packages/db/src/schema.ts:183-185` where `accessToken`/`refreshToken`/`idToken` are defined as plain `text()`. This directly conflicts with the codebase's own deliberate convention of encrypting every other integration secret at rest, as evidenced by the `encrypted_` column naming used throughout the same schema: `linear_integrations.encrypted_access_token`/`encrypted_webhook_secret`, `slack_integrations.encrypted_bot_token`, `granola_integrations.encrypted_api_key`, `github_integrations.encrypted_token`, and the app's own OAuth server tables (`oauth_clients.encrypted_tokens`, `oauth_authorization_codes.encrypted_*`). Because the `accounts` table holds LIVE, immediately-usable OAuth tokens for external identity providers (Google, GitHub, etc.), a separate database-compromise vector (SQL injection elsewhere in the app, a leaked DB backup, exposed DB credentials, or an insider with read access) would expose these tokens in cleartext, enabling account takeover across every linked provider for every user, plus PII disclosure from `id_token` payloads. The same compromise of the `*_integrations` tables would NOT yield usable secrets because those are encrypted at rest. Note: the `password` column (line 72) is not the concern — Better Auth stores a password hash there, not a plaintext password. Caveat: this is a generated snapshot reflecting the schema; the underlying decision lives in `schema.ts` and the Better Auth library's default account storage, which writes tokens directly to these columns without application-level encryption. Fix: Encrypt the `access_token`, `refresh_token`, and `id_token` columns at rest using the same encryption utility already used for the `encrypted_*` columns in the integration tables. If Better Auth writes these columns directly and cannot be easily intercepted, consider either (a) post-processing tokens through a Better Auth hook/database adapter to encrypt before write and decrypt on read, (b) enabling database-level encryption (e.g., Postgres TDE / pgcrypto) for the `accounts` table, or (c) at minimum, shortening token retention and periodically purging `access_token`/`refresh_token` once they are no longer needed for active sessions. Apply the encryption retroactively to existing rows via a migration.</issue>

Commit b0b9c20.

Comment on lines +448 to +467
const existingTracked = await db.query.trackedSocialAccounts.findFirst({
columns: { id: true },
where: and(
eq(trackedSocialAccounts.organizationId, input.organizationId),
eq(trackedSocialAccounts.provider, "twitter"),
eq(
trackedSocialAccounts.providerAccountId,
resolved.providerAccountId
)
),
});
if (existingTracked) {
return {
trackedAccountId: existingTracked.id,
username: resolved.username,
};
}

const trackedAccountId = crypto.randomUUID();
await db.insert(trackedSocialAccounts).values({

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

MEDIUM: Concurrent trackAccount requests for the same username can 500 on unique-index conflict

In trackAccount, the existence check db.query.trackedSocialAccounts.findFirst (L448) and the subsequent db.insert (L467) are not atomic. Two concurrent requests for the same new Twitter handle will both observe no existing tracked row, both proceed past the checks, and both attempt an insert. The second insert collides with the trackedSocialAccounts_org_provider_account_uidx unique index and throws a database constraint error, which propagates as an unhandled 500 rather than gracefully returning the existing trackedAccountId (the way the single-request path does at L452-455). The unique index prevents data corruption, so this is a correctness/UX bug rather than a security issue.

Suggestion: Catch the unique-constraint violation on insert and re-read the existing row to return its id (idempotent behavior), or use an upsert (onConflictDoNothing().returning()) against the unique index, mirroring the existing-tracked early-return path.

Prompt for AI agents
Check whether this issue is valid; if so, find the root cause and fix it. Read the referenced code to confirm the problem before changing it.

<issue at="apps/dashboard/src/lib/orpc/routers/analytics.ts:448-467" severity="MEDIUM">Concurrent trackAccount requests for the same username can 500 on unique-index conflict — In `trackAccount`, the existence check `db.query.trackedSocialAccounts.findFirst` (L448) and the subsequent `db.insert` (L467) are not atomic. Two concurrent requests for the same new Twitter handle will both observe no existing tracked row, both proceed past the checks, and both attempt an insert. The second insert collides with the `trackedSocialAccounts_org_provider_account_uidx` unique index and throws a database constraint error, which propagates as an unhandled 500 rather than gracefully returning the existing trackedAccountId (the way the single-request path does at L452-455). The unique index prevents data corruption, so this is a correctness/UX bug rather than a security issue. Fix: Catch the unique-constraint violation on insert and re-read the existing row to return its id (idempotent behavior), or use an upsert (`onConflictDoNothing().returning()`) against the unique index, mirroring the existing-tracked early-return path.</issue>

Commit b0b9c20.

) ||
connectedKeys.has(
`${account.organizationId}:${account.provider}:@${account.username.toLowerCase()}`
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

MEDIUM: Tracked-only social accounts always synced to Tinybird with verified=false

In listSyncableAccounts, the query against trackedSocialAccounts (lines 40-53) selects id, organizationId, provider, providerAccountId, username, displayName, profileImageUrl but omits the verified column. The tracked_social_accounts table does have a verified column (per migration 0062_brave_menace.sql), and the trackAccount handler in lib/orpc/routers/analytics.ts populates it from the Twitter API (verified: resolved.verified). However, the trackedOnly mapping at line 83 hardcodes verified: false. These accounts flow through snapshotAccountDimensions -> buildAccountRow (which sets verified: account.verified) and are ingested into Tinybird as account dimensions. As a result, every tracked-only (non-OAuth-connected) account is persisted to the analytics warehouse with verified=false regardless of its real verification status. This is inconsistent with the leaderboard handler, which reads verified/verifiedType from the same trackedSocialAccounts table and returns the correct value. Any analytics view or downstream consumer relying on Tinybird's verified dimension will incorrectly display tracked accounts as unverified. This is a data-correctness logic bug, not a security vulnerability. Note: the connected accounts query (line 30) correctly selects verified and the connected mapping uses verified: account.verified ?? false, so only tracked-only accounts are affected.

Suggestion: Add verified: true to the trackedSocialAccounts.findMany columns list in listSyncableAccounts, and change the trackedOnly mapping to use verified: account.verified ?? false instead of the hardcoded false. If the Tinybird SocialAccountRow datasource supports a verified-type field and it is desired, also select verifiedType: true and propagate it.

Prompt for AI agents
Check whether this issue is valid; if so, find the root cause and fix it. Read the referenced code to confirm the problem before changing it.

<issue at="apps/dashboard/src/workflows/steps/social-analytics-steps.ts:40-83" severity="MEDIUM">Tracked-only social accounts always synced to Tinybird with verified=false — In `listSyncableAccounts`, the query against `trackedSocialAccounts` (lines 40-53) selects id, organizationId, provider, providerAccountId, username, displayName, profileImageUrl but omits the `verified` column. The `tracked_social_accounts` table does have a `verified` column (per migration 0062_brave_menace.sql), and the `trackAccount` handler in lib/orpc/routers/analytics.ts populates it from the Twitter API (`verified: resolved.verified`). However, the `trackedOnly` mapping at line 83 hardcodes `verified: false`. These accounts flow through `snapshotAccountDimensions` -> `buildAccountRow` (which sets `verified: account.verified`) and are ingested into Tinybird as account dimensions. As a result, every tracked-only (non-OAuth-connected) account is persisted to the analytics warehouse with `verified=false` regardless of its real verification status. This is inconsistent with the `leaderboard` handler, which reads `verified`/`verifiedType` from the same `trackedSocialAccounts` table and returns the correct value. Any analytics view or downstream consumer relying on Tinybird's `verified` dimension will incorrectly display tracked accounts as unverified. This is a data-correctness logic bug, not a security vulnerability. Note: the `connected` accounts query (line 30) correctly selects `verified` and the `connected` mapping uses `verified: account.verified ?? false`, so only tracked-only accounts are affected. Fix: Add `verified: true` to the `trackedSocialAccounts.findMany` columns list in `listSyncableAccounts`, and change the `trackedOnly` mapping to use `verified: account.verified ?? false` instead of the hardcoded `false`. If the Tinybird `SocialAccountRow` datasource supports a verified-type field and it is desired, also select `verifiedType: true` and propagate it.</issue>

Commit b0b9c20.

@vercel
vercel Bot temporarily deployed to Preview – notra-web August 2, 2026 11:22 Inactive
@vercel
vercel Bot temporarily deployed to Preview – notra-console August 2, 2026 11:22 Inactive
@vercel
vercel Bot temporarily deployed to Preview – notra-agent August 2, 2026 11:22 Inactive
@vercel
vercel Bot temporarily deployed to Preview – notra-onboarding-agent August 2, 2026 11:22 Inactive

@comp-ai-code-review comp-ai-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No blocking issues found across the changed files.

Commit f3c6cbe · Posted by Comp AI Code Reviews.

@vercel
vercel Bot temporarily deployed to Preview – notra-onboarding-agent August 2, 2026 11:29 Inactive
@vercel
vercel Bot temporarily deployed to Preview – notra-console August 2, 2026 11:29 Inactive
@vercel
vercel Bot temporarily deployed to Preview – notra-agent August 2, 2026 11:29 Inactive
@vercel
vercel Bot temporarily deployed to Preview – notra-web August 2, 2026 11:29 Inactive

@comp-ai-code-review comp-ai-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No blocking issues found across the changed files.

Commit b6805a1 · Posted by Comp AI Code Reviews.

@comp-ai-code-review comp-ai-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 issue found across 1 file.

Prompt for AI agents (all issues)
Check whether each issue below is valid; if so, find the root cause and fix it. Read the referenced code to confirm the problem before changing it, and use sub-agents to handle independent issues in parallel.

<file name="packages/analytics/src/cache/query-cache.ts">
<issue n="1" at="packages/analytics/src/cache/query-cache.ts:43-94" severity="MEDIUM">Silent error swallowing in bumpAnalyticsVersions can serve stale analytics data for up to 6 hours — In `bumpAnalyticsVersions` (lines 79-95), the Redis pipeline that increments per-organization cache version keys is wrapped with `Effect.ignore` (line 94). If the Redis pipeline fails (network error, rate limit, timeout, etc.), the error is silently swallowed and the version counter is never incremented. Since `cachedQuery` uses the version number as a cache-buster in the cache key (line 61), a failed version bump means the query cache will continue serving stale data until the TTL expires (QUERY_CACHE_TTL_SECONDS = 21,600 seconds = 6 hours, per constants/cache.ts). This occurs right after successful data ingestion into Tinybird — `ingestRows` in client.ts awaits `bumpAnalyticsVersions` (line 93) after a successful ingest, but a silent failure there leaves the cache pointing at the pre-ingestion version. The `readVersion` function (lines 35-46) has a related issue: on Redis GET failure it falls back to `INITIAL_CACHE_VERSION = 0` via `Effect.orElseSucceed(() => null)` (line 43) and `version ?? INITIAL_CACHE_VERSION` (line 44), which could cause queries to read a version-0 cache key that may contain very stale or orphaned data. While this is a data-freshness/reliability concern rather than a direct security vulnerability (cached data remains scoped to the correct organization via params), it could cause significant user-facing analytics discrepancies after data ingestion events, with stale data persisting for hours. Fix: Log errors from the version bump pipeline instead of silently ignoring them with `Effect.ignore`, so operators can detect cache invalidation failures. Consider adding retry logic with exponential backoff for the version bump, or using a shorter fallback TTL when version bump failures are detected. For `readVersion`, consider treating Redis GET failures as a cache miss (forcing a fresh fetch) rather than falling back to version 0, which risks serving stale cached entries.</issue>
</file>

Commit c737f8b · Posted by Comp AI Code Reviews.

Comment on lines +43 to +94
Effect.orElseSucceed(() => null),
Effect.map((version) => version ?? INITIAL_CACHE_VERSION)
);
}

export function cachedQuery<TResult>(
options: CachedQueryOptions<TResult>
): Promise<TResult> {
const redis = getAnalyticsRedis();
if (!redis) {
return options.fetch();
}
const program = Effect.gen(function* () {
const version = yield* readVersion(
redis,
options.scope,
options.organizationId
);
const key = `${QUERY_CACHE_KEY_PREFIX}:${options.scope}:${version}:${options.pipe}:${stableParams(options.params)}`;
const hit = yield* Effect.tryPromise(() => redis.get<TResult>(key)).pipe(
Effect.orElseSucceed(() => null)
);
if (hit !== null) {
return hit;
}
const fresh = yield* Effect.tryPromise(() => options.fetch());
if (fresh !== null) {
yield* Effect.tryPromise(() =>
redis.set(key, toJsonSafe(fresh), { ex: QUERY_CACHE_TTL_SECONDS })
).pipe(Effect.ignore);
}
return fresh;
});
return Effect.runPromise(program);
}

export function bumpAnalyticsVersions(
scope: AnalyticsCacheScope,
organizationIds: ReadonlyArray<string | null>
): Promise<void> {
const redis = getAnalyticsRedis();
const keys = [...new Set(organizationIds.map((id) => versionKey(scope, id)))];
if (!redis || keys.length === 0) {
return Promise.resolve();
}
const program = Effect.tryPromise(() => {
const pipeline = redis.pipeline();
for (const key of keys) {
pipeline.incr(key);
}
return pipeline.exec();
}).pipe(Effect.ignore);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

MEDIUM: Silent error swallowing in bumpAnalyticsVersions can serve stale analytics data for up to 6 hours

In bumpAnalyticsVersions (lines 79-95), the Redis pipeline that increments per-organization cache version keys is wrapped with Effect.ignore (line 94). If the Redis pipeline fails (network error, rate limit, timeout, etc.), the error is silently swallowed and the version counter is never incremented. Since cachedQuery uses the version number as a cache-buster in the cache key (line 61), a failed version bump means the query cache will continue serving stale data until the TTL expires (QUERY_CACHE_TTL_SECONDS = 21,600 seconds = 6 hours, per constants/cache.ts). This occurs right after successful data ingestion into Tinybird — ingestRows in client.ts awaits bumpAnalyticsVersions (line 93) after a successful ingest, but a silent failure there leaves the cache pointing at the pre-ingestion version. The readVersion function (lines 35-46) has a related issue: on Redis GET failure it falls back to INITIAL_CACHE_VERSION = 0 via Effect.orElseSucceed(() => null) (line 43) and version ?? INITIAL_CACHE_VERSION (line 44), which could cause queries to read a version-0 cache key that may contain very stale or orphaned data. While this is a data-freshness/reliability concern rather than a direct security vulnerability (cached data remains scoped to the correct organization via params), it could cause significant user-facing analytics discrepancies after data ingestion events, with stale data persisting for hours.

Suggestion: Log errors from the version bump pipeline instead of silently ignoring them with Effect.ignore, so operators can detect cache invalidation failures. Consider adding retry logic with exponential backoff for the version bump, or using a shorter fallback TTL when version bump failures are detected. For readVersion, consider treating Redis GET failures as a cache miss (forcing a fresh fetch) rather than falling back to version 0, which risks serving stale cached entries.

Prompt for AI agents
Check whether this issue is valid; if so, find the root cause and fix it. Read the referenced code to confirm the problem before changing it.

<issue at="packages/analytics/src/cache/query-cache.ts:43-94" severity="MEDIUM">Silent error swallowing in bumpAnalyticsVersions can serve stale analytics data for up to 6 hours — In `bumpAnalyticsVersions` (lines 79-95), the Redis pipeline that increments per-organization cache version keys is wrapped with `Effect.ignore` (line 94). If the Redis pipeline fails (network error, rate limit, timeout, etc.), the error is silently swallowed and the version counter is never incremented. Since `cachedQuery` uses the version number as a cache-buster in the cache key (line 61), a failed version bump means the query cache will continue serving stale data until the TTL expires (QUERY_CACHE_TTL_SECONDS = 21,600 seconds = 6 hours, per constants/cache.ts). This occurs right after successful data ingestion into Tinybird — `ingestRows` in client.ts awaits `bumpAnalyticsVersions` (line 93) after a successful ingest, but a silent failure there leaves the cache pointing at the pre-ingestion version. The `readVersion` function (lines 35-46) has a related issue: on Redis GET failure it falls back to `INITIAL_CACHE_VERSION = 0` via `Effect.orElseSucceed(() => null)` (line 43) and `version ?? INITIAL_CACHE_VERSION` (line 44), which could cause queries to read a version-0 cache key that may contain very stale or orphaned data. While this is a data-freshness/reliability concern rather than a direct security vulnerability (cached data remains scoped to the correct organization via params), it could cause significant user-facing analytics discrepancies after data ingestion events, with stale data persisting for hours. Fix: Log errors from the version bump pipeline instead of silently ignoring them with `Effect.ignore`, so operators can detect cache invalidation failures. Consider adding retry logic with exponential backoff for the version bump, or using a shorter fallback TTL when version bump failures are detected. For `readVersion`, consider treating Redis GET failures as a cache miss (forcing a fresh fetch) rather than falling back to version 0, which risks serving stale cached entries.</issue>

Commit c737f8b.

@vercel
vercel Bot temporarily deployed to Preview – notra-console August 2, 2026 21:10 Inactive
@vercel
vercel Bot temporarily deployed to Preview – notra-agent August 2, 2026 21:10 Inactive
@vercel
vercel Bot temporarily deployed to Preview – notra-web August 2, 2026 21:10 Inactive
@vercel
vercel Bot temporarily deployed to Preview – notra-onboarding-agent August 2, 2026 21:10 Inactive

@comp-ai-code-review comp-ai-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

2 issues found across 1 file.

Prompt for AI agents (all issues)
Check whether each issue below is valid; if so, find the root cause and fix it. Read the referenced code to confirm the problem before changing it, and use sub-agents to handle independent issues in parallel.

<file name="apps/dashboard/src/lib/orpc/routers/analytics.ts">
<issue n="1" at="apps/dashboard/src/lib/orpc/routers/analytics.ts:24-498" severity="MEDIUM">trackAccount and previewTrackAccount call paid Twitter API without rate limiting — The `trackAccount` and `previewTrackAccount` procedures are `authorizedProcedure` handlers (auth-only; no rate limiting) that call the paid/rate-limited Twitter (X) API. `previewTrackAccount` calls `resolveTwitterAccount(input.username)` → `twitterAppFetch('https://api.x.com/2/users/by/username/...')` using the app bearer token. `trackAccount` does the same lookup AND, after inserting a row, calls `syncTrackedAccountNow`, which runs `collectTwitterRows`: a `fetchTwitterUsersBatch` call plus, for each matched account, `fetchUserTweets` that paginates up to `TWITTER_TIMELINE_MAX_PAGES` (5) × `TWITTER_TIMELINE_MAX_RESULTS` (100) = up to 500 timeline tweets per invocation — all billed against the shared `TWITTER_BEARER_TOKEN` quota. Any authenticated user of any org can hammer `analytics.trackAccount` / `analytics.previewTrackAccount` in a tight loop (the only throttle is Twitter's own per-app v2 limit) to exhaust the global Twitter API quota for the whole deployment and/or enumerate X handles. This contrasts with sibling endpoints in the same app that DO rate-limit equivalent Twitter calls: `routers/brand.ts` wraps `fetchTweet` with `ratelimit.fetchTweet` (30/min) and tweet imports with `ratelimit.importTweets` (20/min). The analytics router has no such guard. The `username` input is only constrained by `trackAccountInputSchema` (1–30 chars), not by any per-user/per-org rate limit. Fix: Apply an Upstash rate limit (e.g. `ratelimit.fetchTweet`-style sliding window) keyed by `context.user.id` (and/or `input.organizationId`) at the top of `previewTrackAccount` and `trackAccount`, before calling `resolveTwitterAccount`/`syncTrackedAccountNow`. Consider a tighter limit for `trackAccount` since it also fans out into timeline pagination. Reuse the existing `ratelimit` utility in `src/utils/ratelimit.ts` for consistency with the rest of the app.</issue>
<issue n="2" at="apps/dashboard/src/lib/orpc/routers/analytics.ts:18-500" severity="MEDIUM">trackAccount always ingests verified=false into analytics, ignoring resolved verification status — In `trackAccount`, after resolving the Twitter account, the DB insert correctly stores `verified: resolved.verified` and `verifiedType: resolved.verifiedType`. But the immediately-following `syncTrackedAccountNow` call builds a `SyncableSocialAccount` literal with `verified: false` hardcoded (line ~498, the object passed to `syncTrackedAccountNow`), even though `resolved.verified` is in scope. `buildAccountRow` (rows.ts) then writes that `verified: false` into the `social_accounts` Tinybird datasource row. So every freshly-tracked account's first analytics ingest records `verified: false` regardless of the account's actual X verification status, and the leaderboard/overview `verified` field derived from those rows will be wrong until a later sync corrects it. This is a data-correctness bug, not a security issue. Fix: Pass `verified: resolved.verified` (and `verifiedType`) into the `SyncableSocialAccount` object passed to `syncTrackedAccountNow`, matching the values written to the DB.</issue>
</file>

Commit 143412a · Posted by Comp AI Code Reviews.

};
}

const trackedAccountId = crypto.randomUUID();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

MEDIUM: trackAccount and previewTrackAccount call paid Twitter API without rate limiting

The trackAccount and previewTrackAccount procedures are authorizedProcedure handlers (auth-only; no rate limiting) that call the paid/rate-limited Twitter (X) API. previewTrackAccount calls resolveTwitterAccount(input.username)twitterAppFetch('https://api.x.com/2/users/by/username/...') using the app bearer token. trackAccount does the same lookup AND, after inserting a row, calls syncTrackedAccountNow, which runs collectTwitterRows: a fetchTwitterUsersBatch call plus, for each matched account, fetchUserTweets that paginates up to TWITTER_TIMELINE_MAX_PAGES (5) × TWITTER_TIMELINE_MAX_RESULTS (100) = up to 500 timeline tweets per invocation — all billed against the shared TWITTER_BEARER_TOKEN quota. Any authenticated user of any org can hammer analytics.trackAccount / analytics.previewTrackAccount in a tight loop (the only throttle is Twitter's own per-app v2 limit) to exhaust the global Twitter API quota for the whole deployment and/or enumerate X handles. This contrasts with sibling endpoints in the same app that DO rate-limit equivalent Twitter calls: routers/brand.ts wraps fetchTweet with ratelimit.fetchTweet (30/min) and tweet imports with ratelimit.importTweets (20/min). The analytics router has no such guard. The username input is only constrained by trackAccountInputSchema (1–30 chars), not by any per-user/per-org rate limit.

Suggestion: Apply an Upstash rate limit (e.g. ratelimit.fetchTweet-style sliding window) keyed by context.user.id (and/or input.organizationId) at the top of previewTrackAccount and trackAccount, before calling resolveTwitterAccount/syncTrackedAccountNow. Consider a tighter limit for trackAccount since it also fans out into timeline pagination. Reuse the existing ratelimit utility in src/utils/ratelimit.ts for consistency with the rest of the app.

Prompt for AI agents
Check whether this issue is valid; if so, find the root cause and fix it. Read the referenced code to confirm the problem before changing it.

<issue at="apps/dashboard/src/lib/orpc/routers/analytics.ts:24-498" severity="MEDIUM">trackAccount and previewTrackAccount call paid Twitter API without rate limiting — The `trackAccount` and `previewTrackAccount` procedures are `authorizedProcedure` handlers (auth-only; no rate limiting) that call the paid/rate-limited Twitter (X) API. `previewTrackAccount` calls `resolveTwitterAccount(input.username)` → `twitterAppFetch('https://api.x.com/2/users/by/username/...')` using the app bearer token. `trackAccount` does the same lookup AND, after inserting a row, calls `syncTrackedAccountNow`, which runs `collectTwitterRows`: a `fetchTwitterUsersBatch` call plus, for each matched account, `fetchUserTweets` that paginates up to `TWITTER_TIMELINE_MAX_PAGES` (5) × `TWITTER_TIMELINE_MAX_RESULTS` (100) = up to 500 timeline tweets per invocation — all billed against the shared `TWITTER_BEARER_TOKEN` quota. Any authenticated user of any org can hammer `analytics.trackAccount` / `analytics.previewTrackAccount` in a tight loop (the only throttle is Twitter's own per-app v2 limit) to exhaust the global Twitter API quota for the whole deployment and/or enumerate X handles. This contrasts with sibling endpoints in the same app that DO rate-limit equivalent Twitter calls: `routers/brand.ts` wraps `fetchTweet` with `ratelimit.fetchTweet` (30/min) and tweet imports with `ratelimit.importTweets` (20/min). The analytics router has no such guard. The `username` input is only constrained by `trackAccountInputSchema` (1–30 chars), not by any per-user/per-org rate limit. Fix: Apply an Upstash rate limit (e.g. `ratelimit.fetchTweet`-style sliding window) keyed by `context.user.id` (and/or `input.organizationId`) at the top of `previewTrackAccount` and `trackAccount`, before calling `resolveTwitterAccount`/`syncTrackedAccountNow`. Consider a tighter limit for `trackAccount` since it also fans out into timeline pagination. Reuse the existing `ratelimit` utility in `src/utils/ratelimit.ts` for consistency with the rest of the app.</issue>

Commit 143412a.


const trackedAccountId = crypto.randomUUID();
await db.insert(trackedSocialAccounts).values({
id: trackedAccountId,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

MEDIUM: trackAccount always ingests verified=false into analytics, ignoring resolved verification status

In trackAccount, after resolving the Twitter account, the DB insert correctly stores verified: resolved.verified and verifiedType: resolved.verifiedType. But the immediately-following syncTrackedAccountNow call builds a SyncableSocialAccount literal with verified: false hardcoded (line ~498, the object passed to syncTrackedAccountNow), even though resolved.verified is in scope. buildAccountRow (rows.ts) then writes that verified: false into the social_accounts Tinybird datasource row. So every freshly-tracked account's first analytics ingest records verified: false regardless of the account's actual X verification status, and the leaderboard/overview verified field derived from those rows will be wrong until a later sync corrects it. This is a data-correctness bug, not a security issue.

Suggestion: Pass verified: resolved.verified (and verifiedType) into the SyncableSocialAccount object passed to syncTrackedAccountNow, matching the values written to the DB.

Prompt for AI agents
Check whether this issue is valid; if so, find the root cause and fix it. Read the referenced code to confirm the problem before changing it.

<issue at="apps/dashboard/src/lib/orpc/routers/analytics.ts:18-500" severity="MEDIUM">trackAccount always ingests verified=false into analytics, ignoring resolved verification status — In `trackAccount`, after resolving the Twitter account, the DB insert correctly stores `verified: resolved.verified` and `verifiedType: resolved.verifiedType`. But the immediately-following `syncTrackedAccountNow` call builds a `SyncableSocialAccount` literal with `verified: false` hardcoded (line ~498, the object passed to `syncTrackedAccountNow`), even though `resolved.verified` is in scope. `buildAccountRow` (rows.ts) then writes that `verified: false` into the `social_accounts` Tinybird datasource row. So every freshly-tracked account's first analytics ingest records `verified: false` regardless of the account's actual X verification status, and the leaderboard/overview `verified` field derived from those rows will be wrong until a later sync corrects it. This is a data-correctness bug, not a security issue. Fix: Pass `verified: resolved.verified` (and `verifiedType`) into the `SyncableSocialAccount` object passed to `syncTrackedAccountNow`, matching the values written to the DB.</issue>

Commit 143412a.

@vercel
vercel Bot temporarily deployed to Preview – notra-onboarding-agent August 3, 2026 12:29 Inactive
@vercel
vercel Bot temporarily deployed to Preview – notra-agent August 3, 2026 12:29 Inactive
@vercel
vercel Bot temporarily deployed to Preview – notra-web August 3, 2026 12:29 Inactive
@vercel
vercel Bot temporarily deployed to Preview – notra-console August 3, 2026 12:29 Inactive
Remove the track button and dialog, drop the rank-change column, and pin the window select to the standard module header height so row lines align across modules.
@mezotv
mezotv force-pushed the feat/analytics-leaderboard branch from 0a9d6ba to 92389da Compare August 3, 2026 12:29
@vercel
vercel Bot temporarily deployed to Preview – notra-console August 3, 2026 12:29 Inactive
@vercel
vercel Bot temporarily deployed to Preview – notra-web August 3, 2026 12:29 Inactive
@vercel
vercel Bot temporarily deployed to Preview – notra-agent August 3, 2026 12:29 Inactive
@vercel
vercel Bot temporarily deployed to Preview – notra-onboarding-agent August 3, 2026 12:29 Inactive

@comp-ai-code-review comp-ai-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No blocking issues found across the changed files.

1 other commit review still in progress for this PR — findings may follow.

Commit 0a9d6ba · Posted by Comp AI Code Reviews.

@comp-ai-code-review comp-ai-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

2 issues found across 1 file.

Prompt for AI agents (all issues)
Check whether each issue below is valid; if so, find the root cause and fix it. Read the referenced code to confirm the problem before changing it, and use sub-agents to handle independent issues in parallel.

<file name="apps/dashboard/src/lib/orpc/routers/analytics.ts">
<issue n="1" at="apps/dashboard/src/lib/orpc/routers/analytics.ts:232-291" severity="MEDIUM">Missing rate limiting on trackAccount and previewTrackAccount handlers — The `trackAccount` and `previewTrackAccount` handlers call the Twitter API (`resolveTwitterAccount`) without any rate limiting. More significantly, `trackAccount` calls `syncTrackedAccountNow` which invokes `collectTwitterRows` — this makes a batch Twitter user lookup API call AND then for each account calls `fetchUserTweets` which paginates up to `TWITTER_TIMELINE_MAX_PAGES` (5) pages of `TWITTER_TIMELINE_MAX_RESULTS` (100) tweets each, followed by multiple Tinybird ingest calls (`ingestSocialAccounts`, `ingestSocialAccountStats`, `ingestSocialPosts`, `ingestSocialPostStats`). This is a significantly expensive operation. Other routers in the same codebase consistently rate-limit similar expensive API operations: `brand.ts` rate-limits `importTweets` (20/1m) and `fetchTweet` (30/1m), `github.ts` rate-limits `githubProbe` (30/1m), `integrations.ts` rate-limits `mcpConnection` (10/1m), and `onboarding.ts` rate-limits `onboardingAgent` (2/10m). The analytics router has no rate limiting despite calling the same Twitter API. An authenticated organization member could repeatedly call `trackAccount` with different usernames to exhaust the shared Twitter Bearer Token API quota (which is shared across the entire deployment) and Tinybird ingest quota, causing denial of service for other organizations' analytics features. The `previewTrackAccount` handler is lighter (single Twitter API call) but still uncapped. The `assertOrganizationAccess` check only verifies membership — it does not limit call frequency. Fix: Add rate limiting using the existing `ratelimit` utility, keyed by `input.organizationId` (and optionally the user ID), similar to how `brand.ts` rate-limits `importTweets` and `fetchTweet`. For example: `const { success } = await ratelimit.fetchTweet.limit(input.organizationId); if (!success) throw tooManyRequests(...)`. Consider a stricter limit for `trackAccount` since it triggers a full sync (multiple Twitter API calls + Tinybird ingests) than for `previewTrackAccount` (single lookup).</issue>
<issue n="2" at="apps/dashboard/src/lib/orpc/routers/analytics.ts:273-287" severity="MEDIUM">Race condition in trackAccount between existence check and insert — In the `trackAccount` handler, after checking `existingTracked` (findFirst on trackedSocialAccounts) and finding no match, the handler proceeds to `db.insert(trackedSocialAccounts).values(...)`. If two concurrent requests for the same Twitter account arrive, both could pass the `existingTracked` check before either inserts, leading to a unique constraint violation on the `trackedSocialAccounts_org_provider_account_uidx` index. The error would propagate as an unhandled 500 error to the client rather than the expected idempotent `{ trackedAccountId, username }` response. This is not a security issue (the unique index prevents duplicate data), but it is a UX/correctness bug. The handler does not catch the unique constraint error to retry the lookup or return the existing record. Fix: Wrap the insert in a try/catch that handles the unique constraint violation (e.g., `onConflictDoNothing` with a subsequent findFirst, or catch the error and re-query for the existing record). Drizzle ORM supports `.onConflictDoNothing()` which could be used here.</issue>
</file>

Commit 92389da · Posted by Comp AI Code Reviews.

@mezotv

mezotv commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

Superseded by the split: #656 (analytics) and #657 (GEO, stacked on #656).

@mezotv mezotv closed this Aug 5, 2026
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