Skip to content

Commit 7730e57

Browse files
committed
Add product feature with CRUD operations, pagination, and MudDataGrid integration
1 parent b1dd40f commit 7730e57

File tree

60 files changed

+2923
-335
lines changed

Some content is hidden

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

60 files changed

+2923
-335
lines changed

.editorconfig

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,9 @@ dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggesti
173173
dotnet_style_prefer_auto_properties = true:silent
174174
dotnet_style_prefer_simplified_boolean_expressions = true:suggestion
175175
dotnet_style_prefer_conditional_expression_over_assignment = true:silent
176+
dotnet_style_prefer_inferred_tuple_names = true:suggestion
177+
dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
178+
dotnet_style_namespace_match_folder = true:suggestion
176179

177180
# CSharp code style settings:
178181
[*.cs]
@@ -256,7 +259,7 @@ csharp_preserve_single_line_statements = true
256259
dotnet_diagnostic.IDE0060.severity = warning
257260
csharp_using_directive_placement = outside_namespace:silent
258261
csharp_prefer_simple_using_statement = true:suggestion
259-
csharp_style_namespace_declarations = block_scoped:silent
262+
csharp_style_namespace_declarations = file_scoped:suggestion
260263
csharp_style_prefer_method_group_conversion = true:silent
261264
csharp_style_prefer_top_level_statements = true:silent
262265
csharp_style_prefer_primary_constructors = true:suggestion

src/CleanAspire.Api/Endpoints/FileUploadEndpointRegistrar.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
using Microsoft.AspNetCore.StaticFiles;
1313
using Microsoft.AspNetCore.Mvc;
1414
using Microsoft.AspNetCore.Http.HttpResults;
15-
using System.Security.Claims;
1615

1716
namespace CleanAspire.Api.Endpoints;
1817

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using CleanAspire.Application.Common.Models;
6+
using CleanAspire.Application.Features.Products.Commands;
7+
using CleanAspire.Application.Features.Products.DTOs;
8+
using CleanAspire.Application.Features.Products.Queries;
9+
using Mediator;
10+
using Microsoft.AspNetCore.Mvc;
11+
12+
namespace CleanAspire.Api.Endpoints;
13+
14+
public class ProductEndpointRegistrar : IEndpointRegistrar
15+
{
16+
public void RegisterRoutes(IEndpointRouteBuilder routes)
17+
{
18+
var group = routes.MapGroup("/products").WithTags("products").AllowAnonymous();
19+
20+
// Get all products
21+
group.MapGet("/", async ([FromServices] IMediator mediator) =>
22+
{
23+
var query = new GetAllProductsQuery();
24+
return await mediator.Send(query);
25+
})
26+
.Produces<IEnumerable<ProductDto>>()
27+
.WithSummary("Get all products")
28+
.WithDescription("Returns a list of all products in the system.");
29+
30+
// Get product by ID
31+
group.MapGet("/{id}", (IMediator mediator, [FromRoute] string id) => mediator.Send(new GetProductByIdQuery(id)))
32+
.Produces<ProductDto>()
33+
.WithSummary("Get product by ID")
34+
.WithDescription("Returns the details of a specific product by its unique ID.");
35+
36+
// Create a new product
37+
group.MapPost("/", ([FromServices] IMediator mediator, [FromBody] CreateProductCommand command) => mediator.Send(command))
38+
.WithSummary("Create a new product")
39+
.WithDescription("Creates a new product with the provided details.");
40+
41+
// Update an existing product
42+
group.MapPut("/", ([FromServices] IMediator mediator, [FromBody] UpdateProductCommand command) => mediator.Send(command))
43+
.WithSummary("Update an existing product")
44+
.WithDescription("Updates the details of an existing product.");
45+
46+
// Delete products by IDs
47+
group.MapDelete("/", (IMediator mediator, [FromQuery] string[] ids) => mediator.Send(new DeleteProductCommand(ids)))
48+
.WithSummary("Delete products by IDs")
49+
.WithDescription("Deletes one or more products by their unique IDs.");
50+
51+
// Get products with pagination and filtering
52+
group.MapPost("/pagination", ([FromServices] IMediator mediator, [FromBody] ProductsWithPaginationQuery query) => mediator.Send(query))
53+
.Produces<PaginatedResult<ProductDto>>()
54+
.WithSummary("Get products with pagination")
55+
.WithDescription("Returns a paginated list of products based on search keywords, page size, and sorting options.");
56+
}
57+
}
58+

src/CleanAspire.Api/IdentityApiAdditionalEndpointsExtensions.cs

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,6 @@
1313
using Microsoft.AspNetCore.WebUtilities;
1414
using System.Text.Encodings.Web;
1515
using System.Text;
16-
using CleanAspire.Application.Common.Interfaces;
17-
using SixLabors.ImageSharp.Formats.Png;
18-
using SixLabors.ImageSharp;
19-
using SixLabors.ImageSharp.Processing;
2016
namespace CleanAspire.Api;
2117

2218
public static class IdentityApiAdditionalEndpointsExtensions
@@ -31,16 +27,17 @@ public static IEndpointRouteBuilder MapIdentityApiAdditionalEndpoints<TUser>(thi
3127
var emailSender = endpoints.ServiceProvider.GetRequiredService<IEmailSender<TUser>>();
3228
var linkGenerator = endpoints.ServiceProvider.GetRequiredService<LinkGenerator>();
3329
string? confirmEmailEndpointName = null;
34-
var identityGroup = endpoints.MapGroup("/identity").RequireAuthorization().WithTags("Authentication", "Identity Management");
35-
identityGroup.MapPost("/logout", async (SignInManager<TUser> signInManager) =>
30+
var routeGroup = endpoints.MapGroup("/account").WithTags("Authentication", "Account Management");
31+
routeGroup.MapPost("/logout", async (SignInManager<TUser> signInManager) =>
3632
{
3733
await signInManager.SignOutAsync();
3834
return Results.Ok();
3935
})
36+
.RequireAuthorization()
4037
.WithSummary("Log out the current user.")
4138
.WithDescription("Logs out the currently authenticated user by signing them out of the system. This endpoint requires the user to be authorized before calling, and returns an HTTP 200 OK response upon successful logout.");
4239

43-
identityGroup.MapGet("/profile", async Task<Results<Ok<ProfileResponse>, ValidationProblem, NotFound>>
40+
routeGroup.MapGet("/profile", async Task<Results<Ok<ProfileResponse>, ValidationProblem, NotFound>>
4441
(ClaimsPrincipal claimsPrincipal, HttpContext context, IServiceProvider sp) =>
4542
{
4643
var userManager = sp.GetRequiredService<UserManager<TUser>>();
@@ -50,10 +47,11 @@ public static IEndpointRouteBuilder MapIdentityApiAdditionalEndpoints<TUser>(thi
5047
}
5148
return TypedResults.Ok(await CreateInfoResponseAsync(user, userManager));
5249
})
50+
.RequireAuthorization()
5351
.WithSummary("Retrieve the user's profile")
5452
.WithDescription("Fetches the profile information of the authenticated user. " +
5553
"Returns 404 if the user is not found. Requires authorization.");
56-
identityGroup.MapPost("/profile", async Task<Results<Ok<ProfileResponse>, ValidationProblem, NotFound>>
54+
routeGroup.MapPost("/profile", async Task<Results<Ok<ProfileResponse>, ValidationProblem, NotFound>>
5755
(ClaimsPrincipal claimsPrincipal, [FromBody] ProfileRequest request, HttpContext context, [FromServices] IServiceProvider sp) =>
5856
{
5957
var userManager = sp.GetRequiredService<UserManager<TUser>>();
@@ -89,12 +87,10 @@ public static IEndpointRouteBuilder MapIdentityApiAdditionalEndpoints<TUser>(thi
8987

9088
return TypedResults.Ok(await CreateInfoResponseAsync(user, userManager));
9189
})
90+
.RequireAuthorization()
9291
.WithSummary("Update user profile information.")
9392
.WithDescription("Allows users to update their profile, including username, email, nickname, avatar, time zone, and language code.");
9493

95-
96-
97-
var routeGroup = endpoints.MapGroup("/account").WithTags("Authentication", "Identity Management");
9894
routeGroup.MapPost("/signup", async Task<Results<Ok, ValidationProblem>>
9995
([FromBody] SignupRequest request, HttpContext context, [FromServices] IServiceProvider sp) =>
10096
{
@@ -120,7 +116,8 @@ public static IEndpointRouteBuilder MapIdentityApiAdditionalEndpoints<TUser>(thi
120116
}
121117
await SendConfirmationEmailAsync(user, userManager, context, request.Email);
122118
return TypedResults.Ok();
123-
}).WithSummary("User Signup")
119+
}).AllowAnonymous()
120+
.WithSummary("User Signup")
124121
.WithDescription("Allows a new user to sign up by providing required details such as email, password, and tenant-specific information. This endpoint creates a new user account and sends a confirmation email for verification.");
125122

126123
routeGroup.MapGet("/confirmEmail", async Task<Results<ContentHttpResult, UnauthorizedHttpResult>>
@@ -157,7 +154,8 @@ public static IEndpointRouteBuilder MapIdentityApiAdditionalEndpoints<TUser>(thi
157154
return TypedResults.Unauthorized();
158155
}
159156
return TypedResults.Text("Thank you for confirming your email.");
160-
}).WithSummary("Confirm Email or Update Email Address")
157+
}).AllowAnonymous()
158+
.WithSummary("Confirm Email or Update Email Address")
161159
.WithDescription("Processes email confirmation or email change requests for a user. It validates the confirmation code, verifies the user ID, and updates the email if a new one is provided. Returns a success message upon successful confirmation or email update.")
162160
.Add(endpointBuilder =>
163161
{

src/CleanAspire.Api/OpenApiTransformersExtensions.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
using Microsoft.AspNetCore.Identity;
88
using Microsoft.AspNetCore.Identity.Data;
99
using Microsoft.AspNetCore.OpenApi;
10-
using Microsoft.IdentityModel.Tokens;
1110
using Microsoft.OpenApi.Any;
1211
using Microsoft.OpenApi.Models;
1312

src/CleanAspire.Api/Program.cs

Lines changed: 1 addition & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,12 @@
11
using System.Text.Json.Serialization;
22
using CleanAspire.Api;
33
using CleanAspire.Application;
4-
using CleanAspire.Application.Common.Interfaces;
54
using CleanAspire.Application.Common.Services;
6-
using CleanAspire.Domain.Entities;
75
using CleanAspire.Domain.Identities;
86
using CleanAspire.Infrastructure;
97
using CleanAspire.Infrastructure.Persistence;
10-
using CleanAspire.Infrastructure.Persistence.Seed;
11-
using CleanAspire.Infrastructure.Services;
128
using Microsoft.AspNetCore.Identity;
13-
using Microsoft.Extensions.DependencyInjection;
14-
using Mono.TextTemplating;
159
using Scalar.AspNetCore;
16-
using Microsoft.AspNetCore.OpenApi;
1710
using Microsoft.OpenApi;
1811
using CleanAspire.Api.Identity;
1912
using Microsoft.Extensions.FileProviders;
@@ -68,6 +61,7 @@
6861
{
6962
// Don't serialize null values
7063
options.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
64+
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
7165
// Pretty print JSON
7266
options.SerializerOptions.WriteIndented = true;
7367
});
@@ -83,27 +77,7 @@
8377
// Configure the HTTP request pipeline.
8478
app.UseExceptionHandler();
8579
app.MapEndpointDefinitions();
86-
var summaries = new[]
87-
{
88-
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
89-
};
9080
app.UseCors("wasm");
91-
app.MapGet("/weatherforecast", () =>
92-
{
93-
94-
var forecast = Enumerable.Range(1, 5).Select(index =>
95-
new WeatherForecast
96-
(
97-
DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
98-
Random.Shared.Next(-20, 55),
99-
summaries[Random.Shared.Next(summaries.Length)]
100-
))
101-
.ToArray();
102-
return forecast;
103-
}).WithTags("Weather")
104-
.WithSummary("Get the weather forecast for the next 5 days.")
105-
.WithDescription("Returns an array of weather forecast data including the date, temperature, and weather summary for the next 5 days. Each forecast entry provides information about the expected temperature and a brief summary of the weather conditions.");
106-
10781
app.Use(async (context, next) =>
10882
{
10983
var currentUserContextSetter = context.RequestServices.GetRequiredService<ICurrentUserContextSetter>();

src/CleanAspire.Application/CleanAspire.Application.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
<ItemGroup>
1010

11+
<PackageReference Include="Mediator.Abstractions" Version="3.0.0-preview.27" />
12+
1113
<PackageReference Include="Mediator.SourceGenerator" Version="3.0.0-preview.27">
1214
<PrivateAssets>all</PrivateAssets>
1315
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

src/CleanAspire.Application/Common/Interfaces/IApplicationDbContext.cs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,4 @@
1-
using CleanAspire.Domain.Entities;
2-
using Microsoft.EntityFrameworkCore;
3-
4-
namespace CleanAspire.Application.Common.Interfaces;
1+
namespace CleanAspire.Application.Common.Interfaces;
52

63
public interface IApplicationDbContext
74
{

src/CleanAspire.Application/Common/Interfaces/IDateTime.cs

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,6 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33
// See the LICENSE file in the project root for more information.
44

5-
using System;
6-
using System.Collections.Generic;
7-
using System.Linq;
8-
using System.Text;
9-
using System.Threading.Tasks;
10-
115
namespace CleanAspire.Application.Common.Interfaces;
126

137
public interface IDateTime
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+

2+
namespace CleanAspire.Application.Common.Models;
3+
public class PaginatedResult<T>
4+
{
5+
public PaginatedResult(IEnumerable<T> items, int total, int pageIndex, int pageSize)
6+
{
7+
Items = items;
8+
TotalItems = total;
9+
CurrentPage = pageIndex;
10+
TotalPages = (int)Math.Ceiling(total / (double)pageSize);
11+
}
12+
13+
public int CurrentPage { get; }
14+
public int TotalItems { get; private set; }
15+
public int TotalPages { get; }
16+
public bool HasPreviousPage => CurrentPage > 1;
17+
public bool HasNextPage => CurrentPage < TotalPages;
18+
public IEnumerable<T> Items { get; set; }
19+
20+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
using System.Linq.Expressions;
2+
using System.Reflection;
3+
using CleanAspire.Domain.Common;
4+
5+
namespace CleanAspire.Application.Common;
6+
public static class QueryableExtensions
7+
{
8+
#region OrderBy
9+
public static IOrderedQueryable<T> OrderBy<T>(this IQueryable<T> source, string orderByProperty)
10+
{
11+
return ApplyOrder(source, orderByProperty, "OrderBy");
12+
}
13+
14+
public static IOrderedQueryable<T> OrderByDescending<T>(this IQueryable<T> source, string orderByProperty)
15+
{
16+
return ApplyOrder(source, orderByProperty, "OrderByDescending");
17+
}
18+
19+
public static IOrderedQueryable<T> ThenBy<T>(this IOrderedQueryable<T> source, string orderByProperty)
20+
{
21+
return ApplyOrder(source, orderByProperty, "ThenBy");
22+
}
23+
24+
public static IOrderedQueryable<T> ThenByDescending<T>(this IOrderedQueryable<T> source, string orderByProperty)
25+
{
26+
return ApplyOrder(source, orderByProperty, "ThenByDescending");
27+
}
28+
29+
private static IOrderedQueryable<T> ApplyOrder<T>(IQueryable<T> source, string property, string methodName)
30+
{
31+
var type = typeof(T);
32+
var propertyInfo = type.GetProperty(property, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
33+
if (propertyInfo == null)
34+
{
35+
throw new ArgumentException($"Property '{property}' does not exist on type '{type.Name}'.");
36+
}
37+
38+
var parameter = Expression.Parameter(type, "x");
39+
var propertyAccess = Expression.MakeMemberAccess(parameter, propertyInfo);
40+
var orderByExpression = Expression.Lambda(propertyAccess, parameter);
41+
42+
var resultExpression = Expression.Call(
43+
typeof(Queryable),
44+
methodName,
45+
new[] { type, propertyInfo.PropertyType },
46+
source.Expression,
47+
Expression.Quote(orderByExpression));
48+
49+
return (IOrderedQueryable<T>)source.Provider.CreateQuery<T>(resultExpression);
50+
}
51+
52+
public static IOrderedQueryable<T> OrderBy<T>(this IQueryable<T> source, string orderBy, string sortDirection)
53+
{
54+
return sortDirection.Equals("Descending", StringComparison.OrdinalIgnoreCase)
55+
? source.OrderByDescending(orderBy)
56+
: source.OrderBy(orderBy);
57+
}
58+
#endregion
59+
public static async Task<PaginatedResult<TResult>> ProjectToPaginatedDataAsync<T, TResult>(
60+
this IOrderedQueryable<T> query,
61+
Expression<Func<T, bool>>? condition,
62+
int pageNumber,
63+
int pageSize,
64+
Func<T, TResult> mapperFunc,
65+
CancellationToken cancellationToken = default) where T : class, IEntity
66+
{
67+
if (condition != null)
68+
{
69+
query = (IOrderedQueryable<T>)query.Where(condition);
70+
}
71+
var count = await query.CountAsync(cancellationToken);
72+
var data = await query
73+
.Skip((pageNumber - 1) * pageSize)
74+
.Take(pageSize)
75+
.ToListAsync(cancellationToken);
76+
77+
var items = data.Select(x => mapperFunc(x)).ToList();
78+
return new PaginatedResult<TResult>(items, count, pageNumber, pageSize);
79+
}
80+
}

src/CleanAspire.Application/DependencyInjection.cs

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,7 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33
// See the LICENSE file in the project root for more information.
44

5-
using System;
6-
using System.Collections.Generic;
7-
using System.Linq;
8-
using System.Reflection;
9-
using System.Text;
10-
using System.Threading.Tasks;
115
using CleanAspire.Application.Pipeline;
12-
using Mediator;
136
using Microsoft.Extensions.DependencyInjection;
147

158
namespace CleanAspire.Application;

0 commit comments

Comments
 (0)