Skip to content

feat: add admin auto-reject for review-risk reports#418

Merged
Producdevity merged 4 commits into
stagingfrom
bulk-risk-reject
Jun 3, 2026
Merged

feat: add admin auto-reject for review-risk reports#418
Producdevity merged 4 commits into
stagingfrom
bulk-risk-reject

Conversation

@Producdevity
Copy link
Copy Markdown
Owner

@Producdevity Producdevity commented Jun 3, 2026

Description

Adds an admin-only bulk action to auto-reject pending handheld and PC compatibility reports that match review-risk rejection rules.

The auto-reject rule is:

  • high author risk
  • high submission risk only when the author has at least one author-risk signal

The rejection workflow now lives in the service layer for both handheld and PC reports. It generates per-report processed notes, guards against status races before updating, and keeps trust/notification side effects from failing the endpoint after reports have already been rejected.

No linked issue.

Type of change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update
  • Refactor
  • Other (please describe):

How Has This Been Tested?

  • Local build
  • Lint
  • Typecheck
  • Unit tests
  • Manual testing

Ran:

  • pnpm types
  • pnpm lint
  • pnpm test

Screenshots (if applicable)

N/A

Checklist

  • My code follows the style guidelines of this project
  • I have performed a self-review of my code
  • Documentation changes are not needed for this change
  • I have checked that all checks (lint, typecheck, test) pass

Notes for reviewers

The button only appears for admins while viewing the review-risk queue. The confirmation dialog shows the matching count and scope before running the bulk rejection.

Summary by CodeRabbit

  • New Features

    • Admin auto-reject workflow for risky listings (handheld + PC) with preview counts, dynamic rule details, confirmation, and success/error toasts.
    • Confirmation dialog supports custom button variants and detailed breakdowns.
  • Bug Fixes

    • Improved confirmation dismissal handling and reliable promise resolution.
  • Tests

    • Added unit and integration tests covering the auto-reject UI, dialogs, routers, and services.

@vercel
Copy link
Copy Markdown

vercel Bot commented Jun 3, 2026

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

Project Deployment Actions Updated (UTC)
emuready Ready Ready Preview, Comment Jun 3, 2026 7:19pm

Request Review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 3, 2026

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: f152debe-c9a3-4d34-bcb1-4da4ce8b8991

📥 Commits

Reviewing files that changed from the base of the PR and between ab085f9 and 1c016f2.

📒 Files selected for processing (4)
  • src/server/api/routers/pcListings.test.ts
  • src/server/api/routers/pcListings.ts
  • src/server/services/review-risk.service.test.ts
  • src/server/services/review-risk.service.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/server/services/review-risk.service.test.ts
  • src/server/api/routers/pcListings.ts
  • src/server/services/review-risk.service.ts
  • src/server/api/routers/pcListings.test.ts

Walkthrough

Adds an admin-only bulk auto-reject flow for review-risk-flagged pending listings: candidate selection logic, auto-reject services for handheld and PC listings, admin TRPC procedures, a reusable ReviewRiskAutoRejectPanel with tests, confirmation dialog plumbing, and approvals page integrations.

Changes

Admin Auto-Reject Review-Risk Listings

Layer / File(s) Summary
Review-risk candidate computation
src/server/services/review-risk.service.ts, src/server/services/review-risk.service.test.ts
Exports AutoRejectReviewRiskItem/preview shapes and functions to compute eligible items and preview counts from review-risk candidates and profiles (eligibility predicates and processed-notes formatting).
Auto-reject service implementations
src/server/services/review-risk-auto-reject.service.ts
Implements autoRejectRiskyHandheldReports and autoRejectRiskyPcReports: load eligible items, transactional bulk REJECT updates (rejected vs skipped), per-listing trust actions, notification emission, cache invalidation, and result messages.
API router procedures (admin)
src/server/api/routers/listings/admin.ts, src/server/api/routers/pcListings.ts, src/server/api/routers/listings.ts
Adds admin-only autoRejectRiskyPreview and autoRejectRisky procedures for handheld and PC listings, wiring preview helpers and auto-reject services to TRPC admin routes and exposing client-facing router entries.
Server tests for auto-reject
src/server/api/routers/listings/admin.test.ts, src/server/api/routers/pcListings.test.ts, src/server/services/review-risk.service.test.ts
Adds comprehensive tests covering candidate selection, preview counts, transactional updates, trust-action invocation per rejected listing, resilience when trust/notification emission fails, skipped-count semantics, and admin-only authorization.
ReviewRiskAutoRejectPanel component & tests
src/components/admin/review-risk/ReviewRiskAutoRejectPanel.tsx, src/components/admin/review-risk/ReviewRiskAutoRejectPanel.test.tsx, src/components/admin/review-risk/index.ts
Expands panel props to accept eligible/pending counts, loading/error flags, and submission state; computes badges/labels and conditional danger button text/disabled state; adds tests asserting rendering, disabled states, and onAutoReject callback; re-exports from barrel.
Confirm dialog & AlertDialog updates
src/components/ui/ConfirmDialogProvider.tsx, src/components/ui/ConfirmDialogProvider.test.tsx, src/components/ui/AlertDialog.tsx
Refactors confirm Promise resolver lifecycle, adds optional details rows and confirmVariant styling to ConfirmDialogProvider, updates AlertDialogAction to accept a variant prop, and adds tests for confirm/dismiss behavior.
Handheld approvals page integration
src/app/admin/approvals/page.tsx
Adds preview query and autoRejectRiskyListings mutation (admin-only when risk-only filter active), confirmation handler with dynamic counts/rule details, toast/analytics on success, cache invalidation, and conditional rendering of the auto-reject panel.
PC approvals page integration
src/app/admin/pc-listing-approvals/page.tsx
Same integration for PC listing approvals: preview query, autoRejectRisky mutation, confirmation handler, conditional analytics logging when rejectedCount > 0, and panel rendering when admin & risk-only filter active.

Sequence Diagrams

sequenceDiagram
  participant Admin
  participant Page
  participant API
  participant RiskService
  participant Database
  participant TrustService
  participant Cache
  participant Notifier
  Admin->>Page: Click auto-reject (risk-only)
  Page->>Page: Open confirmation dialog (details from preview)
  Admin->>Page: Confirm
  Page->>API: autoRejectRisky()
  API->>RiskService: getAutoRejectableReviewRiskItemsForCandidates()
  RiskService->>Database: load candidates + compute profiles
  RiskService-->>API: eligible items w/ processedNotes
  API->>Database: transaction update PENDING -> REJECTED (per eligible ids)
  API->>TrustService: applyTrustAction per rejected listing
  API->>Notifier: emit rejection notifications
  API->>Cache: invalidate listing stats / compatibility
  API-->>Page: { success, rejectedCount, skippedCount }
  Page->>Page: show toast, invalidate queries, clear selection
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • Producdevity/EmuReady#353: Introduced review-risk indicator and risk-only filter that this PR builds upon for gating the auto-reject UI and preview.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.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 summarizes the main feature: adding an admin auto-reject capability for review-risk reports.
Description check ✅ Passed The description covers the feature, rejection rules, implementation approach, testing status, and reviewer notes. Only minor checklist items differ from template.
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 bulk-risk-reject
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch bulk-risk-reject

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.

…g UI integration, admin-only permission, and submission/author risk evaluation
Copy link
Copy Markdown

@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: 5

🤖 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 `@src/server/api/routers/listings/admin.ts`:
- Around line 835-957: Extract the multi-step auto-reject workflow from the
router mutation autoRejectRisky into a dedicated service (e.g.,
AutoRejectService) and make the router call that service for orchestration only:
move Prisma operations (the transaction block that queries/updates listing via
tx.listing.*), the logic that builds processedNotesById/listingIds, calls to
getAutoRejectableReviewRiskItemsForCandidates and
ListingsRepository.getPendingListingRiskCandidates, trust actions via
applyTrustAction, notification emission via
notificationEventEmitter.emitNotificationEvent, cache invalidation calls
(listingStatsCache.delete and invalidateCatalogCompatibilityCacheForDevices),
and message construction into methods on the new service; keep the router
mutation to validate admin user and call
AutoRejectService.autoRejectRisky({adminUserId}) and return the service result.
Ensure the service returns the same shape ({ success, rejectedCount,
skippedCount, message }) and surface any errors for the router to handle.
- Around line 895-910: The current Promise.all over rejectedListingsWithAuthor
calls applyTrustAction after the DB commit, but a single rejection will throw
and cause the mutation to fail; change this to use per-item error isolation
(e.g., Promise.allSettled or try/catch per listing) so failures in
applyTrustAction do not propagate and fail the whole endpoint. For each listing
from rejectedListingsWithAuthor (and using processedNotesById, listing.id,
listing.authorId, adminUserId, TrustAction.LISTING_REJECTED, applyTrustAction),
catch/log errors for that specific call and continue processing the others, and
surface aggregated/logged errors separately instead of letting a single
rejection throw.

In `@src/server/api/routers/pcListings.test.ts`:
- Around line 997-1001: The test is using raw status strings in the mock
expectation; replace those literals with the enum constant
ApprovalStatus.PENDING (and similarly for other occurrences at the noted
assertion blocks) so assertions use the ApprovalStatus enum instead of
'PENDING'/'APPROVED' strings; update the expectations against
prisma.pcListing.findMany and other findMany/findUnique mocks to reference
ApprovalStatus.<ENUM_MEMBER> (e.g., ApprovalStatus.PENDING) while keeping the
rest of the matcher intact (preserve LISTING_ID, LISTING_ID_B and the where
shape).

In `@src/server/api/routers/pcListings.ts`:
- Around line 883-985: The autoRejectRisky adminProcedure mutation contains
multi-step business logic and raw Prisma writes (calls to
ctx.prisma.$transaction, PcListingsRepository.getPendingListingRiskCandidates,
processedNotesById, applyTrustAction, notificationEventEmitter,
listingStatsCache) which must be moved into a service; create e.g. a
PcListingsService with a method like autoRejectRiskyListings({ prisma,
currentUserId }) that performs getAutoRejectableReviewRiskItemsForCandidates,
the transaction that updates pcListing status, applies trust actions
(applyTrustAction), emits notifications
(notificationEventEmitter.emitNotificationEvent), clears listingStatsCache, and
returns { pendingListings, skippedCount } and any message data. In the router
mutation autoRejectRisky keep only auth/context extraction and response shaping:
call the new service method, then return the same { success, rejectedCount,
skippedCount, message } shape; update imports and unit tests accordingly and
ensure the service accepts prisma and session user id (or ctx) rather than using
ctx directly.
- Around line 905-925: The current transaction reads PENDING pcListing rows via
tx.pcListing.findMany but then updates by id only (tx.pcListing.update), which
can overwrite concurrent state changes; change the update to require the status
is still ApprovalStatus.PENDING (e.g., update with a compound where that
includes status or use tx.pcListing.updateMany with where: { id: listing.id,
status: ApprovalStatus.PENDING }) and handle the returned count/result to know
if the row was actually updated; update the same fields (status, processedAt,
processedByUserId, processedNotes using processedNotesById) only when the
precondition holds so you avoid races in the $transaction block that reads
pending listings.
🪄 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: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: dcf6b6b0-6687-4495-88d2-3936857c97f9

📥 Commits

Reviewing files that changed from the base of the PR and between dc5391f and 685b0f1.

📒 Files selected for processing (12)
  • src/app/admin/approvals/page.tsx
  • src/app/admin/pc-listing-approvals/page.tsx
  • src/components/admin/review-risk/ReviewRiskAutoRejectPanel.test.tsx
  • src/components/admin/review-risk/ReviewRiskAutoRejectPanel.tsx
  • src/components/admin/review-risk/index.ts
  • src/server/api/routers/listings.ts
  • src/server/api/routers/listings/admin.test.ts
  • src/server/api/routers/listings/admin.ts
  • src/server/api/routers/pcListings.test.ts
  • src/server/api/routers/pcListings.ts
  • src/server/services/review-risk.service.test.ts
  • src/server/services/review-risk.service.ts

Comment thread src/server/api/routers/listings/admin.ts Outdated
Comment on lines +895 to +910
await Promise.all(
rejectedListingsWithAuthor.map((listing) => {
const processedNotes =
processedNotesById.get(listing.id) ?? 'Automatically rejected by review risk.'

return applyTrustAction({
userId: listing.authorId,
action: TrustAction.LISTING_REJECTED,
context: {
listingId: listing.id,
adminUserId,
reason: processedNotes,
},
})
}),
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don’t fail the mutation after commit when trust updates partially fail.

If any applyTrustAction call rejects, this throws after listings are already rejected in the transaction. The caller receives a failure and may retry, creating duplicate side effects.

Suggested hardening
-    await Promise.all(
-      rejectedListingsWithAuthor.map((listing) => {
+    const trustActionResults = await Promise.allSettled(
+      rejectedListingsWithAuthor.map((listing) => {
         const processedNotes =
           processedNotesById.get(listing.id) ?? 'Automatically rejected by review risk.'

         return applyTrustAction({
           userId: listing.authorId,
           action: TrustAction.LISTING_REJECTED,
           context: {
             listingId: listing.id,
             adminUserId,
             reason: processedNotes,
           },
         })
       }),
     )
+    const trustFailures = trustActionResults.filter((r) => r.status === 'rejected')
+    if (trustFailures.length > 0) {
+      console.error('Some trust actions failed during autoRejectRisky', trustFailures)
+    }
📝 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
await Promise.all(
rejectedListingsWithAuthor.map((listing) => {
const processedNotes =
processedNotesById.get(listing.id) ?? 'Automatically rejected by review risk.'
return applyTrustAction({
userId: listing.authorId,
action: TrustAction.LISTING_REJECTED,
context: {
listingId: listing.id,
adminUserId,
reason: processedNotes,
},
})
}),
)
const trustActionResults = await Promise.allSettled(
rejectedListingsWithAuthor.map((listing) => {
const processedNotes =
processedNotesById.get(listing.id) ?? 'Automatically rejected by review risk.'
return applyTrustAction({
userId: listing.authorId,
action: TrustAction.LISTING_REJECTED,
context: {
listingId: listing.id,
adminUserId,
reason: processedNotes,
},
})
}),
)
const trustFailures = trustActionResults.filter((r) => r.status === 'rejected')
if (trustFailures.length > 0) {
console.error('Some trust actions failed during autoRejectRisky', trustFailures)
}
🤖 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 `@src/server/api/routers/listings/admin.ts` around lines 895 - 910, The current
Promise.all over rejectedListingsWithAuthor calls applyTrustAction after the DB
commit, but a single rejection will throw and cause the mutation to fail; change
this to use per-item error isolation (e.g., Promise.allSettled or try/catch per
listing) so failures in applyTrustAction do not propagate and fail the whole
endpoint. For each listing from rejectedListingsWithAuthor (and using
processedNotesById, listing.id, listing.authorId, adminUserId,
TrustAction.LISTING_REJECTED, applyTrustAction), catch/log errors for that
specific call and continue processing the others, and surface aggregated/logged
errors separately instead of letting a single rejection throw.

Comment thread src/server/api/routers/pcListings.test.ts
Comment thread src/server/api/routers/pcListings.ts Outdated
Comment thread src/server/api/routers/pcListings.ts Outdated
Comment on lines +905 to +925
const transactionResult = await ctx.prisma.$transaction(async (tx) => {
const pendingListings = await tx.pcListing.findMany({
where: {
id: { in: pcListingIds },
status: ApprovalStatus.PENDING,
},
select: { id: true, authorId: true },
})

for (const listing of pendingListings) {
await tx.pcListing.update({
where: { id: listing.id },
data: {
status: ApprovalStatus.REJECTED,
processedAt: new Date(),
processedByUserId: ctx.session.user.id,
processedNotes:
processedNotesById.get(listing.id) ?? 'Automatically rejected by review risk.',
},
})
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard against status races in auto-reject updates.

Line 906 reads PENDING rows, but Line 915 updates by id only. A concurrent approval/rejection after the read can still be overwritten to REJECTED.

Suggested fix
-    const transactionResult = await ctx.prisma.$transaction(async (tx) => {
+    const transactionResult = await ctx.prisma.$transaction(async (tx) => {
       const pendingListings = await tx.pcListing.findMany({
         where: {
           id: { in: pcListingIds },
           status: ApprovalStatus.PENDING,
         },
         select: { id: true, authorId: true },
       })

+      const rejectedListings: typeof pendingListings = []
+      let skippedCount = pcListingIds.length - pendingListings.length
+      const processedAt = new Date()
+
       for (const listing of pendingListings) {
-        await tx.pcListing.update({
-          where: { id: listing.id },
+        const { count } = await tx.pcListing.updateMany({
+          where: { id: listing.id, status: ApprovalStatus.PENDING },
           data: {
             status: ApprovalStatus.REJECTED,
-            processedAt: new Date(),
+            processedAt,
             processedByUserId: ctx.session.user.id,
             processedNotes:
               processedNotesById.get(listing.id) ?? 'Automatically rejected by review risk.',
           },
         })
+        if (count === 1) rejectedListings.push(listing)
+        else skippedCount += 1
       }

       return {
-        pendingListings,
-        skippedCount: pcListingIds.length - pendingListings.length,
+        pendingListings: rejectedListings,
+        skippedCount,
       }
     })
🤖 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 `@src/server/api/routers/pcListings.ts` around lines 905 - 925, The current
transaction reads PENDING pcListing rows via tx.pcListing.findMany but then
updates by id only (tx.pcListing.update), which can overwrite concurrent state
changes; change the update to require the status is still ApprovalStatus.PENDING
(e.g., update with a compound where that includes status or use
tx.pcListing.updateMany with where: { id: listing.id, status:
ApprovalStatus.PENDING }) and handle the returned count/result to know if the
row was actually updated; update the same fields (status, processedAt,
processedByUserId, processedNotes using processedNotesById) only when the
precondition holds so you avoid races in the $transaction block that reads
pending listings.

@Producdevity Producdevity changed the title feat: add auto-reject functionality for review-risk reports, includin… feat: add admin auto-reject for review-risk reports Jun 3, 2026
…uding updates to handling and notification logic
Copy link
Copy Markdown

@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

🤖 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 `@src/server/api/routers/pcListings.ts`:
- Around line 893-897: The mutation autoRejectRisky (adminProcedure.mutation)
currently calls autoRejectRiskyPcReports without verifying the admin user
exists; add a guard that queries ctx.prisma.user.findUnique for
ctx.session.user.id and throw or return an appropriate error if not found before
calling autoRejectRiskyPcReports so you never pass a deleted/stale ID into
processedByUserId/notification or trust writes. Ensure the existence check
occurs in the same resolver (autoRejectRisky) and only forward ctx.prisma and
adminUserId to autoRejectRiskyPcReports after the user is confirmed.

In `@src/server/services/review-risk.service.ts`:
- Around line 100-113: hasHighSubmissionRisk currently returns true solely when
profile.highestSeverity === 'high', allowing malformed profiles with no actual
signals to count as auto-rejectable; update the logic so that
hasHighSubmissionRisk also ensures the profile contains real submission signals
(e.g., profile.signals exists and has length > 0) before returning true, and
keep isAutoRejectableReviewRisk's combination with hasAuthorRiskSignals
unchanged so the submission-side path only auto-rejects when there are both a
'high' severity and actual submission signals present.
🪄 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: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: b746f559-aa64-49db-8639-922fe99d25f3

📥 Commits

Reviewing files that changed from the base of the PR and between 685b0f1 and ab085f9.

📒 Files selected for processing (16)
  • src/app/admin/approvals/page.tsx
  • src/app/admin/pc-listing-approvals/page.tsx
  • src/components/admin/review-risk/ReviewRiskAutoRejectPanel.test.tsx
  • src/components/admin/review-risk/ReviewRiskAutoRejectPanel.tsx
  • src/components/admin/review-risk/index.ts
  • src/components/ui/AlertDialog.tsx
  • src/components/ui/ConfirmDialogProvider.test.tsx
  • src/components/ui/ConfirmDialogProvider.tsx
  • src/server/api/routers/listings.ts
  • src/server/api/routers/listings/admin.test.ts
  • src/server/api/routers/listings/admin.ts
  • src/server/api/routers/pcListings.test.ts
  • src/server/api/routers/pcListings.ts
  • src/server/services/review-risk-auto-reject.service.ts
  • src/server/services/review-risk.service.test.ts
  • src/server/services/review-risk.service.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/components/admin/review-risk/index.ts
  • src/server/api/routers/listings/admin.test.ts
  • src/app/admin/approvals/page.tsx

Comment thread src/server/api/routers/pcListings.ts
Comment thread src/server/services/review-risk.service.ts
@Producdevity Producdevity merged commit b209a5d into staging Jun 3, 2026
8 checks passed
@Producdevity Producdevity deleted the bulk-risk-reject branch June 3, 2026 20:23
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