Performance: push ORDER BY/TAKE into SQL for hot-path reads

- IInAppNotificationRepository: typed repo with GetPagedAsync, GetRecentAsync, GetUnreadAsync
  — bell dropdown no longer loads all notifications then slices in C#
- Add compound indexes on InAppNotifications(CompanyId, IsDeleted, CreatedAt) and
  (CompanyId, IsDeleted, IsRead); ContactSubmissions(CompanyId, IsDeleted, CreatedAt)
- PlainRepository.GetAllAsync/FindAsync: add AsNoTracking (Announcements, Tips, ReleaseNotes)
- AiUsageReportController: replace GetAllAsync + C# Where with FindAsync (SQL-level filter)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 21:34:12 -04:00
parent 54defc158f
commit aeec899cf2
11 changed files with 11628 additions and 62 deletions
@@ -1031,6 +1031,17 @@ modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
modelBuilder.Entity<AuditLog>()
.HasIndex(a => new { a.EntityType, a.EntityId });
// InAppNotification — bell endpoint fires on every page load; compound indexes let SQL
// evaluate the tenant + soft-delete filter then sort/count without a full-table scan.
modelBuilder.Entity<InAppNotification>()
.HasIndex(n => new { n.CompanyId, n.IsDeleted, n.CreatedAt });
modelBuilder.Entity<InAppNotification>()
.HasIndex(n => new { n.CompanyId, n.IsDeleted, n.IsRead });
// ContactSubmission — SuperAdmin inbox; filter + sort covered by a single index.
modelBuilder.Entity<ContactSubmission>()
.HasIndex(c => new { c.CompanyId, c.IsDeleted, c.CreatedAt });
// Announcements — no tenant filter; visible based on Target logic in app layer
modelBuilder.Entity<AnnouncementDismissal>()
.HasOne(d => d.Announcement)
@@ -0,0 +1,88 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddNotificationAndContactIndexes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4191));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4196));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4197));
migrationBuilder.CreateIndex(
name: "IX_InAppNotifications_CompanyId_IsDeleted_CreatedAt",
table: "InAppNotifications",
columns: new[] { "CompanyId", "IsDeleted", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_InAppNotifications_CompanyId_IsDeleted_IsRead",
table: "InAppNotifications",
columns: new[] { "CompanyId", "IsDeleted", "IsRead" });
migrationBuilder.CreateIndex(
name: "IX_ContactSubmissions_CompanyId_IsDeleted_CreatedAt",
table: "ContactSubmissions",
columns: new[] { "CompanyId", "IsDeleted", "CreatedAt" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_InAppNotifications_CompanyId_IsDeleted_CreatedAt",
table: "InAppNotifications");
migrationBuilder.DropIndex(
name: "IX_InAppNotifications_CompanyId_IsDeleted_IsRead",
table: "InAppNotifications");
migrationBuilder.DropIndex(
name: "IX_ContactSubmissions_CompanyId_IsDeleted_CreatedAt",
table: "ContactSubmissions");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 10, 16, 17, 57, 120, DateTimeKind.Utc).AddTicks(9129));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 10, 16, 17, 57, 120, DateTimeKind.Utc).AddTicks(9137));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 10, 16, 17, 57, 120, DateTimeKind.Utc).AddTicks(9138));
}
}
}
@@ -2514,6 +2514,8 @@ namespace PowderCoating.Infrastructure.Migrations
b.HasKey("Id");
b.HasIndex("CompanyId", "IsDeleted", "CreatedAt");
b.ToTable("ContactSubmissions");
});
@@ -3990,6 +3992,10 @@ namespace PowderCoating.Infrastructure.Migrations
b.HasIndex("QuoteId");
b.HasIndex("CompanyId", "IsDeleted", "CreatedAt");
b.HasIndex("CompanyId", "IsDeleted", "IsRead");
b.ToTable("InAppNotifications");
});
@@ -7204,7 +7210,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 1,
CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 10, 16, 17, 57, 120, DateTimeKind.Utc).AddTicks(9129),
CreatedAt = new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4191),
Description = "Standard pricing for regular customers",
DiscountPercent = 0m,
IsActive = true,
@@ -7215,7 +7221,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 2,
CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 10, 16, 17, 57, 120, DateTimeKind.Utc).AddTicks(9137),
CreatedAt = new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4196),
Description = "5% discount for preferred customers",
DiscountPercent = 5m,
IsActive = true,
@@ -7226,7 +7232,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 3,
CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 10, 16, 17, 57, 120, DateTimeKind.Utc).AddTicks(9138),
CreatedAt = new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4197),
Description = "10% discount for premium customers",
DiscountPercent = 10m,
IsActive = true,
@@ -0,0 +1,83 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces.Repositories;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Repositories;
/// <summary>
/// Typed repository for <see cref="InAppNotification"/>. Overrides the three hot-path
/// read operations so ORDER BY / SKIP / TAKE run in SQL rather than in C# after a full
/// table load — critical for the bell dropdown (Recent/Unread) which fires on every page.
/// </summary>
public class InAppNotificationRepository
: Repository<InAppNotification>, IInAppNotificationRepository
{
public InAppNotificationRepository(ApplicationDbContext context) : base(context) { }
/// <inheritdoc/>
public async Task<(List<InAppNotification> Items, int TotalCount)> GetPagedAsync(
bool isPlatformAdmin, int pageNumber, int pageSize)
{
// SuperAdmin path: bypass global filters, scope to CompanyId 0 (platform-only).
// Regular path: global filter already scopes to the current tenant.
var query = isPlatformAdmin
? _context.Set<InAppNotification>()
.IgnoreQueryFilters()
.Where(n => !n.IsDeleted && n.CompanyId == 0)
: _context.Set<InAppNotification>()
.Where(n => true);
var totalCount = await query.CountAsync();
var items = await query
.AsNoTracking()
.OrderByDescending(n => n.CreatedAt)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
return (items, totalCount);
}
/// <inheritdoc/>
public async Task<List<InAppNotification>> GetRecentAsync(
bool isPlatformAdmin, int take = 20)
{
var query = isPlatformAdmin
? _context.Set<InAppNotification>()
.IgnoreQueryFilters()
.Where(n => !n.IsDeleted && n.CompanyId == 0)
: _context.Set<InAppNotification>()
.Where(n => true);
return await query
.AsNoTracking()
.OrderByDescending(n => n.CreatedAt)
.Take(take)
.ToListAsync();
}
/// <inheritdoc/>
public async Task<(List<InAppNotification> Items, int UnreadCount)> GetUnreadAsync(
bool isPlatformAdmin, int take = 20)
{
// Count ALL unread for the badge, then cap the items list for the dropdown.
var baseQuery = isPlatformAdmin
? _context.Set<InAppNotification>()
.IgnoreQueryFilters()
.Where(n => !n.IsDeleted && n.CompanyId == 0 && !n.IsRead)
: _context.Set<InAppNotification>()
.Where(n => !n.IsRead);
var unreadCount = await baseQuery.CountAsync();
var items = await baseQuery
.AsNoTracking()
.OrderByDescending(n => n.CreatedAt)
.Take(take)
.ToListAsync();
return (items, unreadCount);
}
}
@@ -26,10 +26,10 @@ public class PlainRepository<T> : IPlainRepository<T> where T : class
=> await _dbSet.FindAsync(id);
public virtual async Task<IEnumerable<T>> GetAllAsync()
=> await _dbSet.ToListAsync();
=> await _dbSet.AsNoTracking().ToListAsync();
public virtual async Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate)
=> await _dbSet.Where(predicate).ToListAsync();
=> await _dbSet.AsNoTracking().Where(predicate).ToListAsync();
public virtual async Task<T?> FirstOrDefaultAsync(Expression<Func<T, bool>> predicate)
=> await _dbSet.FirstOrDefaultAsync(predicate);
@@ -109,7 +109,7 @@ public class UnitOfWork : IUnitOfWork
private IPlainRepository<Announcement>? _announcements;
private IPlainRepository<BannedIp>? _bannedIps;
private IPlainRepository<DashboardTip>? _dashboardTips;
private IRepository<InAppNotification>? _inAppNotifications;
private IInAppNotificationRepository? _inAppNotifications;
private IPlainRepository<ReleaseNote>? _releaseNotes;
// Bug Reports
@@ -439,8 +439,8 @@ public class UnitOfWork : IUnitOfWork
_dashboardTips ??= new PlainRepository<DashboardTip>(_context);
/// <summary>Repository for <see cref="InAppNotification"/> bell-notification records; tenant-filtered with soft delete.</summary>
public IRepository<InAppNotification> InAppNotifications =>
_inAppNotifications ??= new Repository<InAppNotification>(_context);
public IInAppNotificationRepository InAppNotifications =>
_inAppNotifications ??= new InAppNotificationRepository(_context);
/// <summary>Repository for <see cref="ReleaseNote"/> platform changelog entries; no tenant filter, no soft delete.</summary>
public IPlainRepository<ReleaseNote> ReleaseNotes =>