Skip to content

Commit a985d76

Browse files
committed
Refactor GetAuditSummaryQueryHandler due to long loading of screen.
Add Infrastructure for Localization
1 parent 2f206e3 commit a985d76

10 files changed

Lines changed: 266 additions & 64 deletions

File tree

src/BuildingBlocks/Persistence/Pagination/PaginationExtensions.cs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,38 @@ public static Task<PagedResponse<T>> ToPagedResponseAsync<T>(
4949
return ToPagedResponseInternalAsync(source, pageNumber, pageSize, cancellationToken);
5050
}
5151

52+
/// <summary>
53+
/// Counts on the raw <typeparamref name="TSource"/> query and projects only the paged subset to
54+
/// <typeparamref name="TResult"/>, producing a simpler COUNT(*) and a narrower SELECT.
55+
/// </summary>
56+
public static Task<PagedResponse<TResult>> ToPagedResponseAsync<TSource, TResult>(
57+
this IQueryable<TSource> source,
58+
Expression<Func<TSource, TResult>> projection,
59+
IPagedQuery pagination,
60+
CancellationToken cancellationToken = default)
61+
where TSource : class
62+
where TResult : class
63+
{
64+
ArgumentNullException.ThrowIfNull(source);
65+
ArgumentNullException.ThrowIfNull(projection);
66+
ArgumentNullException.ThrowIfNull(pagination);
67+
68+
var pageNumber = pagination.PageNumber is null or <= 0
69+
? 1
70+
: pagination.PageNumber.Value;
71+
72+
var pageSize = pagination.PageSize is null or <= 0
73+
? DefaultPageSize
74+
: pagination.PageSize.Value;
75+
76+
if (pageSize > MaxPageSize)
77+
{
78+
pageSize = MaxPageSize;
79+
}
80+
81+
return ToPagedResponseInternalAsync(source, projection, pageNumber, pageSize, cancellationToken);
82+
}
83+
5284
private static async Task<PagedResponse<T>> ToPagedResponseInternalAsync<T>(
5385
IQueryable<T> source,
5486
int pageNumber,
@@ -84,4 +116,45 @@ private static async Task<PagedResponse<T>> ToPagedResponseInternalAsync<T>(
84116
TotalPages = totalPages
85117
};
86118
}
119+
120+
// COUNT on TSource (no projection columns in the count query),
121+
// Skip/Take on TSource, then project — avoids SELECT COUNT(*) FROM (SELECT col1, col2...) subquery.
122+
private static async Task<PagedResponse<TResult>> ToPagedResponseInternalAsync<TSource, TResult>(
123+
IQueryable<TSource> source,
124+
Expression<Func<TSource, TResult>> projection,
125+
int pageNumber,
126+
int pageSize,
127+
CancellationToken cancellationToken)
128+
where TSource : class
129+
where TResult : class
130+
{
131+
var totalCount = await source.LongCountAsync(cancellationToken).ConfigureAwait(false);
132+
133+
var totalPages = totalCount == 0
134+
? 0
135+
: (int)Math.Ceiling(totalCount / (double)pageSize);
136+
137+
if (pageNumber > totalPages && totalPages > 0)
138+
{
139+
pageNumber = totalPages;
140+
}
141+
142+
var skip = (pageNumber - 1) * pageSize;
143+
144+
var items = await source
145+
.Skip(skip)
146+
.Take(pageSize)
147+
.Select(projection)
148+
.ToListAsync(cancellationToken)
149+
.ConfigureAwait(false);
150+
151+
return new PagedResponse<TResult>
152+
{
153+
Items = items,
154+
PageNumber = pageNumber,
155+
PageSize = pageSize,
156+
TotalCount = totalCount,
157+
TotalPages = totalPages
158+
};
159+
}
87160
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
namespace FSH.Framework.Shared.Localization;
2+
3+
/// <summary>
4+
/// Defines supported cultures and localization defaults for the application.
5+
/// </summary>
6+
public static class LocalizationConstants
7+
{
8+
public const string DefaultCulture = "en-US";
9+
public const string CultureCookieName = ".FSH.Culture";
10+
11+
/// <summary>Cultures supported across all projects.</summary>
12+
public static readonly string[] SupportedCultures =
13+
[
14+
"en-US"
15+
];
16+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
using Microsoft.AspNetCore.Builder;
2+
using Microsoft.AspNetCore.Localization;
3+
using Microsoft.Extensions.DependencyInjection;
4+
5+
namespace FSH.Framework.Shared.Localization;
6+
7+
/// <summary>
8+
/// Extension methods to wire up localization in both the API and Blazor hosts.
9+
/// </summary>
10+
public static class LocalizationExtensions
11+
{
12+
/// <summary>
13+
/// Registers localization services. Call in IServiceCollection setup.
14+
/// </summary>
15+
public static IServiceCollection AddFshLocalization(this IServiceCollection services)
16+
{
17+
services.AddLocalization();
18+
return services;
19+
}
20+
21+
/// <summary>
22+
/// Adds request localization middleware using <see cref="LocalizationConstants.SupportedCultures"/>.
23+
/// Culture is resolved in order: cookie → Accept-Language header → default.
24+
/// </summary>
25+
public static IApplicationBuilder UseFshLocalization(this IApplicationBuilder app)
26+
{
27+
var options = new RequestLocalizationOptions()
28+
.SetDefaultCulture(LocalizationConstants.DefaultCulture)
29+
.AddSupportedCultures(LocalizationConstants.SupportedCultures)
30+
.AddSupportedUICultures(LocalizationConstants.SupportedCultures);
31+
32+
// Cookie provider allows per-user culture selection
33+
options.RequestCultureProviders.Insert(0, new CookieRequestCultureProvider
34+
{
35+
CookieName = LocalizationConstants.CultureCookieName
36+
});
37+
38+
app.UseRequestLocalization(options);
39+
return app;
40+
}
41+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
namespace FSH.Framework.Shared.Localization;
2+
3+
/// <summary>
4+
/// Marker class for shared localization resources.
5+
/// Pair this with SharedResource.resx (and per-culture variants, e.g. SharedResource.af-ZA.resx).
6+
/// Inject as IStringLocalizer&lt;SharedResource&gt; wherever shared strings are needed.
7+
/// </summary>
8+
public sealed class SharedResource;
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<root>
3+
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
4+
<xsd:element name="root" msdata:IsDataSet="true">
5+
</xsd:element>
6+
</xsd:schema>
7+
<resheader name="resmimetype">
8+
<value>text/microsoft-resx</value>
9+
</resheader>
10+
<resheader name="version">
11+
<value>2.0</value>
12+
</resheader>
13+
<resheader name="reader">
14+
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
15+
</resheader>
16+
<resheader name="writer">
17+
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
18+
</resheader>
19+
20+
<!-- Common validation messages -->
21+
<data name="Required" xml:space="preserve">
22+
<value>{0} is required.</value>
23+
</data>
24+
<data name="MaxLength" xml:space="preserve">
25+
<value>{0} must not exceed {1} characters.</value>
26+
</data>
27+
<data name="MinLength" xml:space="preserve">
28+
<value>{0} must be at least {1} characters.</value>
29+
</data>
30+
<data name="InvalidEmail" xml:space="preserve">
31+
<value>A valid email address is required.</value>
32+
</data>
33+
34+
<!-- Common error messages -->
35+
<data name="Unauthorized" xml:space="preserve">
36+
<value>Authentication failed.</value>
37+
</data>
38+
<data name="NotFound" xml:space="preserve">
39+
<value>The requested resource was not found.</value>
40+
</data>
41+
<data name="Forbidden" xml:space="preserve">
42+
<value>You do not have permission to perform this action.</value>
43+
</data>
44+
</root>

src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditSummary/GetAuditSummaryQueryHandler.cs

Lines changed: 37 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,43 @@ public async ValueTask<AuditSummaryAggregateDto> Handle(GetAuditSummaryQuery que
1414
ArgumentNullException.ThrowIfNull(query);
1515

1616
var audits = ApplyFilters(dbContext.AuditRecords.AsNoTracking(), query);
17-
var list = await audits.ToListAsync(cancellationToken).ConfigureAwait(false);
1817

19-
return AggregateRecords(list);
18+
// Push all aggregation to the database via GROUP BY — avoids loading any rows into memory.
19+
// EF Core DbContext is not thread-safe, so queries are run sequentially.
20+
21+
var byType = await audits
22+
.GroupBy(a => a.EventType)
23+
.Select(g => new { EventType = g.Key, Count = g.LongCount() })
24+
.ToDictionaryAsync(g => (AuditEventType)g.EventType, g => g.Count, cancellationToken)
25+
.ConfigureAwait(false);
26+
27+
var bySeverity = await audits
28+
.GroupBy(a => a.Severity)
29+
.Select(g => new { Severity = g.Key, Count = g.LongCount() })
30+
.ToDictionaryAsync(g => (AuditSeverity)g.Severity, g => g.Count, cancellationToken)
31+
.ConfigureAwait(false);
32+
33+
var bySource = await audits
34+
.Where(a => a.Source != null)
35+
.GroupBy(a => a.Source!)
36+
.Select(g => new { Source = g.Key, Count = g.LongCount() })
37+
.ToDictionaryAsync(g => g.Source, g => g.Count, StringComparer.OrdinalIgnoreCase, cancellationToken)
38+
.ConfigureAwait(false);
39+
40+
var byTenant = await audits
41+
.Where(a => a.TenantId != null)
42+
.GroupBy(a => a.TenantId!)
43+
.Select(g => new { TenantId = g.Key, Count = g.LongCount() })
44+
.ToDictionaryAsync(g => g.TenantId, g => g.Count, StringComparer.OrdinalIgnoreCase, cancellationToken)
45+
.ConfigureAwait(false);
46+
47+
return new AuditSummaryAggregateDto
48+
{
49+
EventsByType = byType,
50+
EventsBySeverity = bySeverity,
51+
EventsBySource = bySource,
52+
EventsByTenant = byTenant
53+
};
2054
}
2155

2256
private static IQueryable<AuditRecord> ApplyFilters(IQueryable<AuditRecord> audits, GetAuditSummaryQuery query)
@@ -38,47 +72,4 @@ private static IQueryable<AuditRecord> ApplyFilters(IQueryable<AuditRecord> audi
3872

3973
return audits;
4074
}
41-
42-
private static AuditSummaryAggregateDto AggregateRecords(List<AuditRecord> records)
43-
{
44-
var aggregate = new AuditSummaryAggregateDto();
45-
46-
foreach (var record in records)
47-
{
48-
AggregateByType(aggregate, record);
49-
AggregrateBySeverity(aggregate, record);
50-
AggregateBySource(aggregate, record);
51-
AggregateByTenant(aggregate, record);
52-
}
53-
54-
return aggregate;
55-
}
56-
57-
private static void AggregateByType(AuditSummaryAggregateDto aggregate, AuditRecord record)
58-
{
59-
var type = (AuditEventType)record.EventType;
60-
aggregate.EventsByType[type] = aggregate.EventsByType.TryGetValue(type, out var c) ? c + 1 : 1;
61-
}
62-
63-
private static void AggregrateBySeverity(AuditSummaryAggregateDto aggregate, AuditRecord record)
64-
{
65-
var severity = (AuditSeverity)record.Severity;
66-
aggregate.EventsBySeverity[severity] = aggregate.EventsBySeverity.TryGetValue(severity, out var s) ? s + 1 : 1;
67-
}
68-
69-
private static void AggregateBySource(AuditSummaryAggregateDto aggregate, AuditRecord record)
70-
{
71-
if (!string.IsNullOrWhiteSpace(record.Source))
72-
{
73-
aggregate.EventsBySource[record.Source] = aggregate.EventsBySource.TryGetValue(record.Source, out var cs) ? cs + 1 : 1;
74-
}
75-
}
76-
77-
private static void AggregateByTenant(AuditSummaryAggregateDto aggregate, AuditRecord record)
78-
{
79-
if (!string.IsNullOrWhiteSpace(record.TenantId))
80-
{
81-
aggregate.EventsByTenant[record.TenantId] = aggregate.EventsByTenant.TryGetValue(record.TenantId, out var ct) ? ct + 1 : 1;
82-
}
83-
}
84-
}
75+
}

src/Modules/Auditing/Modules.Auditing/Features/v1/GetAudits/GetAuditsQueryHandler.cs

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -83,22 +83,23 @@ public async ValueTask<PagedResponse<AuditSummaryDto>> Handle(GetAuditsQuery que
8383

8484
audits = audits.OrderByDescending(a => a.OccurredAtUtc);
8585

86-
IQueryable<AuditSummaryDto> projected = audits.Select(a => new AuditSummaryDto
87-
{
88-
Id = a.Id,
89-
OccurredAtUtc = a.OccurredAtUtc,
90-
EventType = (AuditEventType)a.EventType,
91-
Severity = (AuditSeverity)a.Severity,
92-
TenantId = a.TenantId,
93-
UserId = a.UserId,
94-
UserName = a.UserName,
95-
TraceId = a.TraceId,
96-
CorrelationId = a.CorrelationId,
97-
RequestId = a.RequestId,
98-
Source = a.Source,
99-
Tags = (AuditTag)a.Tags
100-
});
101-
102-
return await projected.ToPagedResponseAsync(query, cancellationToken).ConfigureAwait(false);
86+
return await audits.ToPagedResponseAsync(
87+
a => new AuditSummaryDto
88+
{
89+
Id = a.Id,
90+
OccurredAtUtc = a.OccurredAtUtc,
91+
EventType = (AuditEventType)a.EventType,
92+
Severity = (AuditSeverity)a.Severity,
93+
TenantId = a.TenantId,
94+
UserId = a.UserId,
95+
UserName = a.UserName,
96+
TraceId = a.TraceId,
97+
CorrelationId = a.CorrelationId,
98+
RequestId = a.RequestId,
99+
Source = a.Source,
100+
Tags = (AuditTag)a.Tags
101+
},
102+
query,
103+
cancellationToken).ConfigureAwait(false);
103104
}
104105
}

src/Playground/FSH.Api/Program.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using FSH.Framework.Web;
1+
using FSH.Framework.Shared.Localization;
2+
using FSH.Framework.Web;
23
using FSH.Framework.Web.Modules;
34
using FSH.Modules.Auditing;
45
using FSH.Modules.Identity;
@@ -50,6 +51,8 @@ static void Require(IConfiguration config, string key)
5051
typeof(WebhooksModule).Assembly
5152
};
5253

54+
builder.Services.AddFshLocalization();
55+
5356
builder.AddHeroPlatform(o =>
5457
{
5558
o.EnableCaching = true;
@@ -61,6 +64,7 @@ static void Require(IConfiguration config, string key)
6164
var app = builder.Build();
6265

6366
app.UseHeroMultiTenantDatabases();
67+
app.UseFshLocalization();
6468
app.UseHeroPlatform(p =>
6569
{
6670
p.MapModules = true;

src/Playground/Playground.Blazor/Program.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using FSH.Framework.Shared.Localization;
12
using FSH.Framework.Blazor.UI;
23
using FSH.Framework.Blazor.UI.Theme;
34
using FSH.Playground.Blazor;
@@ -118,6 +119,8 @@
118119
.Expire(TimeSpan.FromSeconds(10)));
119120
});
120121

122+
builder.Services.AddFshLocalization();
123+
121124
builder.Services.AddRazorComponents()
122125
.AddInteractiveServerComponents();
123126

@@ -140,6 +143,7 @@
140143
app.MapGet("/health/live", () => Results.Ok(new { status = "Alive" }))
141144
.AllowAnonymous();
142145

146+
app.UseFshLocalization();
143147
app.UseResponseCompression(); // Must come before UseStaticFiles
144148
app.UseOutputCache();
145149
app.UseHttpsRedirection();

0 commit comments

Comments
 (0)