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:
@@ -137,7 +137,7 @@ IRepository<ReworkRecord> ReworkRecords { get; }
|
|||||||
IPlainRepository<Announcement> Announcements { get; }
|
IPlainRepository<Announcement> Announcements { get; }
|
||||||
IPlainRepository<BannedIp> BannedIps { get; }
|
IPlainRepository<BannedIp> BannedIps { get; }
|
||||||
IPlainRepository<DashboardTip> DashboardTips { get; }
|
IPlainRepository<DashboardTip> DashboardTips { get; }
|
||||||
IRepository<InAppNotification> InAppNotifications { get; }
|
IInAppNotificationRepository InAppNotifications { get; }
|
||||||
IPlainRepository<ReleaseNote> ReleaseNotes { get; }
|
IPlainRepository<ReleaseNote> ReleaseNotes { get; }
|
||||||
|
|
||||||
// Bug Reports
|
// Bug Reports
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using PowderCoating.Core.Entities;
|
||||||
|
|
||||||
|
namespace PowderCoating.Core.Interfaces.Repositories;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Typed repository for <see cref="InAppNotification"/> providing DB-level pagination and
|
||||||
|
/// bounded reads for the bell-dropdown and notification history page. The generic
|
||||||
|
/// <see cref="IRepository{T}"/> returns materialized lists so ordering/limiting must happen
|
||||||
|
/// in C#; these methods push ORDER BY, SKIP, and TAKE into SQL where they belong.
|
||||||
|
/// </summary>
|
||||||
|
public interface IInAppNotificationRepository : IRepository<InAppNotification>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a page of notifications ordered newest-first, plus the total un-paged count.
|
||||||
|
/// SuperAdmin path filters to CompanyId == 0 (platform notifications only).
|
||||||
|
/// </summary>
|
||||||
|
Task<(List<InAppNotification> Items, int TotalCount)> GetPagedAsync(
|
||||||
|
bool isPlatformAdmin, int pageNumber, int pageSize);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the <paramref name="take"/> most recent notifications (read and unread)
|
||||||
|
/// for the bell dropdown. SuperAdmin path is scoped to platform notifications.
|
||||||
|
/// </summary>
|
||||||
|
Task<List<InAppNotification>> GetRecentAsync(bool isPlatformAdmin, int take = 20);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the <paramref name="take"/> most recent unread notifications plus the full
|
||||||
|
/// unread count for the bell badge. SuperAdmin path is scoped to platform notifications.
|
||||||
|
/// </summary>
|
||||||
|
Task<(List<InAppNotification> Items, int UnreadCount)> GetUnreadAsync(
|
||||||
|
bool isPlatformAdmin, int take = 20);
|
||||||
|
}
|
||||||
@@ -1031,6 +1031,17 @@ modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
|
|||||||
modelBuilder.Entity<AuditLog>()
|
modelBuilder.Entity<AuditLog>()
|
||||||
.HasIndex(a => new { a.EntityType, a.EntityId });
|
.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
|
// Announcements — no tenant filter; visible based on Target logic in app layer
|
||||||
modelBuilder.Entity<AnnouncementDismissal>()
|
modelBuilder.Entity<AnnouncementDismissal>()
|
||||||
.HasOne(d => d.Announcement)
|
.HasOne(d => d.Announcement)
|
||||||
|
|||||||
+11351
File diff suppressed because it is too large
Load Diff
+88
@@ -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.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CompanyId", "IsDeleted", "CreatedAt");
|
||||||
|
|
||||||
b.ToTable("ContactSubmissions");
|
b.ToTable("ContactSubmissions");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3990,6 +3992,10 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
|
|
||||||
b.HasIndex("QuoteId");
|
b.HasIndex("QuoteId");
|
||||||
|
|
||||||
|
b.HasIndex("CompanyId", "IsDeleted", "CreatedAt");
|
||||||
|
|
||||||
|
b.HasIndex("CompanyId", "IsDeleted", "IsRead");
|
||||||
|
|
||||||
b.ToTable("InAppNotifications");
|
b.ToTable("InAppNotifications");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -7204,7 +7210,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
CompanyId = 0,
|
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",
|
Description = "Standard pricing for regular customers",
|
||||||
DiscountPercent = 0m,
|
DiscountPercent = 0m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -7215,7 +7221,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 2,
|
Id = 2,
|
||||||
CompanyId = 0,
|
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",
|
Description = "5% discount for preferred customers",
|
||||||
DiscountPercent = 5m,
|
DiscountPercent = 5m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -7226,7 +7232,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 3,
|
Id = 3,
|
||||||
CompanyId = 0,
|
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",
|
Description = "10% discount for premium customers",
|
||||||
DiscountPercent = 10m,
|
DiscountPercent = 10m,
|
||||||
IsActive = true,
|
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);
|
=> await _dbSet.FindAsync(id);
|
||||||
|
|
||||||
public virtual async Task<IEnumerable<T>> GetAllAsync()
|
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)
|
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)
|
public virtual async Task<T?> FirstOrDefaultAsync(Expression<Func<T, bool>> predicate)
|
||||||
=> await _dbSet.FirstOrDefaultAsync(predicate);
|
=> await _dbSet.FirstOrDefaultAsync(predicate);
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ public class UnitOfWork : IUnitOfWork
|
|||||||
private IPlainRepository<Announcement>? _announcements;
|
private IPlainRepository<Announcement>? _announcements;
|
||||||
private IPlainRepository<BannedIp>? _bannedIps;
|
private IPlainRepository<BannedIp>? _bannedIps;
|
||||||
private IPlainRepository<DashboardTip>? _dashboardTips;
|
private IPlainRepository<DashboardTip>? _dashboardTips;
|
||||||
private IRepository<InAppNotification>? _inAppNotifications;
|
private IInAppNotificationRepository? _inAppNotifications;
|
||||||
private IPlainRepository<ReleaseNote>? _releaseNotes;
|
private IPlainRepository<ReleaseNote>? _releaseNotes;
|
||||||
|
|
||||||
// Bug Reports
|
// Bug Reports
|
||||||
@@ -439,8 +439,8 @@ public class UnitOfWork : IUnitOfWork
|
|||||||
_dashboardTips ??= new PlainRepository<DashboardTip>(_context);
|
_dashboardTips ??= new PlainRepository<DashboardTip>(_context);
|
||||||
|
|
||||||
/// <summary>Repository for <see cref="InAppNotification"/> bell-notification records; tenant-filtered with soft delete.</summary>
|
/// <summary>Repository for <see cref="InAppNotification"/> bell-notification records; tenant-filtered with soft delete.</summary>
|
||||||
public IRepository<InAppNotification> InAppNotifications =>
|
public IInAppNotificationRepository InAppNotifications =>
|
||||||
_inAppNotifications ??= new Repository<InAppNotification>(_context);
|
_inAppNotifications ??= new InAppNotificationRepository(_context);
|
||||||
|
|
||||||
/// <summary>Repository for <see cref="ReleaseNote"/> platform changelog entries; no tenant filter, no soft delete.</summary>
|
/// <summary>Repository for <see cref="ReleaseNote"/> platform changelog entries; no tenant filter, no soft delete.</summary>
|
||||||
public IPlainRepository<ReleaseNote> ReleaseNotes =>
|
public IPlainRepository<ReleaseNote> ReleaseNotes =>
|
||||||
|
|||||||
@@ -33,8 +33,7 @@ public class AiUsageReportController : Controller
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var companies = (await _unitOfWork.Companies.GetAllAsync(ignoreQueryFilters: true))
|
var companies = (await _unitOfWork.Companies.FindAsync(c => !c.IsDeleted, ignoreQueryFilters: true))
|
||||||
.Where(c => !c.IsDeleted)
|
|
||||||
.Select(c => new { c.Id, c.CompanyName, c.SubscriptionPlan, c.IsActive })
|
.Select(c => new { c.Id, c.CompanyName, c.SubscriptionPlan, c.IsActive })
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
|||||||
@@ -18,26 +18,21 @@ public class InAppNotificationsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Displays the paginated notification history page (read and unread). SuperAdmins see only platform-level notifications (CompanyId 0) via IgnoreQueryFilters; regular users rely on the global query filter for tenant isolation.
|
/// Displays the paginated notification history page (read and unread). SuperAdmins see only
|
||||||
|
/// platform-level notifications (CompanyId 0); regular users rely on the global query filter.
|
||||||
|
/// ORDER BY / SKIP / TAKE run in SQL via GetPagedAsync — no full-table load.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<IActionResult> Index(int pageNumber = 1, int pageSize = 25)
|
public async Task<IActionResult> Index(int pageNumber = 1, int pageSize = 25)
|
||||||
{
|
{
|
||||||
pageNumber = Math.Max(1, pageNumber);
|
pageNumber = Math.Max(1, pageNumber);
|
||||||
pageSize = pageSize is 10 or 25 or 50 ? pageSize : 25;
|
pageSize = pageSize is 10 or 25 or 50 ? pageSize : 25;
|
||||||
|
|
||||||
var all = _tenant.IsPlatformAdmin()
|
var isPlatformAdmin = _tenant.IsPlatformAdmin();
|
||||||
? (await _unitOfWork.InAppNotifications.FindAsync(
|
var (items, totalCount) = await _unitOfWork.InAppNotifications
|
||||||
n => !n.IsDeleted && n.CompanyId == 0, ignoreQueryFilters: true)).ToList()
|
.GetPagedAsync(isPlatformAdmin, pageNumber, pageSize);
|
||||||
: (await _unitOfWork.InAppNotifications.GetAllAsync()).ToList();
|
|
||||||
|
|
||||||
var totalCount = all.Count;
|
|
||||||
|
|
||||||
var tz = ViewBag.CompanyTimeZone as string;
|
var tz = ViewBag.CompanyTimeZone as string;
|
||||||
var items = all
|
var vm = items.Select(n => new
|
||||||
.OrderByDescending(n => n.CreatedAt)
|
|
||||||
.Skip((pageNumber - 1) * pageSize)
|
|
||||||
.Take(pageSize)
|
|
||||||
.Select(n => new
|
|
||||||
{
|
{
|
||||||
n.Id,
|
n.Id,
|
||||||
n.Title,
|
n.Title,
|
||||||
@@ -47,63 +42,64 @@ public class InAppNotificationsController : Controller
|
|||||||
n.IsRead,
|
n.IsRead,
|
||||||
n.ReadAt,
|
n.ReadAt,
|
||||||
CreatedAt = n.CreatedAt
|
CreatedAt = n.CreatedAt
|
||||||
})
|
}).ToList();
|
||||||
.ToList();
|
|
||||||
|
|
||||||
ViewBag.TotalCount = totalCount;
|
ViewBag.TotalCount = totalCount;
|
||||||
ViewBag.PageNumber = pageNumber;
|
ViewBag.PageNumber = pageNumber;
|
||||||
ViewBag.PageSize = pageSize;
|
ViewBag.PageSize = pageSize;
|
||||||
ViewBag.TotalPages = (int)Math.Ceiling((double)totalCount / pageSize);
|
ViewBag.TotalPages = (int)Math.Ceiling((double)totalCount / pageSize);
|
||||||
return View(items);
|
return View(vm);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// AJAX endpoint that returns the 20 most recent notifications (read and unread) for the bell dropdown.
|
/// AJAX endpoint for the bell dropdown — returns the 20 most recent notifications.
|
||||||
|
/// ORDER BY and TAKE run in SQL; no full-table load.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> Recent()
|
public async Task<IActionResult> Recent()
|
||||||
{
|
{
|
||||||
var all = _tenant.IsPlatformAdmin()
|
var isPlatformAdmin = _tenant.IsPlatformAdmin();
|
||||||
? (await _unitOfWork.InAppNotifications.FindAsync(
|
var items = await _unitOfWork.InAppNotifications.GetRecentAsync(isPlatformAdmin, take: 20);
|
||||||
n => !n.IsDeleted && n.CompanyId == 0, ignoreQueryFilters: true)).ToList()
|
|
||||||
: (await _unitOfWork.InAppNotifications.GetAllAsync()).ToList();
|
|
||||||
|
|
||||||
var tz = ViewBag.CompanyTimeZone as string;
|
var tz = ViewBag.CompanyTimeZone as string;
|
||||||
var items = all
|
|
||||||
.OrderByDescending(n => n.CreatedAt)
|
|
||||||
.Take(20)
|
|
||||||
.Select(n => new { n.Id, n.Title, n.Message, n.Link, n.NotificationType, n.IsRead, n.CreatedAt })
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var unreadCount = items.Count(n => !n.IsRead);
|
var unreadCount = items.Count(n => !n.IsRead);
|
||||||
return Json(new { count = unreadCount, items = items.Select(n => new {
|
return Json(new
|
||||||
|
{
|
||||||
|
count = unreadCount,
|
||||||
|
items = items.Select(n => new
|
||||||
|
{
|
||||||
n.Id, n.Title, n.Message, n.Link, n.NotificationType, n.IsRead,
|
n.Id, n.Title, n.Message, n.Link, n.NotificationType, n.IsRead,
|
||||||
CreatedAt = n.CreatedAt.Tz(tz).ToString("MMM d, h:mm tt")
|
CreatedAt = n.CreatedAt.Tz(tz).ToString("MMM d, h:mm tt")
|
||||||
}) });
|
})
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// AJAX endpoint that returns only unread notifications (up to 20) plus an unread count for the bell badge.
|
/// AJAX endpoint for the bell badge — returns unread count plus up to 20 unread items.
|
||||||
|
/// COUNT and ORDER BY / TAKE run in SQL; no full-table load.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> Unread()
|
public async Task<IActionResult> Unread()
|
||||||
{
|
{
|
||||||
var items = _tenant.IsPlatformAdmin()
|
var isPlatformAdmin = _tenant.IsPlatformAdmin();
|
||||||
? (await _unitOfWork.InAppNotifications.FindAsync(
|
var (items, unreadCount) = await _unitOfWork.InAppNotifications
|
||||||
n => !n.IsDeleted && n.CompanyId == 0 && !n.IsRead, ignoreQueryFilters: true))
|
.GetUnreadAsync(isPlatformAdmin, take: 20);
|
||||||
.OrderByDescending(n => n.CreatedAt).Take(20).ToList()
|
|
||||||
: (await _unitOfWork.InAppNotifications.FindAsync(n => !n.IsRead))
|
|
||||||
.OrderByDescending(n => n.CreatedAt).Take(20).ToList();
|
|
||||||
|
|
||||||
var tz = ViewBag.CompanyTimeZone as string;
|
var tz = ViewBag.CompanyTimeZone as string;
|
||||||
return Json(new { count = items.Count, items = items.Select(n => new {
|
return Json(new
|
||||||
|
{
|
||||||
|
count = unreadCount,
|
||||||
|
items = items.Select(n => new
|
||||||
|
{
|
||||||
n.Id, n.Title, n.Message, n.Link, n.NotificationType,
|
n.Id, n.Title, n.Message, n.Link, n.NotificationType,
|
||||||
CreatedAt = n.CreatedAt.Tz(tz).ToString("MMM d, h:mm tt")
|
CreatedAt = n.CreatedAt.Tz(tz).ToString("MMM d, h:mm tt")
|
||||||
}) });
|
})
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Marks a single notification as read. Tenant isolation is enforced manually for SuperAdmins (CompanyId 0 filter) because IgnoreQueryFilters bypasses the global company filter.
|
/// Marks a single notification as read. Tenant isolation is enforced manually for SuperAdmins
|
||||||
|
/// (CompanyId 0 filter) because IgnoreQueryFilters bypasses the global company filter.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> MarkRead(int id)
|
public async Task<IActionResult> MarkRead(int id)
|
||||||
|
|||||||
Reference in New Issue
Block a user