Skip to content

fix(admin): move search, filters, counts and paging into SQL across admin pages - #331

Merged
fennsaji merged 3 commits into
mainfrom
dev
Jul 30, 2026
Merged

fix(admin): move search, filters, counts and paging into SQL across admin pages#331
fennsaji merged 3 commits into
mainfrom
dev

Conversation

@fennsaji

@fennsaji fennsaji commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Problem

One pattern repeated across the admin panel: fetch a capped slice of rows, then filter and count it in the browser. Search only ever matched the loaded page, and stat cards reported the size of that page rather than the database.

On top of that, several queries had no explicit .range(), so PostgREST silently truncated them at 1000 rows — totals quietly plateaued with no error anywhere.

Changes

Search pushed into SQL

  • token-management — the API already accepted search/plan; the page never sent them and filtered 100 rows client-side. Also removes an N+1 (one token_usage_history query per row → one per page).
  • topics/study-guides — the page never sent any of its four filters. Search now spans input value, topic title and creator name.
  • blogs — paged server-side but searched only the current page. Now searches title/excerpt/slug/tags in SQL.
  • subscriptions — tier/status filtered a single 50-row page. subscriptions has a UNIQUE INDEX ON (user_id), so paging can drive off that table without duplicating users.

Stats counted by Postgres, not by the page

Replaced with count: 'exact' queries / paged aggregates on: subscriptions (new /api/admin/subscription-stats), study guides, feedback, purchase issues, security events, admin logs, memory verses, daily verses, gamification (achievements, user-achievements, streaks), promo codes and subscription-config.

Silent 1000-row truncation

  • LLM costs / P&L — the biggest one. usage_logs was read unpaginated, so every cost, token and profit figure capped at 1000 log rows.
  • Also fixed in study-guide usage counts, learning-path enrollment/topic counts, topics-with-guides and active-subscription counts.

Dashboard LLM cost was always $0

The dashboard card read llm_api_costs.total_cost. That table has no total_cost column (it is cost_usd), and nothing in the codebase ever inserts into it — the table is created by a migration and never written. The query errored, the error was discarded by a data-only destructure, and the card silently showed $0 forever.

Now sums usage_logs.llm_cost_usd via the get_usage_stats RPC — the same source the LLM Costs page uses, so the two agree — and logs the error instead of swallowing it.

Separate bugs found along the way

  • admin-study-guides selected user_profiles.full_name, email — neither column exists, so creator name was always null. Now built from first_name/last_name plus the auth email.
  • The topics page "Verse" filter sent verse, but the DB constraint is scripturethe filter matched nothing.
  • Promo-code pagination total ignored the active status filter.
  • search-users truncated email matches at 100 silently; now 500, and warns when it truncates.

Pagination controls added wherever a list became server-paged. CSV exports on subscriptions and token-management now fetch every matching row instead of the visible page.

Notes

  • Study guides' "Total Saves" is a global count across all guides, not narrowed by the active filters — counting saves for a filtered guide set would need the full filtered id list. The card is labelled accordingly rather than left misleading.
  • This branch also carries two earlier commits not yet in main: the 1.0.2 store release notes and the learning-path resequence scoping fix.

Deploy

Five Edge Functions changed and need deploying before topics, learning-paths, promo-codes and llm-costs pick up the fixes:

admin-study-guides, admin-learning-paths, admin-list-promo-codes, admin-usage-analytics, admin-pl-analytics

No migrations.

Verification

  • tsc --noEmit — clean
  • npm run build — compiled successfully
  • npm run lint — 0 errors (206 pre-existing warnings)
  • deno check — OK on all five touched Edge Functions

Summary by CodeRabbit

  • New Features

    • Added server-side search, filtering, and pagination across blogs, content, gamification, issues, security, subscriptions, topics, memory verses, and token management.
    • Added comprehensive pagination controls and smoother page transitions throughout admin tables.
    • Added full-result CSV exports for matching subscriptions and token balances.
    • Added database-backed aggregate statistics for dashboards and reporting.
    • Added subscription statistics reporting.
    • Updated release notes to version 1.0.2 with learning path, memory verse, sharing, and checkout improvements.
  • Bug Fixes

    • Improved handling of feedback and token balance responses.
    • Corrected statistics and result counts to reflect complete filtered datasets.

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Admin dashboards and APIs were migrated toward server-side pagination, filtering, aggregate statistics, and complete Supabase data retrieval. Subscription and token exports now fetch matching records across pages, blog search is server-backed, and localized release notes were updated to version 1.0.2.

Changes

Admin data modernization

Layer / File(s) Summary
Contracts and client wiring
admin-web/types/admin.ts, admin-web/lib/api/admin.ts, admin-web/app/.../blogs/*, admin-web/app/api/admin/blogs/*, admin-web/app/api/admin/subscription-stats/*
Admin types and client APIs now support server-backed blog search, subscription statistics, and aggregate response fields.
Dashboard pagination and filter state
admin-web/app/(dashboard)/content-management/*, gamification/*, issues/*, memory-verses/*, security/*
Dashboard tabs now request paged results, reset pagination when filters change, retain previous query data, and render server-provided totals and statistics.
Search, export, and table integration
admin-web/app/(dashboard)/subscriptions/*, token-management/*, topics/*, promo-codes/*
Subscription, token, topic, and promo-code pages now use server-side results, aggregate statistics, pagination controls, and bounded export workflows.
Admin API pagination and statistics
admin-web/app/api/admin/*
Admin routes now apply bounded SQL pagination and consistent filters while returning totals, aggregate statistics, and structured empty responses.
Complete dataset retrieval
backend/supabase/functions/*
Edge functions now page through large Supabase datasets and use complete result sets for analytics, usage, campaign, guide, and enrollment calculations.
Release note updates
distribution/whatsnew/*
Localized release notes were updated from version 1.0.1 to version 1.0.2. 

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

  • fennsaji/disciplefy#180: Modifies the daily-verse content-management query and filter wiring in the same dashboard area.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 68.29% 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
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: moving admin search, filtering, counts, and pagination into SQL across admin pages.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch dev

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.

@coderabbitai coderabbitai 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.

Actionable comments posted: 17

Note

Due to the large number of review comments, Critical, Major 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 (3)
admin-web/app/api/admin/admin-logs/route.ts (1)

122-153: 🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

unique_admins streams the whole filtered log set on every request.

With range=all these two fetchAllRows calls can pull up to 100k rows (100 round trips each) purely to compute a distinct count, and their error fields are ignored so a partial page silently under-counts. Prefer a Postgres-side distinct count (RPC / view) or drop this stat to a cached value.

🤖 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 `@admin-web/app/api/admin/admin-logs/route.ts` around lines 122 - 153, Replace
the `logAdminIds` and `actionAdminIds` `fetchAllRows` calls used by
`stats.unique_admins` with a Postgres-side distinct-count query or established
cached value. Ensure the resulting count respects `applyLogFilters` and
`applyActionFilters`, and handle any query error explicitly so failures cannot
produce a silently under-counted statistic.
admin-web/app/api/admin/purchase-issues/route.ts (1)

53-89: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

countRes / status-count errors are never inspected.

A failed count resolves with count: null, so stats.total and every by_status entry become 0 and the client renders a healthy-looking empty breakdown. Same ignored-error pattern appears in the other routes touched by this PR; see the consolidated note.

🤖 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 `@admin-web/app/api/admin/purchase-issues/route.ts` around lines 53 - 89, The
Promise.all result in the purchase-issues route must inspect errors from
countRes and each status query before constructing stats. Update the
count/status query handling around countRes and statusRows to propagate a failed
count request as an error response instead of converting null counts to zero,
while preserving the existing successful totals and by-status response.
backend/supabase/functions/admin-pl-analytics/index.ts (1)

115-151: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Offset paging is ordered by non-unique columns in several of the new reads. Postgres gives no stable total order for tied rows across separate range requests, so rows can be returned twice or skipped between pages — corrupting exactly the totals these paged reads were introduced to make accurate. Each site needs a unique tiebreaker appended to the existing order.

  • backend/supabase/functions/admin-pl-analytics/index.ts#L115-L151: add .order('id', { ascending: true }) after the user_id order on both the subscriptions and subscription_invoices reads.
  • backend/supabase/functions/admin-learning-paths/index.ts#L241-L255: add a unique tiebreaker after .order('learning_path_id') on the learning_path_topics and user_learning_path_progress reads.
  • backend/supabase/functions/admin-study-guides/index.ts#L226-L233: add a unique tiebreaker after .order('study_guide_id') on the user_study_guides read.
🤖 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 `@backend/supabase/functions/admin-pl-analytics/index.ts` around lines 115 -
151, Offset-paged reads need stable total ordering to avoid skipped or
duplicated rows. In backend/supabase/functions/admin-pl-analytics/index.ts lines
115-151, update both subscriptions and subscription_invoices queries by
appending ascending id ordering after user_id; in
backend/supabase/functions/admin-learning-paths/index.ts lines 241-255, append a
unique tiebreaker after learning_path_id for both learning_path_topics and
user_learning_path_progress reads; and in
backend/supabase/functions/admin-study-guides/index.ts lines 226-233, append a
unique tiebreaker after learning_path_id in the user_study_guides read.
🟡 Minor comments (9)
admin-web/app/api/admin/content/daily-verses/route.ts-54-54 (1)

54-54: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

todayKey is UTC while the table classifies dates locally.

admin-web/components/tables/daily-verses-table.tsx deliberately builds today's key from local components ("new Date(dateKey) parses as UTC"), but upcoming_count/past_count here use toISOString(). For IST admins the two disagree for part of each day: a row badged “Current” can be counted as “Past”. Derive the key from local date parts (or make both sides UTC).

🤖 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 `@admin-web/app/api/admin/content/daily-verses/route.ts` at line 54, Update the
date-key construction in the daily-verses route around todayKey to use local
calendar date components, matching daily-verses-table.tsx, instead of new
Date().toISOString(). Keep upcoming_count and past_count comparisons aligned
with the table’s local-date classification.
admin-web/app/(dashboard)/gamification/page.tsx-346-353 (1)

346-353: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

isBusy is wired to isLoading, so the buttons never disable while paging.

With placeholderData: keepPreviousData, isLoading stays false for subsequent pages; the sibling pages (issues, security) correctly use isFetching. Destructure isFetching from both queries and pass it here.

🐛 Proposed fix
-  const { data: userAchievementsData, isLoading: userAchievementsLoading } = useQuery({
+  const {
+    data: userAchievementsData,
+    isLoading: userAchievementsLoading,
+    isFetching: userAchievementsFetching,
+  } = useQuery({
               <TablePagination
                 page={userAchievementsPage}
                 total={userAchievementsData?.total ?? 0}
-                isBusy={userAchievementsLoading}
+                isBusy={userAchievementsFetching}
                 onChange={setUserAchievementsPage}
                 noun="matching unlocks"
               />

(apply the same change for streaksData/streaksPage)

Also applies to: 552-559

🤖 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 `@admin-web/app/`(dashboard)/gamification/page.tsx around lines 346 - 353,
Update both gamification query usages to destructure `isFetching` alongside
their existing loading state, then pass `isFetching` to the `TablePagination`
components for `userAchievements` and `streaks` instead of `isLoading`, so
pagination controls disable during subsequent fetches while preserving the
existing initial-load handling.
admin-web/app/api/admin/content/daily-verses/route.ts-61-72 (1)

61-72: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

active count contradicts the is_active filter.

applyFilters already applies is_active from the query string, then .eq('is_active', true) is chained on top. With is_active=false selected the two predicates conflict and “Active Verses” always renders 0. Build that count without the user filter (or drop the redundant .eq) so the card stays meaningful.

🐛 Proposed fix
-      applyFilters(
-        supabaseAdmin.from('daily_verses_cache').select('id', { count: 'exact', head: true })
-      ).eq('is_active', true),
+      // Active count ignores the is_active filter so the card never self-contradicts
+      supabaseAdmin
+        .from('daily_verses_cache')
+        .select('id', { count: 'exact', head: true })
+        .eq('is_active', true),

(add language back if the language filter should still apply)

🤖 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 `@admin-web/app/api/admin/content/daily-verses/route.ts` around lines 61 - 72,
Update the active-verses count query in the daily-verses count construction so
it does not apply the query-string is_active filter before enforcing
is_active=true. Preserve other applicable filters, such as language, and keep
the “Active Verses” card counting active records regardless of the selected
active-status filter.
admin-web/app/api/admin/admin-logs/route.ts-94-96 (1)

94-96: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Comment contradicts the today range filter.

utcDayStart uses setUTCHours, while the today case at Lines 58-59 uses local-time setHours. The two boundaries only coincide when the server runs in UTC, so either align both or fix the comment.

🤖 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 `@admin-web/app/api/admin/admin-logs/route.ts` around lines 94 - 96, Align the
utcDayStart calculation with the today range case by using the same local-time
boundary behavior as setHours, or update the today filter to use UTC
consistently. Ensure both paths produce the same start-of-day boundary and
revise the comment to accurately describe the chosen behavior.
admin-web/app/(dashboard)/subscriptions/page.tsx-29-51 (1)

29-51: 🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

Missing required React Query defaults.

Neither query sets staleTime: 60_000 nor refetchOnWindowFocus: false, which the project mandates for server state in dashboard pages.

As per coding guidelines: "Use React Query 5 (TanStack) with staleTime: 1 min and refetchOnWindowFocus: false for server state management".

♻️ Add the mandated query options
     placeholderData: keepPreviousData,
+    staleTime: 60_000,
+    refetchOnWindowFocus: false,
   })
 
   // Tier counts come from COUNT queries over the whole subscriptions table
   const { data: dbStats } = useQuery({
     queryKey: ['subscription-stats'],
     queryFn: getSubscriptionStats,
+    staleTime: 60_000,
+    refetchOnWindowFocus: false,
   })
🤖 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 `@admin-web/app/`(dashboard)/subscriptions/page.tsx around lines 29 - 51, Add
staleTime: 60_000 and refetchOnWindowFocus: false to both useQuery
configurations for searchResults and dbStats, preserving their existing query
keys, query functions, and placeholderData behavior.

Source: Coding guidelines

admin-web/app/api/admin/gamification/achievements/route.ts-101-114 (1)

101-114: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

This catalog read is exactly the 1000-row cap the rest of the file guards against.

select('category') has no .range(), so total_all and by_category silently plateau at PostgREST's default cap once the catalog grows — while Line 66 uses fetchAllRows for the same reason. The query's error is also dropped, so a failure renders total_all: 0.

🛠️ Page the read like the sibling query
-    const { data: allCategories } = await supabaseAdmin
-      .from('achievements')
-      .select('category')
+    const { data: allCategories, error: allCategoriesError } = await fetchAllRows<{ category: string }>(
+      (from, to) =>
+        supabaseAdmin
+          .from('achievements')
+          .select('category')
+          .order('id', { ascending: true })
+          .range(from, to)
+    )
+    if (allCategoriesError) {
+      console.error('Failed to fetch achievement categories:', allCategoriesError)
+    }
🤖 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 `@admin-web/app/api/admin/gamification/achievements/route.ts` around lines 101
- 114, Update the all-achievements catalog query used to build byCategory and
total_all to use the existing fetchAllRows pagination helper, matching the
sibling query near line 66. Preserve the category selection and counting
behavior, and handle the helper’s returned error so a failed catalog read is not
silently reported as an empty catalog.
admin-web/app/(dashboard)/token-management/page.tsx-110-138 (1)

110-138: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Export silently truncates above MAX_EXPORT_ROWS.

When matchingTotal > 5000 the loop stops at 5000 rows, yet the button reads Export CSV (${matchingTotal}) and the download completes with no indication that rows are missing — an admin can easily treat the file as complete.

🛠️ Warn on truncation
     setIsExporting(false)
+    if (matchingTotal > exportRows.length) {
+      toast.warning(`Exported the first ${exportRows.length} of ${matchingTotal} matching rows.`)
+    }
🤖 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 `@admin-web/app/`(dashboard)/token-management/page.tsx around lines 110 - 138,
Update handleExportCSV and fetchAllMatching so exports that reach
MAX_EXPORT_ROWS while matchingTotal exceeds that limit explicitly warn the admin
that the CSV is truncated; preserve the existing successful download flow for
complete exports and the current error handling for failed requests.
admin-web/app/api/admin/security-events/route.ts-77-82 (1)

77-82: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Guard min_risk_score parsing.

A non-numeric min_risk_score yields parseFloat(...) === NaN, which is serialized into the filter and makes every one of the five queries fail. Validate before applying.

🛠️ Suggested fix
-      if (minRiskScore) q = q.gte('risk_score', parseFloat(minRiskScore))
+      const parsedRisk = parseFloat(minRiskScore)
+      if (minRiskScore && Number.isFinite(parsedRisk)) q = q.gte('risk_score', parsedRisk)
🤖 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 `@admin-web/app/api/admin/security-events/route.ts` around lines 77 - 82,
Validate the parsed minRiskScore value in applyFilters before adding the
risk_score filter. Only call q.gte when minRiskScore parses to a finite number;
otherwise skip that filter so invalid input does not propagate NaN to the
queries.
admin-web/app/(dashboard)/memory-verses/page.tsx-145-147 (1)

145-147: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Page can land past the last page after deletes.

handleDelete refetches without touching page. Deleting the last row of the final page leaves page > totalPages - 1, and the server returns an empty window until the user clicks Previous. Clamping after the response avoids the dead view.

🛠️ Suggested clamp
   const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
+  useEffect(() => {
+    if (page > 0 && page >= totalPages) setPage(totalPages - 1)
+  }, [page, totalPages])
🤖 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 `@admin-web/app/`(dashboard)/memory-verses/page.tsx around lines 145 - 147,
Update the pagination flow around handleDelete and totalPages so that after
refetching data, page is clamped to the highest valid page index when deletions
reduce the result set. Preserve page zero as the minimum and ensure the view
immediately requests or displays the adjusted final page instead of retaining an
out-of-range page.
🧹 Nitpick comments (16)
admin-web/app/api/admin/gamification/streaks/route.ts (1)

144-151: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Email lookup spans every user in the union, not the rows being returned.

allUserIds covers all study, verse and XP users, so getAuthEmailMap pages the auth admin API for the whole user base per request. Only the current page plus the four leaderboards need emails; restrict the id set to those to cut a large share of the request cost.

🤖 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 `@admin-web/app/api/admin/gamification/streaks/route.ts` around lines 144 -
151, The email lookup currently includes every user from all streak and XP data
instead of only users needed by the response. Update the ID collection before
getAuthEmailMap to include IDs from the current page rows and the four
leaderboard result sets, while preserving deduplication and excluding unrelated
users from studyUserIds, verseUserIds, and xpUserIds.
admin-web/app/(dashboard)/blogs/page.tsx (1)

79-85: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

Debounce + reset wiring looks correct, but consider migrating this page to React Query.

Other dashboards in this PR (issues, security, gamification, content-management) use useQuery with keepPreviousData; this page keeps hand-rolled useState/useEffect fetching, which loses request cancellation and dedupe between loadPosts and the three loadStats calls. As per coding guidelines, admin-web/app/**/*.tsx should "Use React Query 5 (TanStack) with staleTime: 1 min and refetchOnWindowFocus: false for server state management".

🤖 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 `@admin-web/app/`(dashboard)/blogs/page.tsx around lines 79 - 85, Migrate the
blogs page server-state fetching from the hand-rolled useState/useEffect flow to
TanStack React Query 5, including the post query and three statistics queries
currently driven by loadPosts and loadStats. Configure each query with staleTime
of one minute and refetchOnWindowFocus false, preserve the existing filters,
pagination, debounced search, and page-reset behavior, and use keepPreviousData
for paginated results.

Source: Coding guidelines

admin-web/app/api/admin/topics-with-guides/route.ts (1)

92-99: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Group guides once instead of re-filtering per topic.

guidesData.filter(...) inside the per-topic map is O(topics × guides) over a now-unbounded guide set, and each iteration also re-reduces and sorts. A single pass into Map<topic_id, guides[]> (or tracking count/max-created-at during that pass) keeps this linear.

♻️ Suggested refactor
+    const guidesByTopic = new Map<string, any[]>()
+    for (const g of guidesData) {
+      const list = guidesByTopic.get(g.topic_id)
+      if (list) list.push(g)
+      else guidesByTopic.set(g.topic_id, [g])
+    }
+
     const topicsWithGuides = topics?.map((topic: any) => {
-      const topicGuides = guidesData.filter(g => g.topic_id === topic.id)
+      const topicGuides = guidesByTopic.get(topic.id) ?? []
🤖 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 `@admin-web/app/api/admin/topics-with-guides/route.ts` around lines 92 - 99,
Refactor the topicsWithGuides processing so guidesData is grouped once by
topic_id before the per-topic map, using a Map or equivalent single-pass
accumulator. Replace the per-topic guidesData.filter call and retain the
existing mode-counting and sorting behavior while ensuring total processing is
linear in the number of topics and guides.
admin-web/app/api/admin/content/daily-verses/route.ts (1)

73-77: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

by_language pulls every matching row just to count 3 languages.

fetchAllRows here scans the full filtered table (up to MAX_PAGES × PAGE_SIZE rows) per request and counts in Node. Three count: 'exact', head: true queries keyed by language — or a small group by RPC — give the same numbers at constant cost.

Also applies to: 98-101

🤖 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 `@admin-web/app/api/admin/content/daily-verses/route.ts` around lines 73 - 77,
Update the by_language aggregation to avoid using fetchAllRows over the full
filtered daily_verses_cache table. Replace it with constant-cost exact head
count queries for each language, or reuse a small grouped-count RPC, while
preserving the existing filters and returned language counts; apply the same
change to the second matching query.
admin-web/app/api/admin/content/suggested-verses/route.ts (1)

96-136: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

These stats can be counts instead of two full-table reads.

total_all is just count: 'exact', head: true on suggested_verses, and since suggested_verse_translations is unique per (suggested_verse_id, language_code) (see the onConflict in PATCH), each language's coverage is a count with .eq('language_code', lang). That replaces up to 100k rows of transfer per request with 4 cheap counts. Also note both fetchAllRows results' error fields are ignored, so a mid-paging failure silently under-reports.

♻️ Sketch using count queries
-    const [allCategories, allTranslations] = await Promise.all([
-      fetchAllRows<{ category: string }>((from, to) =>
-        supabaseAdmin
-          .from('suggested_verses')
-          .select('category')
-          .order('id', { ascending: true })
-          .range(from, to)
-      ),
-      fetchAllRows<{ suggested_verse_id: string; language_code: string }>((from, to) =>
-        supabaseAdmin
-          .from('suggested_verse_translations')
-          .select('suggested_verse_id, language_code')
-          .order('suggested_verse_id', { ascending: true })
-          .range(from, to)
-      ),
-    ])
+    const LANGUAGES = ['en', 'hi', 'ml'] as const
+    const [totalAllRes, allCategories, ...coverageRes] = await Promise.all([
+      supabaseAdmin.from('suggested_verses').select('id', { count: 'exact', head: true }),
+      fetchAllRows<{ category: string }>((from, to) =>
+        supabaseAdmin
+          .from('suggested_verses')
+          .select('category')
+          .order('id', { ascending: true })
+          .range(from, to)
+      ),
+      ...LANGUAGES.map(lang =>
+        supabaseAdmin
+          .from('suggested_verse_translations')
+          .select('id', { count: 'exact', head: true })
+          .eq('language_code', lang)
+      ),
+    ])
🤖 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 `@admin-web/app/api/admin/content/suggested-verses/route.ts` around lines 96 -
136, Replace the full-table fetches and local aggregation around allCategories,
allTranslations, byCategory, and coverageSets with count queries: use an exact
head-only count on suggested_verses for total_all, and exact counts on
suggested_verse_translations filtered by each supported language_code for
translation_coverage. Remove the unused fetchAllRows results and ensure all
count-query errors are handled consistently with the surrounding route instead
of silently under-reporting.
admin-web/app/(dashboard)/subscriptions/page.tsx (1)

72-101: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consolidate the duplicate CSV export helpers.

subscriptions/page.tsx and token-management/page.tsx both define the same export constants and nearly identical fetchAllMatching + handleExportCSV flows, with only request parsing/row mapping differing. Extract one reusable helper/export CSV builder so export limits/behavior is defined in a single place.

🤖 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 `@admin-web/app/`(dashboard)/subscriptions/page.tsx around lines 72 - 101,
Consolidate the duplicated export constants and pagination/export flow shared by
fetchAllMatching and handleExportCSV in the subscriptions and token-management
pages into one reusable helper or CSV builder. Parameterize the differing
request parsing and row-mapping behavior, then update both pages to use it while
preserving their current export limits, error handling, and output.

Source: Coding guidelines

admin-web/app/api/admin/system/subscription-config/route.ts (1)

67-78: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Per-plan count errors are swallowed and reported as 0.

Only count is destructured, so a failed count renders as "0 active users" for that plan with no signal. Capture and log error (or fail the request) so an undercount isn't mistaken for real data.

🤖 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 `@admin-web/app/api/admin/system/subscription-config/route.ts` around lines 67
- 78, Update the per-plan query inside the planIds mapping to capture Supabase’s
error alongside count, then log the error with planId or fail the request
instead of returning 0 when the count query fails. Preserve the existing zero
fallback only for successful queries with no rows, and keep planCounts
construction unchanged for valid results.
admin-web/app/api/admin/search-users/route.ts (1)

78-94: 🚀 Performance & Scalability | 🔵 Trivial | 🏗️ Heavy lift

Every search enumerates the entire auth user list.

listAllAuthUsers pages through auth.admin.listUsers() (50 per request) on each keystroke-driven search, and again for the email map when query is empty. For a large user base this dominates response time. Consider caching the email map briefly, or matching emails via a database-side source instead of the admin API.

Also applies to: 215-219

🤖 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 `@admin-web/app/api/admin/search-users/route.ts` around lines 78 - 94, Update
the search flow around listAllAuthUsers and the empty-query email mapping to
avoid enumerating auth users on every request. Prefer a short-lived shared cache
for the auth user email-to-ID map, reuse cached data for both paths, and refresh
or invalidate it according to the existing data consistency needs while
preserving MAX_INLINE_IDS truncation.
admin-web/app/(dashboard)/token-management/page.tsx (1)

62-78: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add the required React Query defaults to this query.

As per coding guidelines, "Use React Query 5 (TanStack) with staleTime: 1 min and refetchOnWindowFocus: false for server state management".

♻️ Suggested change
     placeholderData: keepPreviousData,
+    staleTime: 60_000,
+    refetchOnWindowFocus: false,
   })
🤖 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 `@admin-web/app/`(dashboard)/token-management/page.tsx around lines 62 - 78,
Update the useQuery configuration for the token-balance query to include React
Query 5 defaults of staleTime set to one minute and refetchOnWindowFocus
disabled, while preserving the existing queryKey, queryFn, and placeholderData
behavior.

Source: Coding guidelines

admin-web/app/(dashboard)/memory-verses/page.tsx (1)

63-93: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider moving this page's paged fetch to a typed fetcher + React Query.

The other dashboards in this PR (token-management, topics) now use useQuery with keepPreviousData; this page keeps a hand-rolled useEffect/fetch loop against /api/admin/content/suggested-verses, so it loses request de-duplication and gets a full loading flash on every page change. As per coding guidelines, "Admin pages must call typed fetchers in lib/api/admin.ts" and "Use React Query 5 (TanStack) with staleTime: 1 min and refetchOnWindowFocus: false".

🤖 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 `@admin-web/app/`(dashboard)/memory-verses/page.tsx around lines 63 - 93,
Replace the hand-rolled fetchVerses/useEffect flow with a React Query 5 useQuery
using the typed admin fetcher from lib/api/admin.ts for
/api/admin/content/suggested-verses. Include page and categoryFilter in the
query key, configure keepPreviousData, staleTime of one minute, and
refetchOnWindowFocus false, then derive verses, stats, total, loading, and error
from the query while preserving pagination and filtering behavior.

Source: Coding guidelines

admin-web/app/(dashboard)/topics/page.tsx (1)

43-66: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

React Query defaults missing here too — see the consolidated note.

🤖 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 `@admin-web/app/`(dashboard)/topics/page.tsx around lines 43 - 66, Update the
useQuery configuration for the study-guides request to include the project’s
standard React Query defaults, reusing the established shared values or
configuration rather than defining new ones. Keep the existing queryKey,
queryFn, and keepPreviousData behavior unchanged.

Source: Coding guidelines

backend/supabase/functions/admin-learning-paths/index.ts (1)

238-255: 🚀 Performance & Scalability | 🔵 Trivial | 🏗️ Heavy lift

Full-table reads to compute per-path counts.

Correctness is fixed, but every list request now streams all of learning_path_topics and user_learning_path_progress into the function just to increment counters. A grouped aggregate (a Postgres view or RPC returning learning_path_id, count(*)) keeps the numbers accurate without O(enrollments) transfer, and also removes the 100k cap concern entirely.

🤖 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 `@backend/supabase/functions/admin-learning-paths/index.ts` around lines 238 -
255, Replace the full-table fetchAllRows calls for topicCounts and enrollments
with grouped database-side aggregates, using a view or RPC that returns each
learning_path_id with its count. Update the per-path counting logic to consume
these aggregated rows while preserving accurate Topics and Total Enrolled
statistics without transferring individual topic or enrollment records.
backend/supabase/functions/admin-study-guides/index.ts (2)

281-294: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Per-creator auth.admin.getUserById fan-out on every list request.

With limit up to 200 this can fire up to 200 unbounded, un-timed admin API calls in parallel for pages whose creators lack names — slow and a good way to hit auth rate limits. auth.admin.listUsers (paged) or a single auth.users lookup would collapse this to one or two calls; failures are already swallowed, so the UI just shows null names either way.

🤖 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 `@backend/supabase/functions/admin-study-guides/index.ts` around lines 281 -
294, Replace the per-creator getUserById fan-out in the missing-creators
fallback with a bounded bulk lookup using auth.admin.listUsers pagination or a
single auth.users query. Populate creatorsMap for matching creator IDs while
preserving the existing missing-name fallback when the lookup fails or finds no
email.

154-319: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

handleList is ~165 lines and owns six responsibilities.

Search-id resolution, filter composition, paging, usage counts, topic enrichment and creator enrichment in one function is well past the 20-line limit and hard to test. Extracting resolveSearchIds, loadUsageCounts, loadTopicTitles and loadCreatorNames would also let each be unit-tested independently.

As per coding guidelines, "Enforce maximum 20 lines per function for JavaScript/TypeScript code".

🤖 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 `@backend/supabase/functions/admin-study-guides/index.ts` around lines 154 -
319, The handleList function exceeds the 20-line limit and combines search
resolution, filtering, pagination, usage counting, topic enrichment, and creator
enrichment. Extract these responsibilities into focused helpers, including
resolveSearchIds, loadUsageCounts, loadTopicTitles, and loadCreatorNames, then
keep handleList as a short orchestration function that preserves the existing
query, pagination, enrichment, stats, and response behavior.

Source: Coding guidelines

backend/supabase/functions/admin-usage-analytics/index.ts (1)

239-251: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Failed paged reads degrade to zeros with no signal to the client.

Each catch leaves the array empty and the response then reports 0 operations/0 cost, which an admin cannot distinguish from a genuinely quiet period — and a mid-pagination failure yields partial rows silently discarded entirely. Include a per-section partial/error marker in the payload so the UI can badge the card instead of showing confident zeros. Ordering by the unique id here is correct for offset paging.

Also applies to: 273-285, 304-315

🤖 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 `@backend/supabase/functions/admin-usage-analytics/index.ts` around lines 239 -
251, Update the provider, model, and daily paged-read sections around
fetchAllRows to retain failure state instead of silently returning empty arrays.
Track a per-section error or partial marker, set it when any pagination request
fails (including mid-pagination failures), and include that marker in the
corresponding response payload so the UI can distinguish failed data from
genuine zero values while preserving id ordering.
backend/supabase/functions/admin-list-promo-codes/index.ts (1)

148-158: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Use the existing timestamp now consistently for promo-code status comparisons.

The response has no valid_until = NULL case because promotional_campaigns.valid_until is required, but enhancedCampaigns.is_expired compares each row with a fresh new Date(), while the list/filter queries use the parsed now from line 68. Reuse that parsed instant for is_expired and the list/filter comparisons so an in-flight response cannot classify a campaign differently on the same request.

🤖 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 `@backend/supabase/functions/admin-list-promo-codes/index.ts` around lines 148
- 158, The campaign statistics loop should reuse the request’s parsed now
timestamp instead of creating a fresh Date for isExpired. Update the nearby
list/filter comparisons to use the same now value consistently, preserving the
existing valid_until and active-status behavior.
🤖 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 `@admin-web/app/`(dashboard)/issues/page.tsx:
- Around line 14-52: Extract the shared pagination behavior into a reusable
component in admin-web/components/ui/pagination.tsx, including the shared
PAGE_SIZE and props page, total, pageSize, isFetching, onChange, and noun. In
admin-web/app/(dashboard)/issues/page.tsx lines 14-52, replace Pagination with
the shared import; in admin-web/app/(dashboard)/security/page.tsx lines 46-86,
remove the local Pagination and PAGE_SIZE and import the shared component; in
admin-web/app/(dashboard)/gamification/page.tsx lines 24-64, remove
TablePagination, use the shared component, and rename isBusy call-site props to
isFetching; in admin-web/app/(dashboard)/content-management/page.tsx lines
160-187, replace the inline footer with the shared component.

In `@admin-web/app/api/admin/blogs/route.ts`:
- Around line 63-70: Update the search sanitization in the blog route’s search
query before interpolating into query.or: escape underscores for the ilike
patterns and neutralize quotes, backslashes, and array braces/escape sequences
so values cannot alter PostgREST raw filters, while preserving legitimate search
behavior. Apply the same safe value consistently across title, excerpt, slug,
and tags.cs clauses.

In `@admin-web/app/api/admin/feedback/route.ts`:
- Around line 81-102: Update the helpfulRes and notHelpfulRes queries in the
stats calculation to use only the non-helpful filters, avoiding the was_helpful
condition applied by applyFilters when the helpful filter is active. Preserve
totalRes and sentimentRows filtering, and keep each breakdown query’s explicit
helpful or not-helpful condition so the stats represent the full filtered set
breakdown.

In `@admin-web/app/api/admin/gamification/streaks/route.ts`:
- Around line 53-105: Refactor the streaks handler’s Promise.all data-loading
flow to avoid fetching all rows from user_topic_progress, user_achievements, and
user_profiles on every request. Move aggregate calculations and per-user XP
totals into SQL views or RPCs, and query user_study_streaks with database
pagination using range(offset, offset + limit - 1) and count: 'exact'; build the
response from these paged results without the in-memory slice, preserving the
existing sorting, totals, distributions, leaderboards, and profile lookups.
- Around line 107-119: Validate the errors from all five fetchAllRows results
before deriving statistics: studyStreaksRes, verseStreaksRes, xpRowsRes,
achievementXpRes, and profilesRes. Reuse the existing 500-response error
handling and include the failing source context, ensuring any fetch
error—including MAX_PAGES exhaustion—prevents the truncated data arrays from
being used to calculate totals.

In `@admin-web/app/api/admin/gamification/user-achievements/route.ts`:
- Around line 55-68: Replace the full-table fetchAllRows aggregation used for
unlock statistics in the user-achievements route with a database-side aggregate,
preferably a grouped Postgres/RPC query returning per-achievement unlock counts
and joined with achievements metadata for category, tier, and XP. Preserve exact
total unlocks and accurate unique-user, unique-achievement, and total-XP values
while keeping the paginated list query independent of the stats calculation;
remove the in-memory scan and aggregation around fetchAllRows.

In `@admin-web/app/api/admin/search-users/route.ts`:
- Around line 15-28: Update buildSearchConditions so query cannot alter the
PostgREST or-expression syntax: validate or escape reserved characters such as
commas, periods, and parentheses before interpolating it into the ilike
conditions, while preserving valid free-text searches and UUID matching. Apply
the same protection to every query-derived condition in this function.
- Around line 99-141: Update the tier/status branch around
buildSubscriptionQuery so text search does not truncate matches at
MAX_INLINE_IDS before subscription filtering and paging; either explicitly
preserve the capped-subset behavior or page the complete matched-id set. Ensure
the count and result pagination use distinct user IDs, rather than counting
duplicate historical subscription rows, so total matches the number of users
returned.

In `@admin-web/app/api/admin/security-events/route.ts`:
- Around line 96-102: Update the unique_users calculation using fetchAllRows so
it retrieves distinct user IDs at the database query level instead of scanning
every matching security event row. Preserve the existing filters and pagination
behavior while changing the select/query in the unique_users path and its
corresponding occurrence to return only unique non-null user IDs.

In `@admin-web/app/api/admin/subscription-stats/route.ts`:
- Around line 44-63: Update the subscription-stats query handling around
Promise.all and byTier to check totalUsersRes and every tier result for errors,
returning an appropriate error response instead of converting failed counts to
zero. Also change the tier count queries to count distinct user_id values so
multiple active subscription rows for one user are counted once.

In `@admin-web/app/api/admin/user-token-balances/route.ts`:
- Around line 176-189: Update the todayUsage query in the user-token-balances
route to use the shared fetchAllRows helper from
'`@/lib/supabase/fetch-all-rows`', preserving the existing token_usage_history
filters and consumedTodayMap aggregation while ensuring all matching rows beyond
PostgREST’s 1000-row limit are included.

In `@backend/supabase/functions/admin-learning-paths/index.ts`:
- Around line 210-222: Update fetchAllRows with a generic row type, typing
buildPage as returning a Promise containing data rows and an optional error, and
returning Promise<T[]> instead of using any. Track whether pagination stopped
because a page was short; if the loop reaches MAX_PAGES without that condition,
throw or log an explicit cap-reached error before returning the rows.

In `@backend/supabase/functions/admin-list-promo-codes/index.ts`:
- Around line 134-136: The campaign statistics query in the
admin-list-promo-codes handler currently discards errors and is limited by
Supabase’s default row cap. Update the stats calculation around the
promotional_campaigns select to use SQL aggregation or the existing fetchAllRows
helper, retain and explicitly handle the query error, and ensure total and
total_redemptions include all campaigns.

In `@backend/supabase/functions/admin-study-guides/index.ts`:
- Around line 159-172: Update the search flow around topicIdMatches and
creatorIdMatches so it does not truncate matching IDs with MAX_INLINE_IDS,
preserving all matching study guides; preferably resolve the topic and creator
joins server-side through the existing study_guides query mechanism, or
otherwise add a response flag that explicitly indicates narrowed results when a
limit remains necessary.
- Around line 131-145: Extract the duplicated fetchAllRows paging logic into a
shared generic fetchAllRows<T> helper under
backend/supabase/functions/_shared/utils/fetch-all-rows.ts, replacing any types
with the appropriate generic typing while preserving pagination, error, and
truncation behavior. In backend/supabase/functions/admin-study-guides/index.ts
at lines 131-145, remove the local helper and import the shared implementation;
make the same deletion and import change in
backend/supabase/functions/admin-pl-analytics/index.ts at lines 9-27 and
backend/supabase/functions/admin-usage-analytics/index.ts at lines 16-34.
- Around line 330-352: Update buildListStats to replace the per-value countFor
calls with a single grouped aggregate/RPC that returns the input_type,
study_mode, and language breakdowns in one round trip. Ensure the aggregate and
total_usage both respect the active filters; scope usage to the matching study
guides, or rename the field and card to clearly indicate a global total.
- Around line 178-185: Sanitize the user-provided search value before
constructing PostgREST filters in the admin study-guides query, including the
earlier ilike usage near line 163 and the `.or(...)` block. Escape backslashes
and double quotes, wrap the value in PostgREST-compatible double quotes, and
preserve intended literal search behavior by handling `%` and `_` as literal
characters rather than wildcards; use the sanitized value consistently in all
generated conditions.

---

Outside diff comments:
In `@admin-web/app/api/admin/admin-logs/route.ts`:
- Around line 122-153: Replace the `logAdminIds` and `actionAdminIds`
`fetchAllRows` calls used by `stats.unique_admins` with a Postgres-side
distinct-count query or established cached value. Ensure the resulting count
respects `applyLogFilters` and `applyActionFilters`, and handle any query error
explicitly so failures cannot produce a silently under-counted statistic.

In `@admin-web/app/api/admin/purchase-issues/route.ts`:
- Around line 53-89: The Promise.all result in the purchase-issues route must
inspect errors from countRes and each status query before constructing stats.
Update the count/status query handling around countRes and statusRows to
propagate a failed count request as an error response instead of converting null
counts to zero, while preserving the existing successful totals and by-status
response.

In `@backend/supabase/functions/admin-pl-analytics/index.ts`:
- Around line 115-151: Offset-paged reads need stable total ordering to avoid
skipped or duplicated rows. In
backend/supabase/functions/admin-pl-analytics/index.ts lines 115-151, update
both subscriptions and subscription_invoices queries by appending ascending id
ordering after user_id; in
backend/supabase/functions/admin-learning-paths/index.ts lines 241-255, append a
unique tiebreaker after learning_path_id for both learning_path_topics and
user_learning_path_progress reads; and in
backend/supabase/functions/admin-study-guides/index.ts lines 226-233, append a
unique tiebreaker after learning_path_id in the user_study_guides read.

---

Minor comments:
In `@admin-web/app/`(dashboard)/gamification/page.tsx:
- Around line 346-353: Update both gamification query usages to destructure
`isFetching` alongside their existing loading state, then pass `isFetching` to
the `TablePagination` components for `userAchievements` and `streaks` instead of
`isLoading`, so pagination controls disable during subsequent fetches while
preserving the existing initial-load handling.

In `@admin-web/app/`(dashboard)/memory-verses/page.tsx:
- Around line 145-147: Update the pagination flow around handleDelete and
totalPages so that after refetching data, page is clamped to the highest valid
page index when deletions reduce the result set. Preserve page zero as the
minimum and ensure the view immediately requests or displays the adjusted final
page instead of retaining an out-of-range page.

In `@admin-web/app/`(dashboard)/subscriptions/page.tsx:
- Around line 29-51: Add staleTime: 60_000 and refetchOnWindowFocus: false to
both useQuery configurations for searchResults and dbStats, preserving their
existing query keys, query functions, and placeholderData behavior.

In `@admin-web/app/`(dashboard)/token-management/page.tsx:
- Around line 110-138: Update handleExportCSV and fetchAllMatching so exports
that reach MAX_EXPORT_ROWS while matchingTotal exceeds that limit explicitly
warn the admin that the CSV is truncated; preserve the existing successful
download flow for complete exports and the current error handling for failed
requests.

In `@admin-web/app/api/admin/admin-logs/route.ts`:
- Around line 94-96: Align the utcDayStart calculation with the today range case
by using the same local-time boundary behavior as setHours, or update the today
filter to use UTC consistently. Ensure both paths produce the same start-of-day
boundary and revise the comment to accurately describe the chosen behavior.

In `@admin-web/app/api/admin/content/daily-verses/route.ts`:
- Line 54: Update the date-key construction in the daily-verses route around
todayKey to use local calendar date components, matching daily-verses-table.tsx,
instead of new Date().toISOString(). Keep upcoming_count and past_count
comparisons aligned with the table’s local-date classification.
- Around line 61-72: Update the active-verses count query in the daily-verses
count construction so it does not apply the query-string is_active filter before
enforcing is_active=true. Preserve other applicable filters, such as language,
and keep the “Active Verses” card counting active records regardless of the
selected active-status filter.

In `@admin-web/app/api/admin/gamification/achievements/route.ts`:
- Around line 101-114: Update the all-achievements catalog query used to build
byCategory and total_all to use the existing fetchAllRows pagination helper,
matching the sibling query near line 66. Preserve the category selection and
counting behavior, and handle the helper’s returned error so a failed catalog
read is not silently reported as an empty catalog.

In `@admin-web/app/api/admin/security-events/route.ts`:
- Around line 77-82: Validate the parsed minRiskScore value in applyFilters
before adding the risk_score filter. Only call q.gte when minRiskScore parses to
a finite number; otherwise skip that filter so invalid input does not propagate
NaN to the queries.

---

Nitpick comments:
In `@admin-web/app/`(dashboard)/blogs/page.tsx:
- Around line 79-85: Migrate the blogs page server-state fetching from the
hand-rolled useState/useEffect flow to TanStack React Query 5, including the
post query and three statistics queries currently driven by loadPosts and
loadStats. Configure each query with staleTime of one minute and
refetchOnWindowFocus false, preserve the existing filters, pagination, debounced
search, and page-reset behavior, and use keepPreviousData for paginated results.

In `@admin-web/app/`(dashboard)/memory-verses/page.tsx:
- Around line 63-93: Replace the hand-rolled fetchVerses/useEffect flow with a
React Query 5 useQuery using the typed admin fetcher from lib/api/admin.ts for
/api/admin/content/suggested-verses. Include page and categoryFilter in the
query key, configure keepPreviousData, staleTime of one minute, and
refetchOnWindowFocus false, then derive verses, stats, total, loading, and error
from the query while preserving pagination and filtering behavior.

In `@admin-web/app/`(dashboard)/subscriptions/page.tsx:
- Around line 72-101: Consolidate the duplicated export constants and
pagination/export flow shared by fetchAllMatching and handleExportCSV in the
subscriptions and token-management pages into one reusable helper or CSV
builder. Parameterize the differing request parsing and row-mapping behavior,
then update both pages to use it while preserving their current export limits,
error handling, and output.

In `@admin-web/app/`(dashboard)/token-management/page.tsx:
- Around line 62-78: Update the useQuery configuration for the token-balance
query to include React Query 5 defaults of staleTime set to one minute and
refetchOnWindowFocus disabled, while preserving the existing queryKey, queryFn,
and placeholderData behavior.

In `@admin-web/app/`(dashboard)/topics/page.tsx:
- Around line 43-66: Update the useQuery configuration for the study-guides
request to include the project’s standard React Query defaults, reusing the
established shared values or configuration rather than defining new ones. Keep
the existing queryKey, queryFn, and keepPreviousData behavior unchanged.

In `@admin-web/app/api/admin/content/daily-verses/route.ts`:
- Around line 73-77: Update the by_language aggregation to avoid using
fetchAllRows over the full filtered daily_verses_cache table. Replace it with
constant-cost exact head count queries for each language, or reuse a small
grouped-count RPC, while preserving the existing filters and returned language
counts; apply the same change to the second matching query.

In `@admin-web/app/api/admin/content/suggested-verses/route.ts`:
- Around line 96-136: Replace the full-table fetches and local aggregation
around allCategories, allTranslations, byCategory, and coverageSets with count
queries: use an exact head-only count on suggested_verses for total_all, and
exact counts on suggested_verse_translations filtered by each supported
language_code for translation_coverage. Remove the unused fetchAllRows results
and ensure all count-query errors are handled consistently with the surrounding
route instead of silently under-reporting.

In `@admin-web/app/api/admin/gamification/streaks/route.ts`:
- Around line 144-151: The email lookup currently includes every user from all
streak and XP data instead of only users needed by the response. Update the ID
collection before getAuthEmailMap to include IDs from the current page rows and
the four leaderboard result sets, while preserving deduplication and excluding
unrelated users from studyUserIds, verseUserIds, and xpUserIds.

In `@admin-web/app/api/admin/search-users/route.ts`:
- Around line 78-94: Update the search flow around listAllAuthUsers and the
empty-query email mapping to avoid enumerating auth users on every request.
Prefer a short-lived shared cache for the auth user email-to-ID map, reuse
cached data for both paths, and refresh or invalidate it according to the
existing data consistency needs while preserving MAX_INLINE_IDS truncation.

In `@admin-web/app/api/admin/system/subscription-config/route.ts`:
- Around line 67-78: Update the per-plan query inside the planIds mapping to
capture Supabase’s error alongside count, then log the error with planId or fail
the request instead of returning 0 when the count query fails. Preserve the
existing zero fallback only for successful queries with no rows, and keep
planCounts construction unchanged for valid results.

In `@admin-web/app/api/admin/topics-with-guides/route.ts`:
- Around line 92-99: Refactor the topicsWithGuides processing so guidesData is
grouped once by topic_id before the per-topic map, using a Map or equivalent
single-pass accumulator. Replace the per-topic guidesData.filter call and retain
the existing mode-counting and sorting behavior while ensuring total processing
is linear in the number of topics and guides.

In `@backend/supabase/functions/admin-learning-paths/index.ts`:
- Around line 238-255: Replace the full-table fetchAllRows calls for topicCounts
and enrollments with grouped database-side aggregates, using a view or RPC that
returns each learning_path_id with its count. Update the per-path counting logic
to consume these aggregated rows while preserving accurate Topics and Total
Enrolled statistics without transferring individual topic or enrollment records.

In `@backend/supabase/functions/admin-list-promo-codes/index.ts`:
- Around line 148-158: The campaign statistics loop should reuse the request’s
parsed now timestamp instead of creating a fresh Date for isExpired. Update the
nearby list/filter comparisons to use the same now value consistently,
preserving the existing valid_until and active-status behavior.

In `@backend/supabase/functions/admin-study-guides/index.ts`:
- Around line 281-294: Replace the per-creator getUserById fan-out in the
missing-creators fallback with a bounded bulk lookup using auth.admin.listUsers
pagination or a single auth.users query. Populate creatorsMap for matching
creator IDs while preserving the existing missing-name fallback when the lookup
fails or finds no email.
- Around line 154-319: The handleList function exceeds the 20-line limit and
combines search resolution, filtering, pagination, usage counting, topic
enrichment, and creator enrichment. Extract these responsibilities into focused
helpers, including resolveSearchIds, loadUsageCounts, loadTopicTitles, and
loadCreatorNames, then keep handleList as a short orchestration function that
preserves the existing query, pagination, enrichment, stats, and response
behavior.

In `@backend/supabase/functions/admin-usage-analytics/index.ts`:
- Around line 239-251: Update the provider, model, and daily paged-read sections
around fetchAllRows to retain failure state instead of silently returning empty
arrays. Track a per-section error or partial marker, set it when any pagination
request fails (including mid-pagination failures), and include that marker in
the corresponding response payload so the UI can distinguish failed data from
genuine zero values while preserving id ordering.
🪄 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 Plus

Run ID: 4b94252d-2aa1-432e-a302-5ac93c64abfd

📥 Commits

Reviewing files that changed from the base of the PR and between e0c5c08 and b32b320.

📒 Files selected for processing (39)
  • admin-web/app/(dashboard)/blogs/page.tsx
  • admin-web/app/(dashboard)/content-management/page.tsx
  • admin-web/app/(dashboard)/gamification/page.tsx
  • admin-web/app/(dashboard)/issues/[feedbackId]/page.tsx
  • admin-web/app/(dashboard)/issues/page.tsx
  • admin-web/app/(dashboard)/memory-verses/page.tsx
  • admin-web/app/(dashboard)/promo-codes/page.tsx
  • admin-web/app/(dashboard)/security/page.tsx
  • admin-web/app/(dashboard)/subscriptions/page.tsx
  • admin-web/app/(dashboard)/token-management/[userId]/page.tsx
  • admin-web/app/(dashboard)/token-management/page.tsx
  • admin-web/app/(dashboard)/topics/page.tsx
  • admin-web/app/api/admin/admin-logs/route.ts
  • admin-web/app/api/admin/blogs/route.ts
  • admin-web/app/api/admin/content/daily-verses/route.ts
  • admin-web/app/api/admin/content/suggested-verses/route.ts
  • admin-web/app/api/admin/feedback/route.ts
  • admin-web/app/api/admin/gamification/achievements/route.ts
  • admin-web/app/api/admin/gamification/streaks/route.ts
  • admin-web/app/api/admin/gamification/user-achievements/route.ts
  • admin-web/app/api/admin/purchase-issues/route.ts
  • admin-web/app/api/admin/search-users/route.ts
  • admin-web/app/api/admin/security-events/route.ts
  • admin-web/app/api/admin/subscription-stats/route.ts
  • admin-web/app/api/admin/system/subscription-config/route.ts
  • admin-web/app/api/admin/topics-with-guides/route.ts
  • admin-web/app/api/admin/topics/route.ts
  • admin-web/app/api/admin/user-token-balances/route.ts
  • admin-web/lib/api/admin.ts
  • admin-web/types/admin.ts
  • backend/supabase/functions/admin-learning-paths/index.ts
  • backend/supabase/functions/admin-list-promo-codes/index.ts
  • backend/supabase/functions/admin-pl-analytics/index.ts
  • backend/supabase/functions/admin-study-guides/index.ts
  • backend/supabase/functions/admin-usage-analytics/index.ts
  • distribution/whatsnew/whatsnew-en-IN
  • distribution/whatsnew/whatsnew-en-US
  • distribution/whatsnew/whatsnew-hi-IN
  • distribution/whatsnew/whatsnew-ml-IN

Comment on lines +14 to +52
/** Prev/next controls for a server-paged list. */
function Pagination({
page,
total,
isFetching,
onChange,
noun,
}: {
page: number
total: number
isFetching: boolean
onChange: (page: number) => void
noun: string
}) {
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
return (
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-gray-600 dark:text-gray-400">
Page {page + 1} of {totalPages} ({total} {noun})
</p>
<div className="flex gap-2">
<button
onClick={() => onChange(Math.max(0, page - 1))}
disabled={page === 0 || isFetching}
className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
>
Previous
</button>
<button
onClick={() => onChange(page + 1)}
disabled={page + 1 >= totalPages || isFetching}
className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
>
Next
</button>
</div>
</div>
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

The same prev/next pagination control is re-implemented in four dashboards. One shared component (plus a shared PAGE_SIZE) removes the duplication and the drift already visible between copies (prop named isFetching vs isBusy, footer vs inline markup). As per coding guidelines: "Follow the DRY (Don't Repeat Yourself) principle by extracting common functionality into reusable components".

  • admin-web/app/(dashboard)/issues/page.tsx#L14-L52: move this Pagination into a shared module (e.g. admin-web/components/ui/pagination.tsx) taking page, total, pageSize, isFetching, onChange, noun, and import it here.
  • admin-web/app/(dashboard)/security/page.tsx#L46-L86: delete the byte-identical local copy and the local PAGE_SIZE duplication, importing the shared component instead.
  • admin-web/app/(dashboard)/gamification/page.tsx#L24-L64: delete TablePagination and use the shared component, renaming the isBusy prop at the call sites.
  • admin-web/app/(dashboard)/content-management/page.tsx#L160-L187: replace the inline footer markup with the shared component.
📍 Affects 4 files
  • admin-web/app/(dashboard)/issues/page.tsx#L14-L52 (this comment)
  • admin-web/app/(dashboard)/security/page.tsx#L46-L86
  • admin-web/app/(dashboard)/gamification/page.tsx#L24-L64
  • admin-web/app/(dashboard)/content-management/page.tsx#L160-L187
🤖 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 `@admin-web/app/`(dashboard)/issues/page.tsx around lines 14 - 52, Extract the
shared pagination behavior into a reusable component in
admin-web/components/ui/pagination.tsx, including the shared PAGE_SIZE and props
page, total, pageSize, isFetching, onChange, and noun. In
admin-web/app/(dashboard)/issues/page.tsx lines 14-52, replace Pagination with
the shared import; in admin-web/app/(dashboard)/security/page.tsx lines 46-86,
remove the local Pagination and PAGE_SIZE and import the shared component; in
admin-web/app/(dashboard)/gamification/page.tsx lines 24-64, remove
TablePagination, use the shared component, and rename isBusy call-site props to
isFetching; in admin-web/app/(dashboard)/content-management/page.tsx lines
160-187, replace the inline footer with the shared component.

Source: Coding guidelines

Comment on lines +63 to +70
// Search runs in SQL so it spans every post, not just the page on screen.
// `tags` is a text[], so match it with the array `cs` (contains) operator.
if (search) {
const escaped = search.replace(/[%,()]/g, '')
query = query.or(
`title.ilike.%${escaped}%,excerpt.ilike.%${escaped}%,slug.ilike.%${escaped}%,tags.cs.{"${escaped}"}`
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

PostgREST or filter value quoting escape rules cs contains array operator supabase-js

💡 Result:

To use the PostgREST contains operator (cs, which maps to the PostgreSQL @> operator) in Supabase or directly via PostgREST, follow these guidelines for syntax and escaping. 1. Filter Syntax and Operators - The cs (contains) operator is used to filter columns where an array or JSONB type contains the provided value [1][2]. - In raw PostgREST URLs, the format is ?column=cs.{value1,value2} for arrays [1][2]. - When using supabase-js, you can use the .contains(column, value) method [3][4]. This method accepts either a JavaScript array (which the library handles) or a string formatted as a Postgres array (e.g., '{"a","b"}') [4][5]. 2. Quoting and Escape Rules - If your filter value includes reserved characters (,, ., :, *, (, )), you must surround the value in double quotes and ensure they are percent-encoded in the URL if necessary [6][7][8]. - Double quotes inside a value can be escaped with a backslash (\") [6][9]. - A literal backslash inside a value is escaped with a double backslash (\\) [6][9]. - For example, if you need to filter a list with special characters: ?name=in.(%22Quote:%5C%22%22,%22Backslash:%5C%5C%22) [6][10]. - When using supabase-js, the library often handles the encoding for you when you pass standard JavaScript arrays to the .contains() method [4][5]. When manually writing raw filters (e.g., inside .or()), you must adhere to the raw PostgREST URL grammar rules cited above [11][12]. 3. Common Patterns - For array columns: .contains('array_col', ['a', 'b']) [4][5]. - For string-based manual filters: .or('array_col.cs.{"a","b"}') [3][12]. - When dealing with complex types like JSONB, ensure the structure matches what PostgreSQL expects for the @> operator [13]. If you encounter errors, verify that your URL-encoded string correctly represents the expected PostgreSQL literal [8].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== candidate file =="
fd -a 'route\.ts$' admin-web/app/api/admin/blogs || true

echo "== outline =="
ast-grep outline admin-web/app/api/admin/blogs/route.ts --view expanded || true

echo "== relevant source =="
sed -n '1,130p' admin-web/app/api/admin/blogs/route.ts

echo "== package versions for supabase/db =="
for f in package.json app/package.json admin-web/package.json; do
  [ -f "$f" ] && echo "--- $f" && node -e "const p=require('./$f'); console.log(JSON.stringify(p.dependencies?{}:{}))" | sed -n '1,120p'
done

Repository: fennsaji/disciplefy

Length of output: 5381


🌐 Web query:

PostgREST JSON literal string escaping double quote backslash in array element quotes

💡 Result:

In PostgREST, when working with array elements in URL filter parameters (such as the in operator) that contain reserved characters like double quotes (") or backslashes (), you must use backslash escaping [1][2]. For filter values containing these characters: - To include a double quote ("), escape it with a backslash: " [1][2]. - To include a backslash (), escape it with a double backslash: \ [1][2]. When using these in a URL, they must be percent-encoded if they are part of the filter string [1][2]. For example, if you are using the in operator: - A value containing a double quote (e.g., Quote:") would be represented as: in.(%22Quote:%5C%22%22) [1][2]. - A value containing a backslash (e.g., Backslash:) would be represented as: in.(%22Backslash:%5C%5C%22) [1][2]. Note that some HTTP client libraries automatically handle percent-encoding; in such cases, you may provide the double quotes directly (e.g., in.("Quote:"")) [1][2]. If you are sending JSON data in the request body (e.g., for POST or PATCH operations), standard JSON escaping rules apply, where you should escape double quotes and backslashes according to the JSON specification (using a single backslash, e.g., "name": "Value with "quote""), rather than the specific URL grammar rules described above [3][4]. PostgREST internally handles the conversion from this valid JSON to the appropriate PostgreSQL format [5].

Citations:


🌐 Web query:

PostgREST raw filter values double quote escaping supabase js or filter

💡 Result:

In PostgREST and the supabase-js client library, escaping double quotes within filter values requires using a backslash \ [1][2][3]. When a filter value contains PostgREST reserved characters (such as ,, ., :, (, or )), it must be wrapped in double quotes; if that value also contains literal double quotes or backslashes, those characters must be escaped with a backslash—specifically \" for a double quote and \\ for a backslash [1][4][3]. Recent updates to supabase-js have improved this behavior to handle escaping automatically in many standard filter methods (such as in(), notIn(), likeAllOf(), ilikeAllOf(), etc.) [1][2]. When using these methods, you should provide the raw string value (e.g., a"b,c), and the library will correctly serialize it to the required PostgREST format (e.g., in.("a\"b,c")) [1][2]. If you are using the generic .filter() escape hatch or constructing raw PostgREST queries manually, you must handle the escaping and quoting yourself [5][6]. In these cases: - Wrap the value in double quotes if it contains reserved characters [3][7]. - Use a backslash to escape internal double quotes \" and backslashes \\ [3][7]. - Avoid using CSV-style doubling (e.g., "") for quotes, as PostgREST expects backslash-based escaping [1][2]. If your HTTP client or environment automatically encodes URLs (like axios or certain browser contexts), you may need to use literal double quotes " in your string instead of URL-encoded %22 [8][3][7].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from urllib.parse import quote
search = r'a"\b'  # a" + backslash + b
current = search.replace(/[%,()]/g, '')
filter_value = f'title.ilike.%{current}%,excerpt.ilike.%{current}%,slug.ilike.%{current}%,tags.cs.{{"{current}"}}'
print("search:", repr(search))
print("current:", repr(current))
print("filter_value:", filter_value)
print("filter_value_quote_percent:", quote(filter_value, safe=''))
PY

echo "== locate typed fetchers / docs references =="
rg -n "from \`@/types/admin`|`@/types/admin`|fetch-all-rows|api/admin\\.ts|createAdminClient|createClient\\(\\)" admin-web -g '*.ts' -g '*.tsx' | head -80

Repository: fennsaji/disciplefy

Length of output: 285


🌐 Web query:

supabase-js or filter string escaping double quote raw PostgREST query tests

💡 Result:

To escape double quotes and other reserved characters in supabase-js, you should rely on the library's built-in handling, which automatically manages escaping for standard filter methods like.in and.notIn [1][2]. When you need to manually construct PostgREST filter strings (such as in.or or.filter), you must follow PostgREST's URL grammar [3][4]. PostgREST treats the following characters as reserved:,.: " \ [5][4] If your filter value contains any of these characters, you must wrap the value in double quotes [5][4]. Inside those quotes, you must escape double quotes (") and backslashes () using a backslash [1][4][6]: - Escape a double quote (") as " [1][4] - Escape a backslash () as \ [1][4] Note that PostgREST uses backslash escaping rather than CSV-style doubling (e.g., " instead of "") [1][7]. For example, to filter a column for the value Hello"World: - The raw PostgREST string would be: col.eq."Hello"World" [4][6] - In supabase-js using.or:.or('col.eq."Hello"World"') [5] Recent updates to supabase-js have improved automatic handling for these cases in methods like.in,.notIn,.likeAllOf, and others to ensure that values containing reserved characters and quotes are automatically escaped correctly [1][7]. If you encounter issues where values are being incorrectly split by commas or malformed, ensure you are using the latest version of the client library [1][2].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from urllib.parse import quote
search = 'a"\\b'
current = ''.join(c for c in search if c not in '%(),')
filter_value = f'title.ilike.%{current}%,excerpt.ilike.%{current}%,slug.ilike.%{current}%,tags.cs.{{"{current}"}}'
print("search:", repr(search))
print("current:", repr(current))
print("filter_value:", filter_value)
print("filter_value_quote_percent:", quote(filter_value, safe=''))
PY

echo "== search usages/filter builder references =="
rg -n "\.or\(|\.filter\(|tag[s]?\.cs\.|\[filter\b|filter_all_of|ilike|contains\(" admin-web/app admin-web/lib admin-web -g '*.ts' -g '*.tsx' | head -120

Repository: fennsaji/disciplefy

Length of output: 15221


🌐 Web query:

PostgREST contains operator array of strings filter value escaping double quote cs operator

💡 Result:

In PostgREST, the cs (contains) operator is primarily designed for filtering PostgreSQL arrays and JSONB columns using the @> operator [1][2]. When filtering with cs, you typically use curly braces {} for standard PostgreSQL arrays (e.g., ?tags=cs.{example,new}) [1][2][3]. For in operator filters specifically, PostgREST supports escaping double quotes within values using a backslash [4][5]. However, the cs operator operates differently, as it interprets its argument as a literal array or JSON structure. If you need to include a double quote in a value passed to a cs filter—particularly when working with JSONB—you must format the value according to the expected type (e.g., JSONB array [] or object {}) and ensure proper URL encoding [6][7][8]. Key points on escaping and formatting: 1. Inoperator: If a value contains a double quote, you can escape it with a backslash (e.g.,?col=in.("Double"Quote")) [9]. 2. csoperator with Arrays: Generally expects{value1,value2}syntax [1][2]. If your values contain commas or other reserved characters, you should wrap them in double quotes [4][5]. 3.csoperator with JSONB: Often expects JSON array syntax[](e.g.,?col=cs.["value1","value2"]) [6][7]. Because these values are part of a JSON structure, you may need to percent-encode the entire string to ensure the double quotes and brackets are transmitted correctly to the server (e.g., ?col=cs.%5B%22value%22%5D) [6][7]. If you encounter issues with malformed literals, verify whether your target column is a native PostgreSQL array or JSONB, as the syntax requirements differ [6][8]. When using ->(JSONB operator) versus accessing the raw column, ensure you are matching the expected input type for the@>` operator [6][7].

Citations:


Escape raw .or() filter values before interpolating them.

The current sanitizer only removes %, ,, (, and ), but PostgREST filter values can still contain ", \, _, and braces that change the ilike wildcards or the tags.cs.{"…"} array value. Escape _ for the LIKE terms and escape/strip quote/array escape sequences used by PostgREST for raw filters, or route this search through the typed fetcher instead.

🤖 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 `@admin-web/app/api/admin/blogs/route.ts` around lines 63 - 70, Update the
search sanitization in the blog route’s search query before interpolating into
query.or: escape underscores for the ilike patterns and neutralize quotes,
backslashes, and array braces/escape sequences so values cannot alter PostgREST
raw filters, while preserving legitimate search behavior. Apply the same safe
value consistently across title, excerpt, slug, and tags.cs clauses.

Comment on lines +81 to +102
const [totalRes, helpfulRes, notHelpfulRes, sentimentRows] = await Promise.all([
applyFilters(supabaseAdmin.from('feedback').select('id', { count: 'exact', head: true })),
applyFilters(supabaseAdmin.from('feedback').select('id', { count: 'exact', head: true })).eq('was_helpful', true),
applyFilters(supabaseAdmin.from('feedback').select('id', { count: 'exact', head: true })).eq('was_helpful', false),
fetchAllRows<{ sentiment_score: number | null }>((from, to) =>
applyFilters(supabaseAdmin.from('feedback').select('sentiment_score'))
.not('sentiment_score', 'is', null)
.order('id', { ascending: true })
.range(from, to)
),
])

const scores = (sentimentRows.data || [])
.map(r => r.sentiment_score)
.filter((v): v is number => typeof v === 'number')
const stats = {
total: totalRes.count || 0,
helpful: helpfulRes.count || 0,
not_helpful: notHelpfulRes.count || 0,
avg_sentiment: scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : 0,
sentiment_sample_size: scores.length,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Helpful/not-helpful breakdown collapses when the helpful filter is active.

applyFilters already applies was_helpful when helpful is 'true'/'false', so the two stat queries then add a second, contradictory .eq('was_helpful', …). With helpful=true the not_helpful count is always 0 (and vice versa), making the stats panel misleading rather than a true breakdown of the filtered set.

🛠️ Compute the breakdown from the non-helpful filters only
-    const applyFilters = (q: any) => {
+    const applyBaseFilters = (q: any) => {
       if (id) q = q.eq('id', id) // single row, used by the detail page
       if (category) q = q.eq('category', category)
+      return q
+    }
+    const applyFilters = (q: any) => {
+      q = applyBaseFilters(q)
       if (helpful === 'true') q = q.eq('was_helpful', true)
       else if (helpful === 'false') q = q.eq('was_helpful', false)
       return q
     }
@@
-      applyFilters(supabaseAdmin.from('feedback').select('id', { count: 'exact', head: true })).eq('was_helpful', true),
-      applyFilters(supabaseAdmin.from('feedback').select('id', { count: 'exact', head: true })).eq('was_helpful', false),
+      applyBaseFilters(supabaseAdmin.from('feedback').select('id', { count: 'exact', head: true })).eq('was_helpful', true),
+      applyBaseFilters(supabaseAdmin.from('feedback').select('id', { count: 'exact', head: true })).eq('was_helpful', false),
📝 Committable suggestion

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

Suggested change
const [totalRes, helpfulRes, notHelpfulRes, sentimentRows] = await Promise.all([
applyFilters(supabaseAdmin.from('feedback').select('id', { count: 'exact', head: true })),
applyFilters(supabaseAdmin.from('feedback').select('id', { count: 'exact', head: true })).eq('was_helpful', true),
applyFilters(supabaseAdmin.from('feedback').select('id', { count: 'exact', head: true })).eq('was_helpful', false),
fetchAllRows<{ sentiment_score: number | null }>((from, to) =>
applyFilters(supabaseAdmin.from('feedback').select('sentiment_score'))
.not('sentiment_score', 'is', null)
.order('id', { ascending: true })
.range(from, to)
),
])
const scores = (sentimentRows.data || [])
.map(r => r.sentiment_score)
.filter((v): v is number => typeof v === 'number')
const stats = {
total: totalRes.count || 0,
helpful: helpfulRes.count || 0,
not_helpful: notHelpfulRes.count || 0,
avg_sentiment: scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : 0,
sentiment_sample_size: scores.length,
}
const applyBaseFilters = (q: any) => {
if (id) q = q.eq('id', id) // single row, used by the detail page
if (category) q = q.eq('category', category)
return q
}
const applyFilters = (q: any) => {
q = applyBaseFilters(q)
if (helpful === 'true') q = q.eq('was_helpful', true)
else if (helpful === 'false') q = q.eq('was_helpful', false)
return q
}
const [totalRes, helpfulRes, notHelpfulRes, sentimentRows] = await Promise.all([
applyFilters(supabaseAdmin.from('feedback').select('id', { count: 'exact', head: true })),
applyBaseFilters(supabaseAdmin.from('feedback').select('id', { count: 'exact', head: true })).eq('was_helpful', true),
applyBaseFilters(supabaseAdmin.from('feedback').select('id', { count: 'exact', head: true })).eq('was_helpful', false),
fetchAllRows<{ sentiment_score: number | null }>((from, to) =>
applyFilters(supabaseAdmin.from('feedback').select('sentiment_score'))
.not('sentiment_score', 'is', null)
.order('id', { ascending: true })
.range(from, to)
),
])
🤖 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 `@admin-web/app/api/admin/feedback/route.ts` around lines 81 - 102, Update the
helpfulRes and notHelpfulRes queries in the stats calculation to use only the
non-helpful filters, avoiding the was_helpful condition applied by applyFilters
when the helpful filter is active. Preserve totalRes and sentimentRows
filtering, and keep each breakdown query’s explicit helpful or not-helpful
condition so the stats represent the full filtered set breakdown.

Comment on lines 53 to 105
const [
{ data: studyStreaks, error: studyStreaksError },
{ data: verseStreaks },
{ data: xpRows },
{ data: achievementXpRows },
{ data: allProfiles },
studyStreaksRes,
verseStreaksRes,
xpRowsRes,
achievementXpRes,
profilesRes,
] = await Promise.all([
supabaseAdmin
.from('user_study_streaks')
.select('*')
.order(sortBy, { ascending: false })
.limit(limit),
supabaseAdmin
.from('daily_verse_streaks')
.select('user_id, current_streak, longest_streak, last_viewed_at, total_views'),
fetchAllRows<any>((from, to) =>
supabaseAdmin
.from('user_study_streaks')
.select('*')
.order(sortBy, { ascending: false })
.order('user_id', { ascending: true })
.range(from, to)
),
fetchAllRows<{
user_id: string
current_streak: number
longest_streak: number
last_viewed_at: string | null
total_views: number
}>((from, to) =>
supabaseAdmin
.from('daily_verse_streaks')
.select('user_id, current_streak, longest_streak, last_viewed_at, total_views')
.order('user_id', { ascending: true })
.range(from, to)
),
// Study XP from user_topic_progress
supabaseAdmin
.from('user_topic_progress')
.select('user_id, xp_earned'),
fetchAllRows<{ user_id: string; xp_earned: number | null }>((from, to) =>
supabaseAdmin
.from('user_topic_progress')
.select('user_id, xp_earned')
.order('user_id', { ascending: true })
.range(from, to)
),
// Achievement XP: join user_achievements with achievements to get xp_reward per unlock
supabaseAdmin
.from('user_achievements')
.select('user_id, achievements(xp_reward)'),
fetchAllRows<any>((from, to) =>
supabaseAdmin
.from('user_achievements')
.select('user_id, achievements(xp_reward)')
.order('user_id', { ascending: true })
.range(from, to)
),
// All registered users for total count and profile lookup
supabaseAdmin
.from('user_profiles')
.select('id, first_name, last_name'),
fetchAllRows<{ id: string; first_name: string | null; last_name: string | null }>((from, to) =>
supabaseAdmin
.from('user_profiles')
.select('id, first_name, last_name')
.order('id', { ascending: true })
.range(from, to)
),
])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

A paged endpoint now reads five tables in full on every request.

user_topic_progress and user_achievements grow with every user session, yet the handler materialises all of them (plus all profiles) just to serve a 50-row slice, and pagination is a slice in memory. Latency and memory scale with total usage, and it breaches the <500 ms data-query budget in the guidelines. Consider moving the aggregates (count, avg, max, distribution buckets, top-10 leaderboards, per-user XP sums) into SQL views / rpc calls and paging user_study_streaks with range(offset, offset + limit - 1) + count: 'exact'. As per coding guidelines: "Ensure API response times are … less than 500ms for data queries" and "Optimize database queries with proper indexing and prevent N+1 queries".

Also applies to: 168-170

🤖 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 `@admin-web/app/api/admin/gamification/streaks/route.ts` around lines 53 - 105,
Refactor the streaks handler’s Promise.all data-loading flow to avoid fetching
all rows from user_topic_progress, user_achievements, and user_profiles on every
request. Move aggregate calculations and per-user XP totals into SQL views or
RPCs, and query user_study_streaks with database pagination using range(offset,
offset + limit - 1) and count: 'exact'; build the response from these paged
results without the in-memory slice, preserving the existing sorting, totals,
distributions, leaderboards, and profile lookups.

Source: Coding guidelines

Comment on lines +107 to +119
if (studyStreaksRes.error) {
console.error('Failed to fetch streaks:', studyStreaksRes.error)
return NextResponse.json(
{ error: 'Failed to fetch streaks' },
{ status: 500 }
)
}

const allStudyStreaks = studyStreaksRes.data
const verseStreaks = verseStreaksRes.data
const xpRows = xpRowsRes.data
const achievementXpRows = achievementXpRes.data
const allProfiles = profilesRes.data

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Only the study-streak error is checked; the other four fetchAllRows results can fail silently.

fetchAllRows returns accumulated rows plus the error, so a failure in verseStreaksRes, xpRowsRes, achievementXpRes or profilesRes yields silently truncated arrays — total_users, XP totals and verse-streak stats then render as confidently wrong numbers. Same applies when a source exceeds MAX_PAGES.

🛡️ Proposed fix
-    if (studyStreaksRes.error) {
-      console.error('Failed to fetch streaks:', studyStreaksRes.error)
+    const firstError =
+      studyStreaksRes.error ||
+      verseStreaksRes.error ||
+      xpRowsRes.error ||
+      achievementXpRes.error ||
+      profilesRes.error
+    if (firstError) {
+      console.error('Failed to fetch streak analytics:', firstError)
       return NextResponse.json(
         { error: 'Failed to fetch streaks' },
         { status: 500 }
       )
     }
📝 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
if (studyStreaksRes.error) {
console.error('Failed to fetch streaks:', studyStreaksRes.error)
return NextResponse.json(
{ error: 'Failed to fetch streaks' },
{ status: 500 }
)
}
const allStudyStreaks = studyStreaksRes.data
const verseStreaks = verseStreaksRes.data
const xpRows = xpRowsRes.data
const achievementXpRows = achievementXpRes.data
const allProfiles = profilesRes.data
const firstError =
studyStreaksRes.error ||
verseStreaksRes.error ||
xpRowsRes.error ||
achievementXpRes.error ||
profilesRes.error
if (firstError) {
console.error('Failed to fetch streak analytics:', firstError)
return NextResponse.json(
{ error: 'Failed to fetch streaks' },
{ status: 500 }
)
}
const allStudyStreaks = studyStreaksRes.data
const verseStreaks = verseStreaksRes.data
const xpRows = xpRowsRes.data
const achievementXpRows = achievementXpRes.data
const allProfiles = profilesRes.data
🤖 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 `@admin-web/app/api/admin/gamification/streaks/route.ts` around lines 107 -
119, Validate the errors from all five fetchAllRows results before deriving
statistics: studyStreaksRes, verseStreaksRes, xpRowsRes, achievementXpRes, and
profilesRes. Reuse the existing 500-response error handling and include the
failing source context, ensuring any fetch error—including MAX_PAGES
exhaustion—prevents the truncated data arrays from being used to calculate
totals.

Comment on lines +134 to +136
const { data: allCampaigns } = await adminSupabase
.from('promotional_campaigns')
.select('id', { count: 'exact', head: true })
.select('is_active, valid_until, current_use_count')

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Stats read is unchecked and itself subject to the 1000-row cap.

The error field is discarded, so a failed read silently reports all-zero stat cards; and this plain select is exactly the truncating pattern the rest of the PR replaces with fetchAllRows. If the table can grow past 1000 campaigns, total/total_redemptions will plateau. Prefer SQL aggregation (or the paged helper) plus an explicit error check.

🤖 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 `@backend/supabase/functions/admin-list-promo-codes/index.ts` around lines 134
- 136, The campaign statistics query in the admin-list-promo-codes handler
currently discards errors and is limited by Supabase’s default row cap. Update
the stats calculation around the promotional_campaigns select to use SQL
aggregation or the existing fetchAllRows helper, retain and explicitly handle
the query error, and ensure total and total_redemptions include all campaigns.

Comment on lines +131 to +145
async function fetchAllRows(
buildPage: (from: number, to: number) => any
): Promise<any[]> {
const PAGE = 1000
const MAX_PAGES = 100 // safety cap: 100k rows
const rows: any[] = []
for (let page = 0; page < MAX_PAGES; page++) {
const from = page * PAGE
const { data, error } = await buildPage(from, from + PAGE - 1)
if (error) throw new Error(error.message)
rows.push(...(data || []))
if (!data || data.length < PAGE) break
}
return rows
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

fetchAllRows is copy-pasted verbatim across the touched functions. The same 12-line paging helper (plus any typing) is duplicated in every file in this PR and, per the graph context, in several admin-web routes too — so any fix to the page size, the MAX_PAGES cap or the truncation signal has to be applied in eight places.

  • backend/supabase/functions/admin-study-guides/index.ts#L131-L145: move the helper into _shared/utils/ (e.g. fetch-all-rows.ts) as a generic fetchAllRows<T> and import it here.
  • backend/supabase/functions/admin-pl-analytics/index.ts#L9-L27: delete the local copy and import the shared helper.
  • backend/supabase/functions/admin-usage-analytics/index.ts#L16-L34: delete the local copy and import the shared helper.

As per coding guidelines, "Follow the DRY (Don't Repeat Yourself) principle by extracting common functionality into reusable components".

📍 Affects 3 files
  • backend/supabase/functions/admin-study-guides/index.ts#L131-L145 (this comment)
  • backend/supabase/functions/admin-pl-analytics/index.ts#L9-L27
  • backend/supabase/functions/admin-usage-analytics/index.ts#L16-L34
🤖 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 `@backend/supabase/functions/admin-study-guides/index.ts` around lines 131 -
145, Extract the duplicated fetchAllRows paging logic into a shared generic
fetchAllRows<T> helper under
backend/supabase/functions/_shared/utils/fetch-all-rows.ts, replacing any types
with the appropriate generic typing while preserving pagination, error, and
truncation behavior. In backend/supabase/functions/admin-study-guides/index.ts
at lines 131-145, remove the local helper and import the shared implementation;
make the same deletion and import change in
backend/supabase/functions/admin-pl-analytics/index.ts at lines 9-27 and
backend/supabase/functions/admin-usage-analytics/index.ts at lines 16-34.

Source: Coding guidelines

Comment on lines +159 to +172
let topicIdMatches: string[] = []
let creatorIdMatches: string[] = []
if (search) {
const [{ data: topics }, { data: creators }] = await Promise.all([
client.from('recommended_topics').select('id').ilike('title', `%${search}%`).limit(MAX_INLINE_IDS),
client
.from('user_profiles')
.select('id')
.or(`first_name.ilike.%${search}%,last_name.ilike.%${search}%`)
.limit(MAX_INLINE_IDS),
])
topicIdMatches = (topics || []).map((t: any) => t.id)
creatorIdMatches = (creators || []).map((c: any) => c.id)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

MAX_INLINE_IDS silently drops search matches.

If a term matches more than 300 topics or 300 creators, the extra ids are dropped and guides linked to them vanish from results with no indication — the truncation this PR removes elsewhere, reintroduced in the search path. Consider resolving the join server-side (view/RPC over study_guides joined to recommended_topics/user_profiles) or at minimum surfacing a "results narrowed" flag in 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 `@backend/supabase/functions/admin-study-guides/index.ts` around lines 159 -
172, Update the search flow around topicIdMatches and creatorIdMatches so it
does not truncate matching IDs with MAX_INLINE_IDS, preserving all matching
study guides; preferably resolve the topic and creator joins server-side through
the existing study_guides query mechanism, or otherwise add a response flag that
explicitly indicates narrowed results when a limit remains necessary.

Comment on lines +178 to +185
if (search) {
const conditions = [`input_value.ilike.%${search}%`]
if (topicIdMatches.length > 0) conditions.push(`topic_id.in.(${topicIdMatches.join(',')})`)
if (creatorIdMatches.length > 0) {
conditions.push(`creator_user_id.in.(${creatorIdMatches.join(',')})`)
}
q = q.or(conditions.join(','))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

Raw search is interpolated into a PostgREST filter expression.

Inside .or(...), the value is part of the filter grammar, not a bound parameter. A search containing ,, (, ), . or " alters or breaks the expression — faith, hope already produces an invalid/unintended filter, and a crafted string can inject additional conditions such as x),(id.neq.null. Escape the value and quote it (PostgREST accepts double-quoted values with \ escaping) before embedding. Note %/_ also act as ilike wildcards here and on line 163.

As per coding guidelines, "Implement input validation and sanitization for all user data to prevent SQL injection, XSS, and prompt injection attacks".

🔒️ Proposed escaping
+  // PostgREST filter values must be quoted/escaped: they are part of the
+  // expression grammar inside .or().
+  const quoted = search ? `"%${search.replace(/[\\"]/g, (c) => `\\${c}`)}%"` : ''
+
   const applyFilters = (q: any) => {
     if (inputType) q = q.eq('input_type', inputType)
     if (studyMode) q = q.eq('study_mode', studyMode)
     if (language) q = q.eq('language', language)
     if (search) {
-      const conditions = [`input_value.ilike.%${search}%`]
+      const conditions = [`input_value.ilike.${quoted}`]
       if (topicIdMatches.length > 0) conditions.push(`topic_id.in.(${topicIdMatches.join(',')})`)
       if (creatorIdMatches.length > 0) {
         conditions.push(`creator_user_id.in.(${creatorIdMatches.join(',')})`)
       }
       q = q.or(conditions.join(','))
     }
     return q
   }
🤖 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 `@backend/supabase/functions/admin-study-guides/index.ts` around lines 178 -
185, Sanitize the user-provided search value before constructing PostgREST
filters in the admin study-guides query, including the earlier ilike usage near
line 163 and the `.or(...)` block. Escape backslashes and double quotes, wrap
the value in PostgREST-compatible double quotes, and preserve intended literal
search behavior by handling `%` and `_` as literal characters rather than
wildcards; use the sanitized value consistently in all generated conditions.

Source: Coding guidelines

Comment on lines +330 to +352
const countFor = async (column: string, value: string): Promise<number> => {
const { count } = await applyFilters(
client.from('study_guides').select('id', { count: 'exact', head: true })
).eq(column, value)
return count || 0
}

const [inputTypeCounts, studyModeCounts, languageCounts, usageTotal] = await Promise.all([
Promise.all(INPUT_TYPES.map(v => countFor('input_type', v))),
Promise.all(STUDY_MODES.map(v => countFor('study_mode', v))),
Promise.all(LANGUAGES.map(v => countFor('language', v))),
client
.from('user_study_guides')
.select('id', { count: 'exact', head: true })
.then((r: any) => r.count || 0),
])

const asMap = (keys: readonly string[], counts: number[]) =>
Object.fromEntries(keys.map((k, i) => [k, counts[i]]).filter(([, c]) => (c as number) > 0))

return {
total,
total_usage: usageTotal,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

buildListStats costs 12 round trips, and total_usage ignores the active filters.

countFor is invoked 11 times (3 input types + 5 study modes + 3 languages), each a separate head count against study_guides, plus the usage count — for one list render. A single grouped aggregate via RPC (GROUP BY input_type, etc.) would return the same breakdown in one call.

Separately, usageTotal on lines 341-344 counts every user_study_guides row globally while the surrounding stats object is documented as covering "EVERY row matching the current filters". Either scope it to the filtered guide set or rename it so the card isn't read as filtered.

🤖 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 `@backend/supabase/functions/admin-study-guides/index.ts` around lines 330 -
352, Update buildListStats to replace the per-value countFor calls with a single
grouped aggregate/RPC that returns the input_type, study_mode, and language
breakdowns in one round trip. Ensure the aggregate and total_usage both respect
the active filters; scope usage to the matching study guides, or rename the
field and card to clearly indicate a global total.

@fennsaji
fennsaji merged commit b78610a into main Jul 30, 2026
1 check 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