Files
MoneyMap/MoneyMap/Pages/CategoryMappings.cshtml.cs
AJ Isaacs a30e6ff089 Refactor: Extract CategoryMapping model and add caching
- Move CategoryMapping class to Models/CategoryMapping.cs
- Add IMemoryCache with 10-minute TTL for category mappings
- Add InvalidateMappingsCache() method for cache invalidation
- Reduces repeated DB queries during bulk categorization

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 21:11:29 -05:00

317 lines
11 KiB
C#

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using MoneyMap.Models;
using MoneyMap.Services;
using System.ComponentModel.DataAnnotations;
using System.Text;
using System.Text.Json;
namespace MoneyMap.Pages
{
public class CategoryMappingsModel : PageModel
{
private readonly MoneyMapContext _db;
private readonly ITransactionCategorizer _categorizer;
private readonly IMerchantService _merchantService;
public CategoryMappingsModel(MoneyMapContext db, ITransactionCategorizer categorizer, IMerchantService merchantService)
{
_db = db;
_categorizer = categorizer;
_merchantService = merchantService;
}
public List<CategoryGroup> CategoryGroups { get; set; } = new();
public int TotalMappings { get; set; }
public int TotalCategories { get; set; }
public List<string> AvailableCategories { get; set; } = new();
public List<string> AvailableMerchants { get; set; } = new();
[TempData]
public string? SuccessMessage { get; set; }
[TempData]
public string? ErrorMessage { get; set; }
public async Task OnGetAsync()
{
await LoadDataAsync();
}
public async Task<IActionResult> OnPostSeedDefaultsAsync()
{
await _categorizer.SeedDefaultMappingsAsync();
SuccessMessage = "Default category mappings loaded successfully!";
return RedirectToPage();
}
public async Task<IActionResult> OnPostAddMappingAsync(AddMappingModel model)
{
if (!ModelState.IsValid)
{
ErrorMessage = "Please fill in all required fields.";
await LoadDataAsync();
return Page();
}
int? merchantId = null;
if (!string.IsNullOrWhiteSpace(model.Merchant))
{
merchantId = await _merchantService.GetOrCreateIdAsync(model.Merchant);
}
var mapping = new CategoryMapping
{
Category = model.Category.Trim(),
Pattern = model.Pattern.Trim(),
MerchantId = merchantId,
Priority = model.Priority
};
_db.CategoryMappings.Add(mapping);
await _db.SaveChangesAsync();
SuccessMessage = $"Added pattern '{mapping.Pattern}' to category '{mapping.Category}'.";
return RedirectToPage();
}
public async Task<IActionResult> OnPostUpdateMappingAsync(UpdateMappingModel model)
{
if (!ModelState.IsValid)
{
ErrorMessage = "Please fill in all required fields.";
await LoadDataAsync();
return Page();
}
var mapping = await _db.CategoryMappings.FindAsync(model.Id);
if (mapping == null)
{
ErrorMessage = "Mapping not found.";
return RedirectToPage();
}
int? merchantId = null;
if (!string.IsNullOrWhiteSpace(model.Merchant))
{
merchantId = await _merchantService.GetOrCreateIdAsync(model.Merchant);
}
mapping.Category = model.Category.Trim();
mapping.Pattern = model.Pattern.Trim();
mapping.MerchantId = merchantId;
mapping.Priority = model.Priority;
await _db.SaveChangesAsync();
SuccessMessage = "Mapping updated successfully.";
return RedirectToPage();
}
public async Task<IActionResult> OnPostDeleteMappingAsync(int id)
{
var mapping = await _db.CategoryMappings.FindAsync(id);
if (mapping == null)
{
ErrorMessage = "Mapping not found.";
return RedirectToPage();
}
_db.CategoryMappings.Remove(mapping);
await _db.SaveChangesAsync();
SuccessMessage = "Mapping deleted successfully.";
return RedirectToPage();
}
public async Task<IActionResult> OnGetExportAsync()
{
var mappings = await _db.CategoryMappings
.OrderBy(m => m.Category)
.ThenByDescending(m => m.Priority)
.ToListAsync();
var exportData = mappings.Select(m => new CategoryMappingExport
{
Category = m.Category,
Pattern = m.Pattern,
Merchant = m.Merchant?.Name,
Priority = m.Priority
}).ToList();
var json = JsonSerializer.Serialize(exportData, new JsonSerializerOptions
{
WriteIndented = true
});
var bytes = Encoding.UTF8.GetBytes(json);
return File(bytes, "application/json", "category-mappings.json");
}
public async Task<IActionResult> OnPostImportAsync(IFormFile jsonFile, bool replaceExisting)
{
if (jsonFile == null || jsonFile.Length == 0)
{
ErrorMessage = "Please select a JSON file to import.";
return RedirectToPage();
}
try
{
using var stream = jsonFile.OpenReadStream();
var importData = await JsonSerializer.DeserializeAsync<List<CategoryMappingExport>>(stream, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
if (importData == null || !importData.Any())
{
ErrorMessage = "No category mappings found in the file.";
return RedirectToPage();
}
// Validate the data
foreach (var item in importData)
{
if (string.IsNullOrWhiteSpace(item.Category) || string.IsNullOrWhiteSpace(item.Pattern))
{
ErrorMessage = "Invalid data in JSON file. Each mapping must have a category and pattern.";
return RedirectToPage();
}
}
// Replace existing or merge
if (replaceExisting)
{
var existingMappings = await _db.CategoryMappings.ToListAsync();
_db.CategoryMappings.RemoveRange(existingMappings);
}
// Add new mappings (create merchants first if needed)
var newMappings = new List<CategoryMapping>();
foreach (var item in importData)
{
int? merchantId = null;
if (!string.IsNullOrWhiteSpace(item.Merchant))
{
merchantId = await _merchantService.GetOrCreateIdAsync(item.Merchant);
}
newMappings.Add(new CategoryMapping
{
Category = item.Category.Trim(),
Pattern = item.Pattern.Trim(),
MerchantId = merchantId,
Priority = item.Priority
});
}
_db.CategoryMappings.AddRange(newMappings);
await _db.SaveChangesAsync();
SuccessMessage = replaceExisting
? $"Successfully replaced all mappings with {importData.Count} mappings from file."
: $"Successfully imported {importData.Count} mappings from file.";
return RedirectToPage();
}
catch (JsonException)
{
ErrorMessage = "Invalid JSON file format. Please check the file and try again.";
return RedirectToPage();
}
catch (Exception ex)
{
ErrorMessage = $"Error importing file: {ex.Message}";
return RedirectToPage();
}
}
private async Task LoadDataAsync()
{
var mappings = await _db.CategoryMappings
.Include(m => m.Merchant)
.OrderBy(m => m.Category)
.ThenByDescending(m => m.Priority)
.ToListAsync();
CategoryGroups = mappings
.GroupBy(m => m.Category)
.Select(g => new CategoryGroup
{
Category = g.Key,
Mappings = g.OrderByDescending(m => m.Priority).ToList(),
Count = g.Count()
})
.OrderBy(g => g.Category)
.ToList();
TotalMappings = mappings.Count;
TotalCategories = CategoryGroups.Count;
// Get distinct categories for dropdown
AvailableCategories = CategoryGroups
.Select(g => g.Category)
.OrderBy(c => c)
.ToList();
// Get all merchants for dropdown
AvailableMerchants = await _db.Merchants
.OrderBy(m => m.Name)
.Select(m => m.Name)
.ToListAsync();
}
public class CategoryGroup
{
public required string Category { get; set; }
public required List<CategoryMapping> Mappings { get; set; }
public int Count { get; set; }
}
public class AddMappingModel
{
[Required(ErrorMessage = "Category is required")]
[StringLength(100)]
public string Category { get; set; } = "";
[StringLength(100)]
public string? Merchant { get; set; }
[Required(ErrorMessage = "Pattern is required")]
[StringLength(200)]
public string Pattern { get; set; } = "";
public int Priority { get; set; } = 0;
}
public class UpdateMappingModel
{
[Required]
public int Id { get; set; }
[Required(ErrorMessage = "Category is required")]
[StringLength(100)]
public string Category { get; set; } = "";
[StringLength(100)]
public string? Merchant { get; set; }
[Required(ErrorMessage = "Pattern is required")]
[StringLength(200)]
public string Pattern { get; set; } = "";
public int Priority { get; set; } = 0;
}
public class CategoryMappingExport
{
public string Category { get; set; } = "";
public string Pattern { get; set; } = "";
public string? Merchant { get; set; }
public int Priority { get; set; } = 0;
}
}
}