UX-17: Hide applied proposals by default, add clear/dismiss action#634
UX-17: Hide applied proposals by default, add clear/dismiss action#634Chris0Jeky merged 6 commits intomainfrom
Conversation
Add Dismissed status to ProposalStatus enum, Dismiss() domain method, DismissProposalsAsync service method, and POST /automation/proposals/dismiss endpoint accepting an array of IDs. Only terminal-state proposals (Applied, Rejected, Failed, Expired) can be dismissed.
Default view now hides Applied, Rejected, Failed, Expired, and Dismissed proposals. A "Show completed" checkbox reveals them. A "Clear applied" button batch-dismisses terminal proposals via the new dismiss endpoint. Summary cards update reactively based on the visible filtered list.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Self-ReviewCorrectness
Edge cases considered
Security
Style
No issues requiring fixes found. |
There was a problem hiding this comment.
Code Review
This pull request implements a feature to dismiss completed automation proposals, preventing them from cluttering the default review list. Key changes include a new batch dismissal endpoint in the API, service-level logic to update proposal statuses, and frontend enhancements providing a "Show completed" toggle and a "Clear applied" action. Review feedback highlights a critical authorization bug in the controller where a hardcoded limit on fetched proposals could lead to false "Forbidden" errors for users with many records. Additionally, improvements are suggested regarding the use of exceptions for control flow in the application service and simplifying redundant filtering logic in the Vue component.
| var proposals = await _proposalService.GetProposalsAsync( | ||
| new ProposalFilterDto(null, null, callerUserId, null, MaxProposalListLimit), | ||
| cancellationToken); | ||
|
|
||
| if (!proposals.IsSuccess) | ||
| return proposals.ToErrorActionResult(); | ||
|
|
||
| var ownedIds = proposals.Value.Select(p => p.Id).ToHashSet(); | ||
| var unauthorizedIds = request.Ids.Where(id => !ownedIds.Contains(id)).ToList(); | ||
| if (unauthorizedIds.Count > 0) | ||
| { | ||
| return Result.Failure(ErrorCodes.Forbidden, "You can only dismiss your own proposals.").ToErrorActionResult(); | ||
| } |
There was a problem hiding this comment.
The authorization logic here is flawed for users with a large number of proposals. By fetching a list of proposals filtered by callerUserId with a limit of MaxProposalListLimit (500), the check will fail for any proposal ID that exists beyond that limit in the database. This results in a Forbidden error for valid proposals owned by the user simply because they aren't among the most recent 500.
Additionally, this check only validates ownership via RequestedByUserId. If the system allows dismissing proposals based on board permissions (consistent with the logic in AuthorizeProposalAsync), this check is too restrictive.
Consider updating the service to handle authorization by verifying the callerUserId against the specific proposals being dismissed, or fetch the specific proposals by the provided IDs in the controller to verify permissions for each.
| try | ||
| { | ||
| proposal.Dismiss(); | ||
| dismissed++; | ||
| } | ||
| catch (DomainException) | ||
| { | ||
| // Skip proposals that cannot be dismissed (e.g. still Pending/Approved) | ||
| } |
There was a problem hiding this comment.
Swallowing all DomainException instances here is a bit broad. While the intent is to skip proposals that aren't in a terminal state, this could inadvertently hide other domain-level validation errors. It is cleaner to check the proposal status explicitly before calling Dismiss() to avoid using exceptions for expected control flow.
if (proposal.Status is ProposalStatus.Applied or ProposalStatus.Rejected or ProposalStatus.Failed or ProposalStatus.Expired)
{
proposal.Dismiss();
dismissed++;
}| if (normalizeProposalStatus(proposal.status) === 'Dismissed') return false | ||
|
|
||
| // When showCompleted is off, hide terminal-state proposals | ||
| if (!showCompleted.value && completedStatuses.has(normalizeProposalStatus(proposal.status))) return false |
There was a problem hiding this comment.
There is a redundancy in the filtering logic. Since Dismissed is already included in the completedStatuses set (line 48), the explicit check on line 82 is only necessary if the intention is to hide dismissed proposals even when showCompleted is toggled on. If the intention is that "Show completed" should show everything except dismissed items, the code is correct but could be simplified by clarifying the set usage.
The test expected 'Applied to board' text to be visible, but with showCompleted defaulting to false, applied proposals are now hidden. Update test to verify hidden-by-default and toggle-to-reveal behavior.
Adversarial Code Review: PR #634 -- UX-17 Dismiss ProposalsCritical1. GetByIdsAsync uses AsNoTracking -- dismissed proposals will never be persisted DismissProposalsAsync in AutomationProposalService calls _unitOfWork.AutomationProposals.GetByIdsAsync(ids, cancellationToken) and then mutates the returned entities with proposal.Dismiss(). However, GetByIdsAsync in AutomationProposalRepository (line 70) uses .AsNoTracking(): return await _dbSet
.AsNoTracking()
.Where(proposal => uniqueIds.Contains(proposal.Id))
.ToListAsync(cancellationToken);EF Core will not track these entities, so when SaveChangesAsync is called, no UPDATE statements are emitted. The endpoint will report success but the status change is silently lost. The next refresh will show the proposals again, undismissed. Fix: Either (a) add a tracked overload (e.g., GetByIdsForUpdateAsync) that omits AsNoTracking, or (b) fetch via GetByIdAsync per entity (which already tracks), or (c) call _dbSet.AttachRange(...) / DbContext.Update(...) before saving. High2. Authorization check has a hard ceiling at 500 -- silent false-Forbidden for prolific users In the controller's DismissProposals action: var proposals = await _proposalService.GetProposalsAsync(
new ProposalFilterDto(null, null, callerUserId, null, MaxProposalListLimit),
cancellationToken);
var ownedIds = proposals.Value.Select(p => p.Id).ToHashSet();
var unauthorizedIds = request.Ids.Where(id => !ownedIds.Contains(id)).ToList();MaxProposalListLimit is 500. If a user has >500 proposals, older proposals will not appear in ownedIds. The user will receive a 403 Forbidden when attempting to dismiss their own valid proposal, simply because it fell outside the top-500 window. Fix: Do not use GetProposalsAsync for authorization. Instead, fetch the specific proposals by ID (via GetByIdsAsync or GetByIdAsync), and verify RequestedByUserId == callerUserId on each entity. This is both correct and more efficient than loading 500 proposals to check a subset. 3. New enum value Dismissed is not in ReviewedStatuses -- future queries may silently exclude dismissed proposals AutomationProposalRepository.ReviewedStatuses (line 11-17) lists Approved, Rejected, Applied, Failed -- but not Dismissed. This means HasReviewedByUserIdAsync treats dismissed proposals correctly (they were previously in a reviewed state, so the original status was already counted). However, any future code that uses ReviewedStatuses as "all terminal states" will silently exclude Dismissed. Fix: Either add Dismissed to ReviewedStatuses, or rename the array to make its semantic clear (e.g., DecisionOutcomeStatuses) so future developers do not assume it means "all non-pending states." Medium4. Broad DomainException catch in DismissProposalsAsync hides bugs foreach (var proposal in proposals)
{
try
{
proposal.Dismiss();
dismissed++;
}
catch (DomainException)
{
// Skip proposals that cannot be dismissed (e.g. still Pending/Approved)
}
}The inner catch swallows all DomainException -- including unexpected ones from Touch() or future domain logic. Since Dismiss() already has an explicit status guard, the caller should pre-filter to dismissable statuses instead of relying on exception flow for normal control. Fix: foreach (var proposal in proposals)
{
if (proposal.Status is not (ProposalStatus.Applied or ProposalStatus.Rejected
or ProposalStatus.Failed or ProposalStatus.Expired))
continue;
proposal.Dismiss();
dismissed++;
}This also avoids using exceptions for expected control flow, which is a performance concern in hot paths. 5. Dismissed is in completedStatuses AND has an explicit hide-always check -- redundant logic In ReviewView.vue: const completedStatuses = new Set(['Applied', 'Rejected', 'Failed', 'Expired', 'Dismissed'])
// ...
if (normalizeProposalStatus(proposal.status) === 'Dismissed') return false
if (!showCompleted.value && completedStatuses.has(normalizeProposalStatus(proposal.status))) return falseThe first check is redundant because the second check already hides Dismissed when showCompleted is off. The only case the first check adds is hiding Dismissed when showCompleted is ON. If that is the intent, it should be documented with a comment. If not, remove the redundant check and take Dismissed out of completedStatuses. Fix: If the intent is "dismissed are always hidden even when Show Completed is on," then remove Dismissed from completedStatuses (it is dead code there) and keep only the explicit check with a comment: // Dismissed proposals are always hidden -- they are permanently cleared
if (normalizeProposalStatus(proposal.status) === 'Dismissed') return false
const completedStatuses = new Set(['Applied', 'Rejected', 'Failed', 'Expired'])
if (!showCompleted.value && completedStatuses.has(normalizeProposalStatus(proposal.status))) return false6. No EF migration for the new enum value ProposalStatus.Dismissed (value 6) is added to the C# enum. If EF Core stores this as an integer column (the default for SQLite), existing rows are fine -- but there is no migration to validate the schema snapshot is up to date. If the column uses string conversion, existing queries filtering by status will need to handle the new value. Fix: Generate an EF migration (dotnet ef migrations add AddDismissedProposalStatus) even if it is empty, to keep the model snapshot in sync. Verify the storage format (int vs. string) and confirm the migration path is clean. Low7. Button label says "Clear applied" but action dismisses all terminal states The button text reads "Clear applied (N)" but it dismisses Applied, Rejected, Failed, AND Expired proposals. This is misleading UX copy. Fix: Rename to "Clear completed" to match the actual behavior. 8. Frontend removes dismissed proposals from local state optimistically const dismissedSet = new Set(ids)
proposals.value = proposals.value.filter((p) => !dismissedSet.has(p.id))This removes ALL requested IDs from the local list regardless of whether the backend actually dismissed them (some may have been skipped due to status). The result.dismissed count could be less than ids.length, but the UI removes all of them. Fix: Either (a) have the backend return the list of actually-dismissed IDs so the frontend can filter accurately, or (b) after dismiss, re-fetch the proposal list from the server. 9. No backend tests for the new Dismiss() domain method or DismissProposalsAsync service method The PR mentions "Backend tests pass (1572/1572)" but there are no new test cases for:
Given the project's testing guidelines ("Behavior changes ship with tests"), this is a gap. VerdictFinding #1 (Critical) is blocking. The dismiss operation silently does nothing due to AsNoTracking. This must be fixed before merge. Finding #2 (High) is blocking. The authorization logic has a concrete failure mode for users with >500 proposals. The fix is straightforward -- authorize against the fetched proposals, not a separate capped query. The remaining findings are non-blocking but should be addressed -- especially #4 (exception swallowing) and #9 (missing tests). |
- Remove AsNoTracking() from GetByIdsAsync so Dismiss() mutations are persisted by SaveChangesAsync (was silently doing nothing) - Replace pre-fetch-all authorization with per-ID ownership check to avoid false 403s for users with >500 proposals - Replace broad DomainException catch with explicit status guard before calling Dismiss()
Adversarial review fixes (3d2376a)Addressed all blocking issues from the adversarial review: 1. CRITICAL:
|
Applied proposals now disappear from the review list by default. Update 4 E2E tests to assert card is not visible after applying, and toggle showCompleted where the card is still needed.
Summary
Closes #611
Dismissedstatus toProposalStatusenum,Dismiss()domain method onAutomationProposal,DismissProposalsAsyncservice method, andPOST /api/automation/proposals/dismissendpoint accepting{ ids: [...] }. Only terminal-state proposals (Applied, Rejected, Failed, Expired) can be dismissed. Authorization checks ensure callers can only dismiss their own proposals. Batch size capped at 500.Dismissedadded to frontendProposalStatustype andnormalizeProposalStatusindex array.Acceptance criteria
POST /automation/proposals/dismisswith array of IDsTest plan