Skip to content

[POLY-60] Build admin moderation view for reports#103

Open
cole-hackman wants to merge 3 commits intodevfrom
feature/POLY-60-admin-moderation-view
Open

[POLY-60] Build admin moderation view for reports#103
cole-hackman wants to merge 3 commits intodevfrom
feature/POLY-60-admin-moderation-view

Conversation

@cole-hackman
Copy link
Copy Markdown
Collaborator

@cole-hackman cole-hackman commented Apr 19, 2026

Summary

Adds an admin-facing moderation dashboard where reports can be reviewed and acted on. Admins can view pending reports, see full context (target content, reporter, reason, notes), and take action (dismiss, mark reviewed, hide/unhide content).

Linear: POLY-60

Changes

Backend schema

  • Added isAdmin (optional boolean) to users table
  • Added status ('pending' | 'reviewed' | 'dismissed'), reviewedBy, and reviewedAt fields to reports table
  • Existing reports with null status are treated as 'pending' (no migration needed)

Backend auth (backend/convex/lib/authIdentity.ts)

  • Added requireAdmin() helper that checks isAdmin === true on the user record

Backend admin module (backend/convex/admin.ts)

  • getReports — paginated report queue with status/type filters, enriched with target and reporter context
  • getReportDetail — single report with full target data and all reports for that target
  • getStats — dashboard stats (pending, reviewed, dismissed, hidden counts)
  • isCurrentUserAdmin — check if current user has admin access
  • resolveReport — mark a report as reviewed/dismissed, optionally hide target
  • resolveAllForTarget — bulk resolve all reports for a target
  • hideContent / unhideContent — manually hide or unhide listings/profiles

Frontend admin page (frontend/app/admin/moderation.tsx)

  • Stats dashboard (pending, reviewed, dismissed, hidden counts)
  • Filter chips for status and target type
  • Report cards with type badge, reason, reporter, notes, and action buttons
  • Actions: Dismiss, Mark Reviewed, Hide & Resolve, Unhide
  • Access denied screen for non-admins

Frontend navigation

  • Added route in _layout.tsx
  • Added "Moderation" link in settings page (only visible to admins)

How to make a user admin

Run from the backend directory:

npx convex run --component users:patch '{"id": "<user_id>", "patch": {"isAdmin": true}}'

Developed with Kiro

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

## Release Notes

* **New Features**
  * Added admin moderation dashboard with filtering capabilities by report status and content type
  * Admins can view detailed reports with reporter information and take action (dismiss, mark reviewed, or hide content)
  * Admins can unhide previously hidden listings and profiles
  * Added moderation access in settings for authorized admin users

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 19, 2026

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

Project Deployment Actions Updated (UTC)
poly-buys Ready Ready Preview, Comment Apr 21, 2026 6:27am

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 19, 2026

📝 Walkthrough

Walkthrough

This PR introduces a complete admin moderation system, adding backend queries and mutations for managing reported content, extending the data model with admin status and report lifecycle fields, and creating a React Native UI for admins to review, resolve, and hide/unhide reported listings and profiles.

Changes

Cohort / File(s) Summary
Backend Authorization
backend/convex/lib/authIdentity.ts
Added requireAdmin function that enforces both authentication and admin privilege verification, throwing appropriate errors if either check fails.
Data Model Extensions
backend/convex/schema.ts
Extended users table with optional isAdmin boolean field and reports table with moderation lifecycle fields: status, reviewedBy, reviewedAt.
Admin Moderation Backend
backend/convex/admin.ts
Added admin-gated queries (getReports, getReportDetail, getStats, isCurrentUserAdmin) and mutations (resolveReport, resolveAllForTarget, hideContent, unhideContent) implementing full moderation workflow with target enrichment and report filtering.
Frontend Navigation & UI
frontend/app/(tabs)/settings.tsx, frontend/app/_layout.tsx, frontend/app/admin/moderation.tsx
Added conditional "Moderation" menu item in settings for admins, registered moderation route in navigation stack, and implemented full-featured moderation dashboard screen with filtering, stats display, action buttons, and error handling.

Sequence Diagram(s)

sequenceDiagram
    actor Admin as Admin User
    participant FE as Frontend<br/>(Moderation Screen)
    participant Convex as Convex Backend
    participant DB as Database

    Admin->>FE: Opens moderation screen
    FE->>Convex: isCurrentUserAdmin()
    Convex->>DB: Fetch user record
    DB-->>Convex: User data with isAdmin flag
    Convex-->>FE: Admin status confirmed

    FE->>Convex: getStats()
    Convex->>DB: Query reports & hidden content counts
    DB-->>Convex: Aggregated stats
    Convex-->>FE: Display stats cards

    FE->>Convex: getReports(status, targetType, limit)
    Convex->>DB: Query reports with filters
    DB-->>Convex: Report records
    Convex->>DB: Fetch target & reporter details
    DB-->>Convex: Enriched data
    Convex-->>FE: Render report list

    Admin->>FE: Clicks action (Dismiss/Review/Hide/Unhide)
    FE->>Convex: resolveReport() or hideContent()
    Convex->>DB: Update report status & target visibility
    DB-->>Convex: Confirm update
    Convex-->>FE: Success response
    FE->>Admin: Show result & refresh list
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • SamanSP1386

Poem

🐰 A warren of reports finds order anew,
Admin guards the gates with permission true,
Hidden content surfaces under watchful eyes,
Moderation workflows dance and rise! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically summarizes the main change: adding an admin moderation view for reports, matching the core functionality introduced across backend and frontend.
Description check ✅ Passed The description covers all major sections: linked issue (Linear POLY-60), clear summary, detailed backend/frontend changes, and instructions for testing. The template's testing steps and checklist sections are not included.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/POLY-60-admin-moderation-view

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

❤️ Share

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

…moderation-view

# Conflicts:
#	frontend/app/_layout.tsx
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

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

⚠️ Outside diff range comments (1)
backend/convex/schema.ts (1)

122-130: ⚠️ Potential issue | 🟡 Minor

Add an index on status (and status + createdAt) for the moderation queue.

admin.getReports uses .take(500) then filters by status in memory (lines 32–45), which silently drops older reports once the table exceeds 500 rows. admin.getStats loads the entire reports table with .collect() then filters by status in memory, which is inefficient. A compound index on ['status', 'createdAt'] enables both queries to use range reads keyed by status, fixing the bug and eliminating the performance cost.

🔎 Proposed index
     .index('by_target', ['targetId', 'targetType'])
-    .index('by_reporter', ['reporterId']),
+    .index('by_reporter', ['reporterId'])
+    .index('by_status_createdAt', ['status', 'createdAt']),

Undefined status values (missing optional fields) will sort first in the index, which aligns with the existing "null treated as pending" pattern in the codebase.

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

In `@backend/convex/schema.ts` around lines 122 - 130, Add indexing for the
moderation status so queries can read by status instead of filtering in-memory:
update the reports table schema (the object containing the status:
v.optional(...) and createdAt: v.number()) to include an index on 'status' and a
compound index on ['status', 'createdAt'] (in addition to the existing
.index('by_target', ...) and .index('by_reporter', ...)). This will let
admin.getReports and admin.getStats perform range reads keyed by status and
createdAt rather than loading/ filtering results in memory.
🧹 Nitpick comments (6)
backend/convex/admin.ts (4)

275-314: Resolve pending reports in parallel.

The for loop serializes the patches. Since each patch is independent and the hide step happens after, a Promise.all over the pending subset will cut latency for targets with many reports without changing semantics.

♻️ Suggested refactor
-    // Update all pending reports for this target
-    for (const report of reports) {
-      if ((report.status ?? 'pending') === 'pending') {
-        await ctx.db.patch(report._id, {
-          status: args.resolution,
-          reviewedBy: adminId,
-          reviewedAt: Date.now(),
-        });
-      }
-    }
+    const now = Date.now();
+    await Promise.all(
+      reports
+        .filter((r) => (r.status ?? 'pending') === 'pending')
+        .map((r) =>
+          ctx.db.patch(r._id, {
+            status: args.resolution,
+            reviewedBy: adminId,
+            reviewedAt: now,
+          })
+        )
+    );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/convex/admin.ts` around lines 275 - 314, The current loop serially
patches each pending report (reports array); instead, filter the pending reports
(where (report.status ?? 'pending') === 'pending') and run ctx.db.patch(...) for
each in parallel using Promise.all, then await that Promise.all before
proceeding to the hide logic; update references: use the existing reports
variable, ctx.db.patch(report._id, {..., reviewedBy: adminId, reviewedAt:
Date.now()}), and ensure the hideTarget block (args.hideTarget / args.targetType
/ ctx.db.get / ctx.db.patch for listings/profiles) runs only after the
Promise.all resolves.

139-143: Duplicate reporter lookup for the primary report.

The primary report's reporter profile is already fetched inside the allTargetReports loop on lines 125–137 (the primary report is one of those rows). You can drop the extra query and look it up from enrichedReports instead.

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

In `@backend/convex/admin.ts` around lines 139 - 143, Remove the duplicate DB
query that fetches reporterProfile for the primary report and instead reuse the
already-enriched value from enrichedReports produced in the allTargetReports
loop; locate the block where reporterProfile is fetched (the const
reporterProfile = await ctx.db.query('profiles')... that uses report.reporterId)
and replace it by finding the matching entry in enrichedReports (match on
report.reporterId or report.id) and use that reporter object; ensure any
downstream references still expect the same shape as the enrichedReports
reporter and remove the now-unused DB query logic.

161-193: getStats does full-table scans on three tables.

ctx.db.query('reports').collect() plus .filter(q.eq(q.field('isHidden'), true)).collect() on both listings and profiles load every row on each dashboard render. This gets expensive fast and contributes directly to Convex function cost. The moderation screen polls this reactively, so every report resolution re-runs the three scans.

Suggestions:

  • Index reports on status (see schema.ts comment) and query each status bucket separately so you read exactly the counted rows.
  • Add a filter index like by_isHidden on listings/profiles (or store a sparse index on hiddenAt) so hidden counts don't scan the full catalogs.
  • Alternatively, maintain a small moderationCounters table incremented/decremented inside the mutations.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/convex/admin.ts` around lines 161 - 193, getStats currently does
full-table scans by calling ctx.db.query('reports').collect() and collecting all
rows from listings and profiles; change it to use indexed queries or counters:
query reports by status using the reports.status index (e.g.,
query('reports').filter(q => q.eq(q.field('status'), 'pending')).collect() for
each status) or, alternatively, add/use a by_isHidden index on listings and
profiles (as suggested in schema.ts) to query only hidden rows rather than
filtering the whole table; another option is to create a moderationCounters
table and update its counts inside the report/listing/profile mutation handlers
so getStats simply reads those counters. Ensure you update the corresponding
mutation functions that change report status or hide/unhide listings/profiles to
maintain the counters if you choose the moderationCounters approach.

321-382: Use v.id() validation instead of v.string() + unsafe casts for targetId.

hideContent and unhideContent accept targetId: v.string() but immediately cast to Id<'listings' | 'profiles'>. This bypasses Convex's ID format validation, relying instead on runtime behavior with malformed IDs that is unclear and unsafe. Use v.union(v.id('listings'), v.id('profiles')) instead, which validates the ID format at the boundary before the handler runs, preventing malformed strings from reaching your code and eliminating the unsafe as casts.

The same applies to resolveAllForTarget (line 267). Note that while resolveAllForTarget handles the case slightly better with if (listing && ...) checks at lines 296-297, the pattern should still be fixed for consistency and safety.

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

In `@backend/convex/admin.ts` around lines 321 - 382, Replace the unsafe
v.string() + "as Id<...>" pattern in hideContent, unhideContent, and
resolveAllForTarget by validating targetId at the boundary with
v.union(v.id('listings'), v.id('profiles')) (or appropriate v.id(...) union that
matches the targetType design), remove the runtime "as Id<...>" casts and use
args.targetId directly as a typed Id, and adjust the handler logic to rely on
the validated ID (keeping the existing existence checks like listing/profile
null checks). Update the args schema for each function (hideContent,
unhideContent, resolveAllForTarget) to use the v.id union and ensure any
conditional branches continue to check the fetched record before patching.
frontend/app/(tabs)/settings.tsx (1)

70-70: Skip the admin query on web for consistency.

Every other useQuery here uses isAuthenticated && !isWeb ? {} : 'skip' because the web branch returns an OpenInAppPrompt early (lines 82–93). The isAdmin query fires unconditionally on web, which is wasted work and inconsistent with the surrounding pattern.

♻️ Proposed change
-  const isAdmin = useQuery(api.admin.isCurrentUserAdmin, isAuthenticated ? {} : 'skip');
+  const isAdmin = useQuery(
+    api.admin.isCurrentUserAdmin,
+    isAuthenticated && !isWeb ? {} : 'skip'
+  );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/`(tabs)/settings.tsx at line 70, The isAdmin query is being
executed on web even when the component returns the OpenInAppPrompt early;
modify the useQuery call for isAdmin (api.admin.isCurrentUserAdmin) to skip when
not applicable by changing its enabled condition to match the others: use
isAuthenticated && !isWeb ? {} : 'skip' (or the equivalent gating expression) so
the query only runs when the user is authenticated and not on web.
frontend/app/admin/moderation.tsx (1)

69-95: actionLoading keying is inconsistent across handlers.

handleResolve sets actionLoading to reportId, but handleUnhide sets it to targetId. The unhide button’s disabled check (lines 306, 329) accordingly compares against report.targetId, so if the same target has multiple reports visible, clicking unhide on one disables all unhide buttons for that target — and it also lets the user tap a Dismiss/Mark Reviewed on a sibling report during an unhide because those still compare to report._id.

Consider standardizing the key (e.g., always report._id, or a composite like `${report._id}:unhide`) so the disabled state correlates with the button actually in flight.

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

In `@frontend/app/admin/moderation.tsx` around lines 69 - 95, actionLoading is
keyed inconsistently: handleResolve sets actionLoading to reportId while
handleUnhide sets it to targetId, causing disabled checks (which compare against
report._id or report.targetId) to misbehave; standardize the key so each
button's loading state is unique and matches the disabled checks — e.g., change
handleUnhide to setActionLoading(`${targetId}:unhide`) (or change handleResolve
to `${reportId}:resolve`) and update the corresponding disabled checks to
compare against the same composite string, ensuring resolveReport/unhideContent
callers and setActionLoading/reset (in finally) all use the same key format so
only the intended button is disabled while its request is in flight.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@backend/convex/admin.ts`:
- Around line 26-48: The handler in admin.ts uses
ctx.db.query('reports').order('desc').take(500') and then does in-memory
filtering which causes silent data loss when >500 rows; add a DB index in
schema.ts (e.g., .index('by_status_createdAt', ['status','createdAt'])) and
change the query in the handler to use .withIndex('by_status_createdAt') and
query by status (so the DB returns correct filtered rows), then apply targetType
filtering in-memory if necessary or switch to Convex cursor pagination
(.paginate(paginationOpts)) to support proper paging; ensure requireAdmin and
the limit logic remain, but remove the fixed take(500) to rely on indexed
query/pagination.

In `@frontend/app/admin/moderation.tsx`:
- Around line 211-237: The type badge rendering incorrectly maps any
non-'listing' type to "Profile"; update the render logic in the moderation
component that uses report.targetType (the JSX around styles.typeBadge and the
Text showing 'Listing'/'Profile') to display the actual report.targetType value
(e.g., map 'listing'->'Listing', 'profile'->'Profile',
'conversation'->'Conversation', 'message'->'Message') or fall back to a
capitalized report.targetType string if unmapped; ensure the badge text and any
style selection (currently the ternary for styles.typeBadgeListing vs
styles.typeBadgeProfile) use a small mapping function or switch so
conversation/message are labeled correctly and style fallbacks remain sensible.

---

Outside diff comments:
In `@backend/convex/schema.ts`:
- Around line 122-130: Add indexing for the moderation status so queries can
read by status instead of filtering in-memory: update the reports table schema
(the object containing the status: v.optional(...) and createdAt: v.number()) to
include an index on 'status' and a compound index on ['status', 'createdAt'] (in
addition to the existing .index('by_target', ...) and .index('by_reporter',
...)). This will let admin.getReports and admin.getStats perform range reads
keyed by status and createdAt rather than loading/ filtering results in memory.

---

Nitpick comments:
In `@backend/convex/admin.ts`:
- Around line 275-314: The current loop serially patches each pending report
(reports array); instead, filter the pending reports (where (report.status ??
'pending') === 'pending') and run ctx.db.patch(...) for each in parallel using
Promise.all, then await that Promise.all before proceeding to the hide logic;
update references: use the existing reports variable, ctx.db.patch(report._id,
{..., reviewedBy: adminId, reviewedAt: Date.now()}), and ensure the hideTarget
block (args.hideTarget / args.targetType / ctx.db.get / ctx.db.patch for
listings/profiles) runs only after the Promise.all resolves.
- Around line 139-143: Remove the duplicate DB query that fetches
reporterProfile for the primary report and instead reuse the already-enriched
value from enrichedReports produced in the allTargetReports loop; locate the
block where reporterProfile is fetched (the const reporterProfile = await
ctx.db.query('profiles')... that uses report.reporterId) and replace it by
finding the matching entry in enrichedReports (match on report.reporterId or
report.id) and use that reporter object; ensure any downstream references still
expect the same shape as the enrichedReports reporter and remove the now-unused
DB query logic.
- Around line 161-193: getStats currently does full-table scans by calling
ctx.db.query('reports').collect() and collecting all rows from listings and
profiles; change it to use indexed queries or counters: query reports by status
using the reports.status index (e.g., query('reports').filter(q =>
q.eq(q.field('status'), 'pending')).collect() for each status) or,
alternatively, add/use a by_isHidden index on listings and profiles (as
suggested in schema.ts) to query only hidden rows rather than filtering the
whole table; another option is to create a moderationCounters table and update
its counts inside the report/listing/profile mutation handlers so getStats
simply reads those counters. Ensure you update the corresponding mutation
functions that change report status or hide/unhide listings/profiles to maintain
the counters if you choose the moderationCounters approach.
- Around line 321-382: Replace the unsafe v.string() + "as Id<...>" pattern in
hideContent, unhideContent, and resolveAllForTarget by validating targetId at
the boundary with v.union(v.id('listings'), v.id('profiles')) (or appropriate
v.id(...) union that matches the targetType design), remove the runtime "as
Id<...>" casts and use args.targetId directly as a typed Id, and adjust the
handler logic to rely on the validated ID (keeping the existing existence checks
like listing/profile null checks). Update the args schema for each function
(hideContent, unhideContent, resolveAllForTarget) to use the v.id union and
ensure any conditional branches continue to check the fetched record before
patching.

In `@frontend/app/`(tabs)/settings.tsx:
- Line 70: The isAdmin query is being executed on web even when the component
returns the OpenInAppPrompt early; modify the useQuery call for isAdmin
(api.admin.isCurrentUserAdmin) to skip when not applicable by changing its
enabled condition to match the others: use isAuthenticated && !isWeb ? {} :
'skip' (or the equivalent gating expression) so the query only runs when the
user is authenticated and not on web.

In `@frontend/app/admin/moderation.tsx`:
- Around line 69-95: actionLoading is keyed inconsistently: handleResolve sets
actionLoading to reportId while handleUnhide sets it to targetId, causing
disabled checks (which compare against report._id or report.targetId) to
misbehave; standardize the key so each button's loading state is unique and
matches the disabled checks — e.g., change handleUnhide to
setActionLoading(`${targetId}:unhide`) (or change handleResolve to
`${reportId}:resolve`) and update the corresponding disabled checks to compare
against the same composite string, ensuring resolveReport/unhideContent callers
and setActionLoading/reset (in finally) all use the same key format so only the
intended button is disabled while its request is in flight.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 95ba089f-a4bc-4798-8d2f-a2e8a272b048

📥 Commits

Reviewing files that changed from the base of the PR and between a4b8459 and 99f41b1.

⛔ Files ignored due to path filters (1)
  • backend/convex/_generated/api.d.ts is excluded by !**/_generated/**
📒 Files selected for processing (6)
  • backend/convex/admin.ts
  • backend/convex/lib/authIdentity.ts
  • backend/convex/schema.ts
  • frontend/app/(tabs)/settings.tsx
  • frontend/app/_layout.tsx
  • frontend/app/admin/moderation.tsx

Comment thread backend/convex/admin.ts
Comment on lines +26 to +48
handler: async (ctx, args) => {
await requireAdmin(ctx);

const limit = Math.min(args.limit ?? 50, 100);

// Fetch reports ordered by newest first
let allReports = await ctx.db.query('reports').order('desc').take(500);

// Filter by status (null treated as pending)
if (args.status) {
allReports = allReports.filter((r) => {
const reportStatus = r.status ?? 'pending';
return reportStatus === args.status;
});
}

// Filter by targetType
if (args.targetType) {
allReports = allReports.filter((r) => r.targetType === args.targetType);
}

// Limit results
const reports = allReports.slice(0, limit);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Convex paginate paginationOpts usage with withIndex and order

💡 Result:

In Convex, use paginationOpts with .paginate(paginationOpts) on a database query chain that includes .withIndex and .order for efficient indexed pagination. Example from official docs: export const listWithExtraArg = query({ args: { paginationOpts: paginationOptsValidator, author: v.string }, handler: async (ctx, args) => { return await ctx.db .query("messages") .withIndex("by_author", (q) => q.eq("author", args.author)) .order("desc") .paginate(args.paginationOpts); }, }); Key points: - Import paginationOptsValidator from "convex/server" for args validation. - Chain .withIndex(indexName, filter) to use a predefined index for filtering/sorting. - Follow with .order("asc") or .order("desc") to specify sort direction (matches index order). - Call .paginate(args.paginationOpts) last; paginationOpts includes numItems, cursor, etc., for cursor-based pagination. - Use usePaginatedQuery hook on client for React integration. - Supports additional args beyond paginationOpts. - Post-paginate transformations (map/filter on page) allowed, but preserve continueCursor, isDone, status. This works for cursor-based, gap-less pagination with indexes for performance. For non-indexed queries, prefer indexes to avoid scans.

Citations:


🏁 Script executed:

fd -t f schema.ts | head -5

Repository: codebox-calpoly/PolyBuys

Length of output: 92


🏁 Script executed:

fd -t f admin.ts | head -5

Repository: codebox-calpoly/PolyBuys

Length of output: 91


🏁 Script executed:

# Check if reports table exists in schema and look for status field/index
rg -A 20 "reports.*defineTable\|defineTable.*reports" --type=ts

Repository: codebox-calpoly/PolyBuys

Length of output: 50


🏁 Script executed:

# Look for any existing indexes on the reports table
rg "index.*status|by_status" --type=ts

Repository: codebox-calpoly/PolyBuys

Length of output: 2084


🏁 Script executed:

# Look specifically for reports table definition in schema.ts
rg -A 30 "reports.*:.*defineTable|reports\s*:\s*{" backend/convex/schema.ts

Repository: codebox-calpoly/PolyBuys

Length of output: 209


🏁 Script executed:

# Check lines around the reports table to see all its fields and indexes
cat -n backend/convex/schema.ts | grep -A 40 "reports.*:"

Repository: codebox-calpoly/PolyBuys

Length of output: 1835


🏁 Script executed:

cat -n backend/convex/admin.ts | sed -n '1,80p'

Repository: codebox-calpoly/PolyBuys

Length of output: 3289


🏁 Script executed:

cat -n backend/convex/admin.ts | sed -n '26,60p'

Repository: codebox-calpoly/PolyBuys

Length of output: 1460


take(500) + in-memory filter causes silent data loss when reports exceed 500 and requested status is not in the latest 500.

The code fetches the 500 newest reports, then filters by status and targetType in memory before slicing to limit. If the table exceeds ~500 rows and the newest 500 happen to be reviewed/dismissed, a query for status: 'pending' returns an empty result despite pending reports existing. This is especially problematic for a moderation queue where most reports get resolved.

Additionally, this is not true pagination despite the function's intent; it returns a capped, unsorted result set once the table grows.

Recommended fix:

  1. Add index to reports table in schema.ts:
    .index('by_status_createdAt', ['status', 'createdAt'])
    
  2. Update the query to use the index with .withIndex(), then filter targetType in-memory if needed. Alternatively, implement proper cursor-based pagination with .paginate(paginationOpts) per Convex docs.

The current sketch requires the index to exist first; without schema changes, the fix cannot work as written.

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

In `@backend/convex/admin.ts` around lines 26 - 48, The handler in admin.ts uses
ctx.db.query('reports').order('desc').take(500') and then does in-memory
filtering which causes silent data loss when >500 rows; add a DB index in
schema.ts (e.g., .index('by_status_createdAt', ['status','createdAt'])) and
change the query in the handler to use .withIndex('by_status_createdAt') and
query by status (so the DB returns correct filtered rows), then apply targetType
filtering in-memory if necessary or switch to Convex cursor pagination
(.paginate(paginationOpts)) to support proper paging; ensure requireAdmin and
the limit logic remain, but remove the fixed take(500) to rely on indexed
query/pagination.

Comment on lines +211 to +237
return (
<View key={report._id} style={styles.reportCard}>
<View style={styles.reportHeader}>
<View style={styles.reportMeta}>
<View
style={[
styles.typeBadge,
report.targetType === 'listing'
? styles.typeBadgeListing
: styles.typeBadgeProfile,
]}
>
<Text style={styles.typeBadgeText}>
{report.targetType === 'listing' ? 'Listing' : 'Profile'}
</Text>
</View>
<View style={styles.reasonBadge}>
<Text style={styles.reasonBadgeText}>{report.reason}</Text>
</View>
{report.targetIsHidden && (
<View style={styles.hiddenBadge}>
<Text style={styles.hiddenBadgeText}>Hidden</Text>
</View>
)}
</View>
<Text style={styles.reportDate}>{formatDate(report.createdAt)}</Text>
</View>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Type badge mislabels non-listing/profile reports.

reports.targetType in the schema includes 'conversation' and 'message', and admin.getReports does not filter those out when no targetType arg is passed (i.e. when the user selects the "All" chip). A conversation or message report will fall through the ternary and be labeled "Profile", which is incorrect.

Either fix the rendering to render the actual type, or have the backend exclude/handle the non-moderatable types explicitly.

🩹 Suggested render fix
-                    <View
-                      style={[
-                        styles.typeBadge,
-                        report.targetType === 'listing'
-                          ? styles.typeBadgeListing
-                          : styles.typeBadgeProfile,
-                      ]}
-                    >
-                      <Text style={styles.typeBadgeText}>
-                        {report.targetType === 'listing' ? 'Listing' : 'Profile'}
-                      </Text>
-                    </View>
+                    <View
+                      style={[
+                        styles.typeBadge,
+                        report.targetType === 'listing'
+                          ? styles.typeBadgeListing
+                          : styles.typeBadgeProfile,
+                      ]}
+                    >
+                      <Text style={styles.typeBadgeText}>
+                        {report.targetType.charAt(0).toUpperCase() + report.targetType.slice(1)}
+                      </Text>
+                    </View>
📝 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
return (
<View key={report._id} style={styles.reportCard}>
<View style={styles.reportHeader}>
<View style={styles.reportMeta}>
<View
style={[
styles.typeBadge,
report.targetType === 'listing'
? styles.typeBadgeListing
: styles.typeBadgeProfile,
]}
>
<Text style={styles.typeBadgeText}>
{report.targetType === 'listing' ? 'Listing' : 'Profile'}
</Text>
</View>
<View style={styles.reasonBadge}>
<Text style={styles.reasonBadgeText}>{report.reason}</Text>
</View>
{report.targetIsHidden && (
<View style={styles.hiddenBadge}>
<Text style={styles.hiddenBadgeText}>Hidden</Text>
</View>
)}
</View>
<Text style={styles.reportDate}>{formatDate(report.createdAt)}</Text>
</View>
return (
<View key={report._id} style={styles.reportCard}>
<View style={styles.reportHeader}>
<View style={styles.reportMeta}>
<View
style={[
styles.typeBadge,
report.targetType === 'listing'
? styles.typeBadgeListing
: styles.typeBadgeProfile,
]}
>
<Text style={styles.typeBadgeText}>
{report.targetType.charAt(0).toUpperCase() + report.targetType.slice(1)}
</Text>
</View>
<View style={styles.reasonBadge}>
<Text style={styles.reasonBadgeText}>{report.reason}</Text>
</View>
{report.targetIsHidden && (
<View style={styles.hiddenBadge}>
<Text style={styles.hiddenBadgeText}>Hidden</Text>
</View>
)}
</View>
<Text style={styles.reportDate}>{formatDate(report.createdAt)}</Text>
</View>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/admin/moderation.tsx` around lines 211 - 237, The type badge
rendering incorrectly maps any non-'listing' type to "Profile"; update the
render logic in the moderation component that uses report.targetType (the JSX
around styles.typeBadge and the Text showing 'Listing'/'Profile') to display the
actual report.targetType value (e.g., map 'listing'->'Listing',
'profile'->'Profile', 'conversation'->'Conversation', 'message'->'Message') or
fall back to a capitalized report.targetType string if unmapped; ensure the
badge text and any style selection (currently the ternary for
styles.typeBadgeListing vs styles.typeBadgeProfile) use a small mapping function
or switch so conversation/message are labeled correctly and style fallbacks
remain sensible.

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.

2 participants