-
Notifications
You must be signed in to change notification settings - Fork 0
[POLY-60] Build admin moderation view for reports #103
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
cole-hackman
wants to merge
3
commits into
dev
Choose a base branch
from
feature/POLY-60-admin-moderation-view
base: dev
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,382 @@ | ||
| import { v, ConvexError } from 'convex/values'; | ||
| import { query, mutation } from './_generated/server'; | ||
| import type { Id } from './_generated/dataModel'; | ||
| import { requireAdmin } from './lib/authIdentity'; | ||
|
|
||
| /** | ||
| * Admin moderation queries and mutations. | ||
| * All functions require the caller to have isAdmin === true on their user record. | ||
| */ | ||
|
|
||
| // --- Queries --- | ||
|
|
||
| /** | ||
| * Get paginated reports for the admin moderation queue. | ||
| * Supports filtering by status and targetType. | ||
| * Null status on a report is treated as 'pending'. | ||
| */ | ||
| export const getReports = query({ | ||
| args: { | ||
| status: v.optional( | ||
| v.union(v.literal('pending'), v.literal('reviewed'), v.literal('dismissed')) | ||
| ), | ||
| targetType: v.optional(v.union(v.literal('listing'), v.literal('profile'))), | ||
| limit: v.optional(v.number()), | ||
| }, | ||
| 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); | ||
|
|
||
| // Enrich with target and reporter context | ||
| const enriched = await Promise.all( | ||
| reports.map(async (report) => { | ||
| let targetTitle: string | null = null; | ||
| let targetImage: string | null = null; | ||
| let targetIsHidden = false; | ||
|
|
||
| if (report.targetType === 'listing') { | ||
| const listing = await ctx.db.get(report.targetId as Id<'listings'>).catch(() => null); | ||
| if (listing) { | ||
| targetTitle = listing.title; | ||
| targetImage = listing.images?.[0] ?? null; | ||
| targetIsHidden = listing.isHidden === true; | ||
| } | ||
| } else if (report.targetType === 'profile') { | ||
| const profile = await ctx.db.get(report.targetId as Id<'profiles'>).catch(() => null); | ||
| if (profile) { | ||
| targetTitle = profile.name; | ||
| targetIsHidden = profile.isHidden === true; | ||
| } | ||
| } | ||
|
|
||
| // Get reporter profile name | ||
| const reporterProfile = await ctx.db | ||
| .query('profiles') | ||
| .withIndex('by_userId', (q) => q.eq('userId', report.reporterId)) | ||
| .first(); | ||
|
|
||
| return { | ||
| ...report, | ||
| status: report.status ?? 'pending', | ||
| targetTitle, | ||
| targetImage, | ||
| targetIsHidden, | ||
| reporterName: reporterProfile?.name ?? 'Unknown user', | ||
| }; | ||
| }) | ||
| ); | ||
|
|
||
| return enriched; | ||
| }, | ||
| }); | ||
|
|
||
| /** | ||
| * Get detailed view of a single report with full target context and all reports for that target. | ||
| */ | ||
| export const getReportDetail = query({ | ||
| args: { reportId: v.id('reports') }, | ||
| handler: async (ctx, args) => { | ||
| await requireAdmin(ctx); | ||
|
|
||
| const report = await ctx.db.get(args.reportId); | ||
| if (!report) { | ||
| throw new ConvexError('Report not found'); | ||
| } | ||
|
|
||
| // Get full target data | ||
| let target: Record<string, unknown> | null = null; | ||
| if (report.targetType === 'listing') { | ||
| const listing = await ctx.db.get(report.targetId as Id<'listings'>).catch(() => null); | ||
| target = listing ? { ...listing } : null; | ||
| } else if (report.targetType === 'profile') { | ||
| const profile = await ctx.db.get(report.targetId as Id<'profiles'>).catch(() => null); | ||
| target = profile ? { ...profile } : null; | ||
| } | ||
|
|
||
| // Get all reports for this target | ||
| const allTargetReports = await ctx.db | ||
| .query('reports') | ||
| .withIndex('by_target', (q) => | ||
| q.eq('targetId', report.targetId).eq('targetType', report.targetType) | ||
| ) | ||
| .collect(); | ||
|
|
||
| // Enrich each report with reporter name | ||
| const enrichedReports = await Promise.all( | ||
| allTargetReports.map(async (r) => { | ||
| const reporterProfile = await ctx.db | ||
| .query('profiles') | ||
| .withIndex('by_userId', (q) => q.eq('userId', r.reporterId)) | ||
| .first(); | ||
| return { | ||
| ...r, | ||
| status: r.status ?? 'pending', | ||
| reporterName: reporterProfile?.name ?? 'Unknown user', | ||
| }; | ||
| }) | ||
| ); | ||
|
|
||
| // Get reporter profile for the primary report | ||
| const reporterProfile = await ctx.db | ||
| .query('profiles') | ||
| .withIndex('by_userId', (q) => q.eq('userId', report.reporterId)) | ||
| .first(); | ||
|
|
||
| return { | ||
| report: { | ||
| ...report, | ||
| status: report.status ?? 'pending', | ||
| reporterName: reporterProfile?.name ?? 'Unknown user', | ||
| }, | ||
| target, | ||
| allReportsForTarget: enrichedReports, | ||
| uniqueReporterCount: new Set(allTargetReports.map((r) => r.reporterId)).size, | ||
| }; | ||
| }, | ||
| }); | ||
|
|
||
| /** | ||
| * Get summary stats for the admin dashboard. | ||
| */ | ||
| export const getStats = query({ | ||
| args: {}, | ||
| handler: async (ctx) => { | ||
| await requireAdmin(ctx); | ||
|
|
||
| const allReports = await ctx.db.query('reports').collect(); | ||
|
|
||
| const pending = allReports.filter((r) => (r.status ?? 'pending') === 'pending').length; | ||
| const reviewed = allReports.filter((r) => r.status === 'reviewed').length; | ||
| const dismissed = allReports.filter((r) => r.status === 'dismissed').length; | ||
|
|
||
| // Count hidden listings | ||
| const hiddenListings = await ctx.db | ||
| .query('listings') | ||
| .filter((q) => q.eq(q.field('isHidden'), true)) | ||
| .collect(); | ||
|
|
||
| // Count hidden profiles | ||
| const hiddenProfiles = await ctx.db | ||
| .query('profiles') | ||
| .filter((q) => q.eq(q.field('isHidden'), true)) | ||
| .collect(); | ||
|
|
||
| return { | ||
| pendingReports: pending, | ||
| reviewedReports: reviewed, | ||
| dismissedReports: dismissed, | ||
| totalReports: allReports.length, | ||
| hiddenListings: hiddenListings.length, | ||
| hiddenProfiles: hiddenProfiles.length, | ||
| }; | ||
| }, | ||
| }); | ||
|
|
||
| /** | ||
| * Check if the current user is an admin. | ||
| */ | ||
| export const isCurrentUserAdmin = query({ | ||
| args: {}, | ||
| handler: async (ctx) => { | ||
| try { | ||
| await requireAdmin(ctx); | ||
| return true; | ||
| } catch { | ||
| return false; | ||
| } | ||
| }, | ||
| }); | ||
|
|
||
| // --- Mutations --- | ||
|
|
||
| /** | ||
| * Resolve a report by marking it as reviewed or dismissed. | ||
| * Optionally hides the target content. | ||
| */ | ||
| export const resolveReport = mutation({ | ||
| args: { | ||
| reportId: v.id('reports'), | ||
| resolution: v.union(v.literal('reviewed'), v.literal('dismissed')), | ||
| hideTarget: v.optional(v.boolean()), | ||
| }, | ||
| handler: async (ctx, args) => { | ||
| const adminId = await requireAdmin(ctx); | ||
|
|
||
| const report = await ctx.db.get(args.reportId); | ||
| if (!report) { | ||
| throw new ConvexError('Report not found'); | ||
| } | ||
|
|
||
| // Update report status | ||
| await ctx.db.patch(args.reportId, { | ||
| status: args.resolution, | ||
| reviewedBy: adminId, | ||
| reviewedAt: Date.now(), | ||
| }); | ||
|
|
||
| // Optionally hide the target | ||
| if (args.hideTarget) { | ||
| if (report.targetType === 'listing') { | ||
| const listing = await ctx.db.get(report.targetId as Id<'listings'>); | ||
| if (listing && !listing.isHidden) { | ||
| await ctx.db.patch(report.targetId as Id<'listings'>, { | ||
| isHidden: true, | ||
| hiddenAt: Date.now(), | ||
| hiddenReason: 'admin_action', | ||
| }); | ||
| } | ||
| } else if (report.targetType === 'profile') { | ||
| const profile = await ctx.db.get(report.targetId as Id<'profiles'>); | ||
| if (profile && !profile.isHidden) { | ||
| await ctx.db.patch(report.targetId as Id<'profiles'>, { | ||
| isHidden: true, | ||
| hiddenAt: Date.now(), | ||
| hiddenReason: 'admin_action', | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| }, | ||
| }); | ||
|
|
||
| /** | ||
| * Bulk resolve all reports for a given target. | ||
| */ | ||
| export const resolveAllForTarget = mutation({ | ||
| args: { | ||
| targetId: v.string(), | ||
| targetType: v.union(v.literal('listing'), v.literal('profile')), | ||
| resolution: v.union(v.literal('reviewed'), v.literal('dismissed')), | ||
| hideTarget: v.optional(v.boolean()), | ||
| }, | ||
| handler: async (ctx, args) => { | ||
| const adminId = await requireAdmin(ctx); | ||
|
|
||
| const reports = await ctx.db | ||
| .query('reports') | ||
| .withIndex('by_target', (q) => | ||
| q.eq('targetId', args.targetId).eq('targetType', args.targetType) | ||
| ) | ||
| .collect(); | ||
|
|
||
| // 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(), | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| // Optionally hide the target | ||
| if (args.hideTarget) { | ||
| if (args.targetType === 'listing') { | ||
| const listing = await ctx.db.get(args.targetId as Id<'listings'>); | ||
| if (listing && !listing.isHidden) { | ||
| await ctx.db.patch(args.targetId as Id<'listings'>, { | ||
| isHidden: true, | ||
| hiddenAt: Date.now(), | ||
| hiddenReason: 'admin_action', | ||
| }); | ||
| } | ||
| } else if (args.targetType === 'profile') { | ||
| const profile = await ctx.db.get(args.targetId as Id<'profiles'>); | ||
| if (profile && !profile.isHidden) { | ||
| await ctx.db.patch(args.targetId as Id<'profiles'>, { | ||
| isHidden: true, | ||
| hiddenAt: Date.now(), | ||
| hiddenReason: 'admin_action', | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| }, | ||
| }); | ||
|
|
||
| /** | ||
| * Manually hide a listing or profile. | ||
| */ | ||
| export const hideContent = mutation({ | ||
| args: { | ||
| targetId: v.string(), | ||
| targetType: v.union(v.literal('listing'), v.literal('profile')), | ||
| }, | ||
| handler: async (ctx, args) => { | ||
| await requireAdmin(ctx); | ||
|
|
||
| if (args.targetType === 'listing') { | ||
| const listing = await ctx.db.get(args.targetId as Id<'listings'>); | ||
| if (!listing) throw new ConvexError('Listing not found'); | ||
| if (listing.isHidden) return; // Already hidden | ||
| await ctx.db.patch(args.targetId as Id<'listings'>, { | ||
| isHidden: true, | ||
| hiddenAt: Date.now(), | ||
| hiddenReason: 'admin_action', | ||
| }); | ||
| } else { | ||
| const profile = await ctx.db.get(args.targetId as Id<'profiles'>); | ||
| if (!profile) throw new ConvexError('Profile not found'); | ||
| if (profile.isHidden) return; | ||
| await ctx.db.patch(args.targetId as Id<'profiles'>, { | ||
| isHidden: true, | ||
| hiddenAt: Date.now(), | ||
| hiddenReason: 'admin_action', | ||
| }); | ||
| } | ||
| }, | ||
| }); | ||
|
|
||
| /** | ||
| * Unhide a listing or profile. | ||
| */ | ||
| export const unhideContent = mutation({ | ||
| args: { | ||
| targetId: v.string(), | ||
| targetType: v.union(v.literal('listing'), v.literal('profile')), | ||
| }, | ||
| handler: async (ctx, args) => { | ||
| await requireAdmin(ctx); | ||
|
|
||
| if (args.targetType === 'listing') { | ||
| const listing = await ctx.db.get(args.targetId as Id<'listings'>); | ||
| if (!listing) throw new ConvexError('Listing not found'); | ||
| if (!listing.isHidden) return; | ||
| await ctx.db.patch(args.targetId as Id<'listings'>, { | ||
| isHidden: false, | ||
| hiddenAt: undefined, | ||
| hiddenReason: undefined, | ||
| }); | ||
| } else { | ||
| const profile = await ctx.db.get(args.targetId as Id<'profiles'>); | ||
| if (!profile) throw new ConvexError('Profile not found'); | ||
| if (!profile.isHidden) return; | ||
| await ctx.db.patch(args.targetId as Id<'profiles'>, { | ||
| isHidden: false, | ||
| hiddenAt: undefined, | ||
| hiddenReason: undefined, | ||
| }); | ||
| } | ||
| }, | ||
| }); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 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 -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:
Repository: codebox-calpoly/PolyBuys
Length of output: 50
🏁 Script executed:
Repository: codebox-calpoly/PolyBuys
Length of output: 2084
🏁 Script executed:
Repository: codebox-calpoly/PolyBuys
Length of output: 209
🏁 Script executed:
Repository: codebox-calpoly/PolyBuys
Length of output: 1835
🏁 Script executed:
Repository: codebox-calpoly/PolyBuys
Length of output: 3289
🏁 Script executed:
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
statusandtargetTypein memory before slicing tolimit. If the table exceeds ~500 rows and the newest 500 happen to bereviewed/dismissed, a query forstatus: '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:
.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