Skip to content

Commit 73b1d3d

Browse files
author
Fortinbra
committed
feat(rules): redesign rules listing with server-side pagination, sorting, filtering, bulk actions, and table/card views
- Server-side paginated API: GET /api/v1/categorization-rules with search, category, status, sort params - Bulk operations: POST activate/deactivate/delete for multiple rules - New Blazor components: RulesTable, RulesToolbar, RulesPagination, BulkActionBar - Table view with sortable columns, keyboard navigation (Arrow/Enter/Space/Esc) - Card view toggle and group-by-category mode - CategorizationEngine performance: rule caching, batch loading, string-first evaluation - Contracts: CategorizationRuleListRequest, PageResponse, BulkRuleActionRequest/Response - Comprehensive tests across Domain, Application, Infrastructure, API, and Client layers - Performance regression tests for categorization engine (100 rules x 1000 transactions) Refs: #115
1 parent 757a31f commit 73b1d3d

File tree

51 files changed

+6199
-172
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

51 files changed

+6199
-172
lines changed

docs/115-rules-listing-redesign.md

Lines changed: 95 additions & 95 deletions
Large diffs are not rendered by default.

src/BudgetExperiment.Api/Controllers/CategorizationRulesController.cs

Lines changed: 100 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,51 @@ public CategorizationRulesController(ICategorizationRuleService service)
3131
}
3232

3333
/// <summary>
34-
/// Gets all categorization rules.
34+
/// Gets categorization rules. When page/pageSize are provided, returns a paginated response.
35+
/// Otherwise returns all rules for backward compatibility.
3536
/// </summary>
36-
/// <param name="activeOnly">If true, returns only active rules.</param>
37+
/// <param name="activeOnly">If true, returns only active rules (non-paginated mode only).</param>
38+
/// <param name="page">Page number (1-based) for paginated results.</param>
39+
/// <param name="pageSize">Number of items per page.</param>
40+
/// <param name="search">Search text to filter by rule name or pattern.</param>
41+
/// <param name="categoryId">Category ID to filter by.</param>
42+
/// <param name="status">Status filter: "active", "inactive", or null for all.</param>
43+
/// <param name="sortBy">Sort field: "priority", "name", "category", "createdAt".</param>
44+
/// <param name="sortDirection">Sort direction: "asc" or "desc".</param>
3745
/// <param name="cancellationToken">Cancellation token.</param>
38-
/// <returns>A list of categorization rules.</returns>
46+
/// <returns>A list of categorization rules or a paged response.</returns>
3947
[HttpGet]
4048
[ProducesResponseType<IReadOnlyList<CategorizationRuleDto>>(StatusCodes.Status200OK)]
41-
public async Task<IActionResult> GetAllAsync([FromQuery] bool activeOnly = false, CancellationToken cancellationToken = default)
49+
[ProducesResponseType<CategorizationRulePageResponse>(StatusCodes.Status200OK)]
50+
public async Task<IActionResult> GetAllAsync(
51+
[FromQuery] bool activeOnly = false,
52+
[FromQuery] int? page = null,
53+
[FromQuery] int? pageSize = null,
54+
[FromQuery] string? search = null,
55+
[FromQuery] Guid? categoryId = null,
56+
[FromQuery] string? status = null,
57+
[FromQuery] string? sortBy = null,
58+
[FromQuery] string? sortDirection = null,
59+
CancellationToken cancellationToken = default)
4260
{
61+
if (page.HasValue || pageSize.HasValue)
62+
{
63+
var request = new CategorizationRuleListRequest
64+
{
65+
Page = page ?? 1,
66+
PageSize = pageSize ?? 25,
67+
Search = search,
68+
CategoryId = categoryId,
69+
Status = status,
70+
SortBy = sortBy,
71+
SortDirection = sortDirection,
72+
};
73+
74+
var pagedResult = await this._service.ListPagedAsync(request, cancellationToken);
75+
this.Response.Headers["X-Pagination-TotalCount"] = pagedResult.TotalCount.ToString(System.Globalization.CultureInfo.InvariantCulture);
76+
return this.Ok(pagedResult);
77+
}
78+
4379
var rules = await this._service.GetAllAsync(activeOnly, cancellationToken);
4480
return this.Ok(rules);
4581
}
@@ -222,4 +258,64 @@ public async Task<IActionResult> ApplyRulesAsync([FromBody] ApplyRulesRequest re
222258
var result = await this._service.ApplyRulesAsync(request, cancellationToken);
223259
return this.Ok(result);
224260
}
261+
262+
/// <summary>
263+
/// Bulk deletes categorization rules.
264+
/// </summary>
265+
/// <param name="request">The bulk action request containing rule IDs.</param>
266+
/// <param name="cancellationToken">Cancellation token.</param>
267+
/// <returns>The number of deleted rules.</returns>
268+
[HttpDelete("bulk")]
269+
[ProducesResponseType<BulkRuleActionResponse>(StatusCodes.Status200OK)]
270+
[ProducesResponseType(StatusCodes.Status400BadRequest)]
271+
public async Task<IActionResult> BulkDeleteAsync([FromBody] BulkRuleActionRequest request, CancellationToken cancellationToken)
272+
{
273+
if (request.Ids.Count == 0)
274+
{
275+
return this.BadRequest("No rule IDs provided.");
276+
}
277+
278+
var count = await this._service.BulkDeleteAsync(request.Ids, cancellationToken);
279+
return this.Ok(new BulkRuleActionResponse { AffectedCount = count });
280+
}
281+
282+
/// <summary>
283+
/// Bulk activates categorization rules.
284+
/// </summary>
285+
/// <param name="request">The bulk action request containing rule IDs.</param>
286+
/// <param name="cancellationToken">Cancellation token.</param>
287+
/// <returns>The number of activated rules.</returns>
288+
[HttpPost("bulk/activate")]
289+
[ProducesResponseType<BulkRuleActionResponse>(StatusCodes.Status200OK)]
290+
[ProducesResponseType(StatusCodes.Status400BadRequest)]
291+
public async Task<IActionResult> BulkActivateAsync([FromBody] BulkRuleActionRequest request, CancellationToken cancellationToken)
292+
{
293+
if (request.Ids.Count == 0)
294+
{
295+
return this.BadRequest("No rule IDs provided.");
296+
}
297+
298+
var count = await this._service.BulkActivateAsync(request.Ids, cancellationToken);
299+
return this.Ok(new BulkRuleActionResponse { AffectedCount = count });
300+
}
301+
302+
/// <summary>
303+
/// Bulk deactivates categorization rules.
304+
/// </summary>
305+
/// <param name="request">The bulk action request containing rule IDs.</param>
306+
/// <param name="cancellationToken">Cancellation token.</param>
307+
/// <returns>The number of deactivated rules.</returns>
308+
[HttpPost("bulk/deactivate")]
309+
[ProducesResponseType<BulkRuleActionResponse>(StatusCodes.Status200OK)]
310+
[ProducesResponseType(StatusCodes.Status400BadRequest)]
311+
public async Task<IActionResult> BulkDeactivateAsync([FromBody] BulkRuleActionRequest request, CancellationToken cancellationToken)
312+
{
313+
if (request.Ids.Count == 0)
314+
{
315+
return this.BadRequest("No rule IDs provided.");
316+
}
317+
318+
var count = await this._service.BulkDeactivateAsync(request.Ids, cancellationToken);
319+
return this.Ok(new BulkRuleActionResponse { AffectedCount = count });
320+
}
225321
}

src/BudgetExperiment.Application/Categorization/CategorizationEngine.cs

Lines changed: 102 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -5,31 +5,45 @@
55
using BudgetExperiment.Contracts.Dtos;
66
using BudgetExperiment.Domain;
77

8+
using Microsoft.Extensions.Caching.Memory;
9+
810
namespace BudgetExperiment.Application.Categorization;
911

1012
/// <summary>
1113
/// Implementation of <see cref="ICategorizationEngine"/> for applying auto-categorization rules to transactions.
14+
/// Uses batch transaction loading, in-memory rule caching, and string-first evaluation for performance.
1215
/// </summary>
1316
public class CategorizationEngine : ICategorizationEngine
1417
{
18+
/// <summary>
19+
/// Cache key for active rules ordered by priority.
20+
/// </summary>
21+
internal const string ActiveRulesCacheKey = "CategorizationEngine_ActiveRules";
22+
23+
private static readonly TimeSpan CacheDuration = TimeSpan.FromSeconds(30);
24+
1525
private readonly ICategorizationRuleRepository _ruleRepository;
1626
private readonly ITransactionRepository _transactionRepository;
1727
private readonly IUnitOfWork _unitOfWork;
28+
private readonly IMemoryCache _cache;
1829

1930
/// <summary>
2031
/// Initializes a new instance of the <see cref="CategorizationEngine"/> class.
2132
/// </summary>
2233
/// <param name="ruleRepository">The categorization rule repository.</param>
2334
/// <param name="transactionRepository">The transaction repository.</param>
2435
/// <param name="unitOfWork">The unit of work for persisting changes.</param>
36+
/// <param name="cache">The memory cache for caching active rules.</param>
2537
public CategorizationEngine(
2638
ICategorizationRuleRepository ruleRepository,
2739
ITransactionRepository transactionRepository,
28-
IUnitOfWork unitOfWork)
40+
IUnitOfWork unitOfWork,
41+
IMemoryCache cache)
2942
{
3043
this._ruleRepository = ruleRepository;
3144
this._transactionRepository = transactionRepository;
3245
this._unitOfWork = unitOfWork;
46+
this._cache = cache;
3347
}
3448

3549
/// <inheritdoc />
@@ -42,17 +56,8 @@ public CategorizationEngine(
4256
return null;
4357
}
4458

45-
var rules = await this._ruleRepository.GetActiveByPriorityAsync(cancellationToken);
46-
47-
foreach (var rule in rules)
48-
{
49-
if (rule.Matches(description))
50-
{
51-
return rule.CategoryId;
52-
}
53-
}
54-
55-
return null;
59+
var rules = await this.GetCachedActiveRulesAsync(cancellationToken);
60+
return FindFirstMatch(rules, description);
5661
}
5762

5863
/// <inheritdoc />
@@ -61,7 +66,7 @@ public async Task<CategorizationResult> ApplyRulesAsync(
6166
bool overwriteExisting = false,
6267
CancellationToken cancellationToken = default)
6368
{
64-
var rules = await this._ruleRepository.GetActiveByPriorityAsync(cancellationToken);
69+
var rules = await this.GetCachedActiveRulesAsync(cancellationToken);
6570

6671
var totalProcessed = 0;
6772
var categorized = 0;
@@ -74,50 +79,40 @@ public async Task<CategorizationResult> ApplyRulesAsync(
7479

7580
if (transactionIds == null)
7681
{
77-
// Process all uncategorized transactions
7882
transactions = await this._transactionRepository.GetUncategorizedAsync(cancellationToken);
7983
}
8084
else
8185
{
82-
// Process specific transactions
86+
var idList = transactionIds.ToList();
87+
var allFetched = await this._transactionRepository.GetByIdsAsync(idList, cancellationToken);
88+
8389
var transactionList = new List<Transaction>();
84-
foreach (var id in transactionIds)
90+
foreach (var transaction in allFetched)
8591
{
86-
var transaction = await this._transactionRepository.GetByIdAsync(id, cancellationToken);
87-
if (transaction != null)
92+
if (overwriteExisting || transaction.CategoryId == null)
93+
{
94+
transactionList.Add(transaction);
95+
}
96+
else
8897
{
89-
// Skip already-categorized transactions unless overwriteExisting is true
90-
if (overwriteExisting || transaction.CategoryId == null)
91-
{
92-
transactionList.Add(transaction);
93-
}
94-
else
95-
{
96-
// Track as skipped due to existing category
97-
skippedDueToExisting++;
98-
}
98+
skippedDueToExisting++;
9999
}
100100
}
101101

102102
transactions = transactionList;
103103
}
104104

105+
// Partition rules: string rules first, then regex rules
106+
var (stringRules, regexRules) = PartitionRules(rules);
107+
105108
foreach (var transaction in transactions)
106109
{
107110
totalProcessed++;
108111

109112
try
110113
{
111-
Guid? matchedCategoryId = null;
112-
113-
foreach (var rule in rules)
114-
{
115-
if (rule.Matches(transaction.Description))
116-
{
117-
matchedCategoryId = rule.CategoryId;
118-
break;
119-
}
120-
}
114+
var matchedCategoryId = FindFirstMatch(stringRules, transaction.Description)
115+
?? FindFirstMatch(regexRules, transaction.Description);
121116

122117
if (matchedCategoryId.HasValue)
123118
{
@@ -161,12 +156,11 @@ public async Task<IReadOnlyList<string>> TestPatternAsync(
161156
{
162157
ArgumentException.ThrowIfNullOrWhiteSpace(pattern);
163158

164-
// Create a temporary rule to leverage the Matches logic
165159
var testRule = CategorizationRule.Create(
166160
name: "Test Rule",
167161
pattern: pattern,
168162
matchType: matchType,
169-
categoryId: Guid.NewGuid(), // Dummy category, we just need the Matches method
163+
categoryId: Guid.NewGuid(),
170164
caseSensitive: caseSensitive);
171165

172166
var allDescriptions = await this._transactionRepository.GetAllDescriptionsAsync(cancellationToken);
@@ -208,35 +202,89 @@ public async Task<Dictionary<Guid, InlineCategorySuggestionDto>> GetBatchSuggest
208202
return result;
209203
}
210204

211-
var rules = await this._ruleRepository.GetActiveByPriorityAsync(cancellationToken);
205+
var rules = await this.GetCachedActiveRulesAsync(cancellationToken);
212206
if (rules.Count == 0)
213207
{
214208
return result;
215209
}
216210

217-
foreach (var transactionId in transactionIds)
211+
var transactions = await this._transactionRepository.GetByIdsAsync(transactionIds, cancellationToken);
212+
213+
foreach (var transaction in transactions)
218214
{
219-
var transaction = await this._transactionRepository.GetByIdAsync(transactionId, cancellationToken);
220-
if (transaction is null || transaction.CategoryId is not null)
215+
if (transaction.CategoryId is not null)
221216
{
222217
continue;
223218
}
224219

225-
foreach (var rule in rules)
220+
var matchedCategoryId = FindFirstMatch(rules, transaction.Description);
221+
if (matchedCategoryId.HasValue)
226222
{
227-
if (rule.Matches(transaction.Description))
223+
var matchedRule = rules.First(r => r.CategoryId == matchedCategoryId.Value && r.Matches(transaction.Description));
224+
result[transaction.Id] = new InlineCategorySuggestionDto
228225
{
229-
result[transactionId] = new InlineCategorySuggestionDto
230-
{
231-
TransactionId = transactionId,
232-
CategoryId = rule.CategoryId,
233-
CategoryName = rule.Category?.Name ?? string.Empty,
234-
};
235-
break;
236-
}
226+
TransactionId = transaction.Id,
227+
CategoryId = matchedCategoryId.Value,
228+
CategoryName = matchedRule.Category?.Name ?? string.Empty,
229+
};
237230
}
238231
}
239232

240233
return result;
241234
}
235+
236+
/// <summary>
237+
/// Invalidates the cached active rules, forcing a fresh load on next access.
238+
/// Called by <see cref="CategorizationRuleService"/> after rule CRUD operations.
239+
/// </summary>
240+
public void InvalidateRuleCache()
241+
{
242+
this._cache.Remove(ActiveRulesCacheKey);
243+
}
244+
245+
private static Guid? FindFirstMatch(IReadOnlyList<CategorizationRule> rules, string description)
246+
{
247+
foreach (var rule in rules)
248+
{
249+
if (rule.Matches(description))
250+
{
251+
return rule.CategoryId;
252+
}
253+
}
254+
255+
return null;
256+
}
257+
258+
private static (IReadOnlyList<CategorizationRule> StringRules, IReadOnlyList<CategorizationRule> RegexRules) PartitionRules(
259+
IReadOnlyList<CategorizationRule> rules)
260+
{
261+
var stringRules = new List<CategorizationRule>();
262+
var regexRules = new List<CategorizationRule>();
263+
264+
foreach (var rule in rules)
265+
{
266+
if (rule.MatchType == RuleMatchType.Regex)
267+
{
268+
regexRules.Add(rule);
269+
}
270+
else
271+
{
272+
stringRules.Add(rule);
273+
}
274+
}
275+
276+
return (stringRules, regexRules);
277+
}
278+
279+
private async Task<IReadOnlyList<CategorizationRule>> GetCachedActiveRulesAsync(CancellationToken cancellationToken)
280+
{
281+
if (this._cache.TryGetValue(ActiveRulesCacheKey, out IReadOnlyList<CategorizationRule>? cached) && cached is not null)
282+
{
283+
return cached;
284+
}
285+
286+
var rules = await this._ruleRepository.GetActiveByPriorityAsync(cancellationToken);
287+
this._cache.Set(ActiveRulesCacheKey, rules, CacheDuration);
288+
return rules;
289+
}
242290
}

0 commit comments

Comments
 (0)