[POLY-60] Build admin moderation view for reports#103
[POLY-60] Build admin moderation view for reports#103cole-hackman wants to merge 3 commits intodevfrom
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughThis 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
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
…moderation-view # Conflicts: # frontend/app/_layout.tsx
There was a problem hiding this comment.
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 | 🟡 MinorAdd an index on
status(andstatus + createdAt) for the moderation queue.
admin.getReportsuses.take(500)then filters by status in memory (lines 32–45), which silently drops older reports once the table exceeds 500 rows.admin.getStatsloads 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
statusvalues (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
forloop serializes the patches. Since each patch is independent and the hide step happens after, aPromise.allover 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
allTargetReportsloop on lines 125–137 (the primary report is one of those rows). You can drop the extra query and look it up fromenrichedReportsinstead.🤖 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:getStatsdoes full-table scans on three tables.
ctx.db.query('reports').collect()plus.filter(q.eq(q.field('isHidden'), true)).collect()on bothlistingsandprofilesload 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
reportsonstatus(see schema.ts comment) and query each status bucket separately so you read exactly the counted rows.- Add a filter index like
by_isHiddenonlistings/profiles(or store a sparse index onhiddenAt) so hidden counts don't scan the full catalogs.- Alternatively, maintain a small
moderationCounterstable 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: Usev.id()validation instead ofv.string()+ unsafe casts for targetId.
hideContentandunhideContentaccepttargetId: v.string()but immediately cast toId<'listings' | 'profiles'>. This bypasses Convex's ID format validation, relying instead on runtime behavior with malformed IDs that is unclear and unsafe. Usev.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 unsafeascasts.The same applies to
resolveAllForTarget(line 267). Note that whileresolveAllForTargethandles the case slightly better withif (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
useQueryhere usesisAuthenticated && !isWeb ? {} : 'skip'because the web branch returns anOpenInAppPromptearly (lines 82–93). TheisAdminquery 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:actionLoadingkeying is inconsistent across handlers.
handleResolvesetsactionLoadingtoreportId, buthandleUnhidesets it totargetId. The unhide button’sdisabledcheck (lines 306, 329) accordingly compares againstreport.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 aDismiss/Mark Reviewedon a sibling report during an unhide because those still compare toreport._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
⛔ Files ignored due to path filters (1)
backend/convex/_generated/api.d.tsis excluded by!**/_generated/**
📒 Files selected for processing (6)
backend/convex/admin.tsbackend/convex/lib/authIdentity.tsbackend/convex/schema.tsfrontend/app/(tabs)/settings.tsxfrontend/app/_layout.tsxfrontend/app/admin/moderation.tsx
| 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); |
There was a problem hiding this comment.
🧩 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:
- 1: https://docs.convex.dev/database/pagination
- 2: https://docs.convex.dev/using/pagination
- 3: https://docs.convex.dev/database/pagination.md
- 4: https://docs.convex.dev/api/interfaces/server.QueryInitializer
🏁 Script executed:
fd -t f schema.ts | head -5Repository: codebox-calpoly/PolyBuys
Length of output: 92
🏁 Script executed:
fd -t f admin.ts | head -5Repository: 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=tsRepository: codebox-calpoly/PolyBuys
Length of output: 50
🏁 Script executed:
# Look for any existing indexes on the reports table
rg "index.*status|by_status" --type=tsRepository: 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.tsRepository: 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:
- Add index to reports table in schema.ts:
.index('by_status_createdAt', ['status', 'createdAt']) - Update the query to use the index with
.withIndex(), then filtertargetTypein-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.
| 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> |
There was a problem hiding this comment.
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.
| 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.
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
isAdmin(optional boolean) to users tablestatus('pending' | 'reviewed' | 'dismissed'),reviewedBy, andreviewedAtfields to reports tableBackend auth (
backend/convex/lib/authIdentity.ts)requireAdmin()helper that checksisAdmin === trueon the user recordBackend admin module (
backend/convex/admin.ts)getReports— paginated report queue with status/type filters, enriched with target and reporter contextgetReportDetail— single report with full target data and all reports for that targetgetStats— dashboard stats (pending, reviewed, dismissed, hidden counts)isCurrentUserAdmin— check if current user has admin accessresolveReport— mark a report as reviewed/dismissed, optionally hide targetresolveAllForTarget— bulk resolve all reports for a targethideContent/unhideContent— manually hide or unhide listings/profilesFrontend admin page (
frontend/app/admin/moderation.tsx)Frontend navigation
_layout.tsxHow to make a user admin
Run from the backend directory: