backend phase 0: foundation, cross-cutting seams & starter cleanup

Remove the Order demo (entity/feature/repo/config/gRPC/proto) and the three
pre-marketplace migrations; regenerate a fresh InitialBaseline migration.

Stand up the REST surface (PingController + System/Ping CQRS) proving the
Mediator -> behaviors -> OperationResult -> ApiResult envelope end to end.

Close wiring gaps: register LoggingBehavior (outermost) and add the built-in
rate limiter (per-IP global + otp/auth/sensitive policies), placed before
authentication.

Add current-user + audit plumbing: ICurrentUser (HttpContext + null impls),
rename BaseEntity audit fields to CreatedAt/ModifiedAt (DateTimeOffset) +
CreatedById/ModifiedById, stamped by a new AuditFieldInterceptor.

Introduce five cross-cutting seams (IDateTimeProvider, IFieldEncryptor,
ICacheService, IObjectStorage, INotificationDispatcher) with in-memory/local
mocks registered via AddCrossCuttingSeams.

Add Baya.Test.Foundation (encryptor, audit interceptor, ping handler) and
update docs, contracts (swagger.v1.json), handoff, report, and mocks registry.
This commit is contained in:
hamid
2026-06-30 22:48:41 +03:30
parent 53a40dc51d
commit 765cc632d5
75 changed files with 1539 additions and 1418 deletions
@@ -0,0 +1,29 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.System.Queries.Ping;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Mediator;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Baya.WebFramework.ServiceConfiguration;
namespace Baya.Web.Api.Controllers.V1;
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[Display(Description = "Service liveness and pipeline-smoke endpoints")]
public sealed class PingController(ISender sender) : BaseController
{
[HttpGet("[action]")]
[ProducesOkApiResponseType<PingQueryResult>]
public async Task<IActionResult> GetStatus(CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new PingQuery(), cancellationToken));
[HttpGet("[action]")]
[EnableRateLimiting(RateLimitingServiceExtension.GlobalPolicy)]
[ProducesOkApiResponseType<PingQueryResult>]
public async Task<IActionResult> GetStatusRateLimited(CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new PingQuery(), cancellationToken));
}
+6 -1
View File
@@ -5,6 +5,7 @@ using Baya.Application.Models.Identity;
using Baya.Application.ServiceConfiguration;
using Baya.Domain.Entities.User;
using Baya.Infrastructure.CrossCutting.Logging;
using Baya.Infrastructure.CrossCutting.ServiceConfiguration;
using Baya.Infrastructure.Identity.Identity.Dtos;
using Baya.Infrastructure.Identity.Jwt;
using Baya.Infrastructure.Identity.ServiceConfiguration;
@@ -69,7 +70,9 @@ builder.Services.AddSwagger("v1","v1.1");
builder.Services.AddApplicationServices()
.RegisterIdentityServices(identitySettings)
.AddPersistenceServices(configuration)
.AddWebFrameworkServices();
.AddCrossCuttingSeams(configuration)
.AddWebFrameworkServices()
.AddRateLimitingPolicies();
builder.Services.RegisterValidatorsAsServices();
builder.Services.AddExceptionHandler<ExceptionHandler>();
@@ -105,6 +108,8 @@ app.UseSwaggerAndUi();
app.UseRouting();
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
+10 -1
View File
@@ -1,6 +1,6 @@
{
"ConnectionStrings": {
"SqlServer": "Server=sql_server2022;Database=Baya_DB_Docker;User Id=SA;Password=A&VeryComplex123Password;MultipleActiveResultSets=true;encrypt=false",
"SqlServer": "Server=192.168.100.14,1433;Database=Baya;User Id=sa;Password=Pa##w0rd;TrustServerCertificate=True;Encrypt=False;",
"logDb": "Server=sql_server2022;Database=Baya_Log_DB_Docker;User Id=SA;Password=A&VeryComplex123Password;MultipleActiveResultSets=true;encrypt=false"
},
"IdentitySettings": {
@@ -11,6 +11,15 @@
"NotBeforeMinutes": "0",
"ExpirationMinutes": "10000"
},
"Seams": {
"FieldEncryption": {
"Key": "local-dev-field-encryption-key-change-me",
"HashKey": "local-dev-field-hash-key-change-me"
},
"ObjectStorage": {
"RootPath": ""
}
},
"AllowedHosts": "*",
"Kestrel": {
"EndpointDefaults": {
@@ -20,4 +20,8 @@
<ProjectReference Include="..\..\Infrastructure\Baya.Infrastructure.Persistence\Baya.Infrastructure.Persistence.csproj" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
</Project>
@@ -0,0 +1,71 @@
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.DependencyInjection;
namespace Baya.WebFramework.ServiceConfiguration;
public static class RateLimitingServiceExtension
{
/// <summary>Per-IP baseline applied to every endpoint that doesn't opt into a named policy.</summary>
public const string GlobalPolicy = "global";
/// <summary>Tighter limit for OTP request/verify endpoints (applied in backend-phase-2).</summary>
public const string OtpPolicy = "otp";
/// <summary>Limit for login/refresh and other auth endpoints.</summary>
public const string AuthPolicy = "auth";
/// <summary>Limit for money-sensitive actions (refund/payout) applied in later phases.</summary>
public const string SensitivePolicy = "sensitive";
/// <summary>
/// Registers the built-in rate limiter with a per-IP global limit plus named policies that
/// auth/OTP/sensitive endpoints opt into via <c>[EnableRateLimiting(name)]</c>. Over-limit
/// requests get <c>429 Too Many Requests</c>. Pair with <c>app.UseRateLimiter()</c> placed
/// before <c>app.UseAuthentication()</c>.
/// </summary>
public static IServiceCollection AddRateLimitingPolicies(this IServiceCollection services)
{
services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
RateLimitPartition.GetFixedWindowLimiter(
PartitionKey(context),
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 100,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0
}));
AddFixedWindowPolicy(options, OtpPolicy, permitLimit: 5, windowSeconds: 60);
AddFixedWindowPolicy(options, AuthPolicy, permitLimit: 10, windowSeconds: 60);
AddFixedWindowPolicy(options, SensitivePolicy, permitLimit: 20, windowSeconds: 60);
// A deliberately tiny policy used by the phase-0 ping endpoint to demonstrate 429s.
AddFixedWindowPolicy(options, GlobalPolicy, permitLimit: 5, windowSeconds: 10);
});
return services;
}
private static void AddFixedWindowPolicy(RateLimiterOptions options, string name, int permitLimit, int windowSeconds)
{
options.AddPolicy(name, context =>
RateLimitPartition.GetFixedWindowLimiter(
PartitionKey(context),
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = permitLimit,
Window = TimeSpan.FromSeconds(windowSeconds),
QueueLimit = 0
}));
}
private static string PartitionKey(HttpContext context) =>
context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
}
@@ -16,7 +16,6 @@
<ItemGroup>
<Protobuf Include="ProtoModels\UserGrpcServiceModels.proto" GrpcServices="Server" />
<Protobuf Include="ProtoModels\OrderGrpcServiceModels.proto" GrpcServices="Server" />
</ItemGroup>
@@ -21,7 +21,6 @@ public static class GrpcPluginStartup
{
app.MapGrpcService<UserGrpcServices>();
app.MapGrpcService<OrderGrpcServices>();
app.MapGrpcReflectionService();
app.MapGet("/GrpcUser", async context =>
@@ -29,11 +28,5 @@ public static class GrpcPluginStartup
await context.Response.WriteAsync(
"Communication with this gRPC endpoint must be made through a gRPC client.");
});
app.MapGet("/GrpcUserOrder", async context =>
{
await context.Response.WriteAsync(
"Communication with this gRPC endpoint must be made through a gRPC client.");
});
}
}
@@ -1,17 +0,0 @@
syntax = "proto3";
option csharp_namespace = "Baya.Web.Plugins.Grpc.ProtoModels";
import "google/protobuf/empty.proto";
package GrpcOrderController;
service OrderServices {
rpc GetUserOrders(google.protobuf.Empty) returns (stream GetUserOrdersModel);
}
message GetUserOrdersModel{
int32 OrderId=1;
string OrderName=2;
}
@@ -1,46 +0,0 @@
using Baya.Application.Features.Order.Queries.GetUserOrders;
using Baya.SharedKernel.Extensions;
using Baya.Web.Plugins.Grpc.ProtoModels;
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using Mediator;
using Microsoft.AspNetCore.Authorization;
namespace Baya.Web.Plugins.Grpc.Services
{
[Authorize]
public class OrderGrpcServices:OrderServices.OrderServicesBase
{
private readonly ISender _sender;
public OrderGrpcServices(ISender sender)
{
_sender = sender;
}
public override async Task GetUserOrders(Empty request, IServerStreamWriter<GetUserOrdersModel> responseStream, ServerCallContext context)
{
var userId = int.Parse(context.GetHttpContext().User.Identity.GetUserId());
var query = await _sender.Send(new GetUserOrdersQueryModel(userId));
if (!query.IsSuccess)
{
context.Status = new Status(StatusCode.InvalidArgument, query.GetErrorMessage());
return;
}
foreach (var getUsersQueryResultModel in query.Result)
{
await responseStream.WriteAsync(new GetUserOrdersModel()
{ OrderId = getUsersQueryResultModel.OrderId, OrderName = getUsersQueryResultModel.OrderName });
await Task.Delay(400);
}
}
}
}
@@ -10,6 +10,9 @@
<EmbeddedResource Remove="Common\ValidationBase\**" />
<None Remove="Common\ValidationBase\**" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Baya.Test.Foundation" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Baya.Domain\Baya.Domain.csproj" />
<PackageReference Include="Mediator.SourceGenerator">
@@ -0,0 +1,21 @@
#nullable enable
namespace Baya.Application.Contracts.Common;
/// <summary>
/// Typed caching seam for read-heavy and reference data. The mock is backed by in-process memory; the
/// real implementation swaps to Redis while keeping the same key/TTL scheme.
/// </summary>
public interface ICacheService
{
ValueTask<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default);
ValueTask SetAsync<T>(string key, T value, TimeSpan? ttl = null, CancellationToken cancellationToken = default);
ValueTask RemoveAsync(string key, CancellationToken cancellationToken = default);
ValueTask<T> GetOrCreateAsync<T>(
string key,
Func<CancellationToken, ValueTask<T>> factory,
TimeSpan? ttl = null,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,15 @@
namespace Baya.Application.Contracts.Common;
/// <summary>
/// Ambient accessor for the authenticated caller, wrapping the HTTP context behind an Application
/// contract. Registered Scoped. A null-object implementation serves non-HTTP contexts (jobs, tests)
/// so the audit interceptor and handlers never depend on <c>IHttpContextAccessor</c> directly.
/// </summary>
public interface ICurrentUser
{
int? UserId { get; }
bool IsAuthenticated { get; }
IReadOnlyList<string> Roles { get; }
}
@@ -0,0 +1,10 @@
namespace Baya.Application.Contracts.Common;
/// <summary>
/// Abstracts the system clock so handlers and the audit interceptor never call <c>DateTime.Now</c>
/// directly and time can be frozen in tests.
/// </summary>
public interface IDateTimeProvider
{
DateTimeOffset UtcNow { get; }
}
@@ -0,0 +1,20 @@
namespace Baya.Application.Contracts.Common;
/// <summary>
/// Seam for field-level PII encryption (phone, national id, IBAN, addresses, clinical notes).
/// Every encrypted-at-rest column flows through this. The mock uses a local symmetric key; the real
/// implementation swaps to a KMS / column-encryption / Key Vault provider without touching callers.
/// Implementations must never log or surface plaintext.
/// </summary>
public interface IFieldEncryptor
{
string Encrypt(string plaintext);
string Decrypt(string ciphertext);
/// <summary>
/// Deterministic keyed hash for equality lookups on encrypted columns (e.g. <c>iban_hash</c>) —
/// the same input always yields the same hash so it can be indexed and queried.
/// </summary>
string Hash(string value);
}
@@ -0,0 +1,32 @@
namespace Baya.Application.Contracts.Common;
/// <summary>
/// The channel a notification is delivered through. Only <see cref="InApp"/> is wired now; SMS/push
/// arrive in later phases behind the same seam.
/// </summary>
public enum NotificationChannel
{
InApp,
Sms,
Push
}
/// <summary>A notification to dispatch to a recipient over one channel.</summary>
/// <param name="RecipientUserId">The target user.</param>
/// <param name="Channel">Delivery channel.</param>
/// <param name="Title">Short title/subject.</param>
/// <param name="Body">Message body (no secrets/PII in logs).</param>
public sealed record Notification(
int RecipientUserId,
NotificationChannel Channel,
string Title,
string Body);
/// <summary>
/// Seam for emitting notifications from domains like booking and payments. The mock logs/no-ops; the
/// real in-app write lands in backend-phase-15, with SMS/push added behind the same interface.
/// </summary>
public interface INotificationDispatcher
{
ValueTask DispatchAsync(Notification notification, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,22 @@
#nullable enable
namespace Baya.Application.Contracts.Common;
/// <summary>
/// Blob/object storage seam keyed by an opaque storage key. The mock stores blobs on local disk; the
/// real implementation swaps to MinIO/S3/ArvanCloud (presigned URLs) without changing callers.
/// </summary>
public interface IObjectStorage
{
ValueTask PutAsync(
string key,
Stream content,
string contentType,
CancellationToken cancellationToken = default);
ValueTask<Stream?> GetAsync(string key, CancellationToken cancellationToken = default);
ValueTask DeleteAsync(string key, CancellationToken cancellationToken = default);
/// <summary>A retrievable URL for the stored object (presigned by the real provider).</summary>
string GetUrl(string key);
}
@@ -1,12 +0,0 @@
using Baya.Domain.Entities.Order;
namespace Baya.Application.Contracts.Persistence;
public interface IOrderRepository
{
Task AddOrderAsync(Order order);
Task<List<Order>> GetAllUserOrdersAsync(int userId);
Task<List<Order>> GetAllOrdersWithRelatedUserAsync();
Task<Order> GetUserOrderByIdAndUserIdAsync(int userId,int orderId,bool trackEntity);
Task DeleteUserOrdersAsync(int userId);
}
@@ -3,7 +3,6 @@
public interface IUnitOfWork
{
public IUserRefreshTokenRepository UserRefreshTokenRepository { get; }
public IOrderRepository OrderRepository { get; }
Task CommitAsync();
ValueTask RollBackAsync();
}
@@ -1,25 +0,0 @@
using Baya.Application.Contracts.Identity;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Order.Commands;
internal class AddOrderCommandHandler(IUnitOfWork unitOfWork, IAppUserManager userManager)
: IRequestHandler<AddOrderCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(AddOrderCommand request, CancellationToken cancellationToken)
{
var user = await userManager.GetUserByIdAsync(request.UserId);
if(user==null)
return OperationResult<bool>.FailureResult("User Not Found");
await unitOfWork.OrderRepository.AddOrderAsync(new Domain.Entities.Order.Order()
{ UserId = user.Id, OrderName = request.OrderName });
await unitOfWork.CommitAsync();
return OperationResult<bool>.SuccessResult(true);
}
}
@@ -1,25 +0,0 @@
using System.Text.Json.Serialization;
using Baya.Application.Models.Common;
using Baya.SharedKernel.ValidationBase;
using Baya.SharedKernel.ValidationBase.Contracts;
using FluentValidation;
using Mediator;
namespace Baya.Application.Features.Order.Commands;
public record AddOrderCommand( string OrderName) : IRequest<OperationResult<bool>>,
IValidatableModel<AddOrderCommand>
{
[JsonIgnore]
public int UserId { get; set; }
public IValidator<AddOrderCommand> ValidateApplicationModel(ApplicationBaseValidationModelProvider<AddOrderCommand> validator)
{
validator.RuleFor(c => c.OrderName)
.NotEmpty()
.NotNull()
.WithMessage("Please enter your role name");
return validator;
}
}
@@ -1,15 +0,0 @@
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Order.Commands;
public class DeleteUserOrdersCommandHandler(IUnitOfWork unitOfWork) : IRequestHandler<DeleteUserOrdersCommand,OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(DeleteUserOrdersCommand request, CancellationToken cancellationToken)
{
await unitOfWork.OrderRepository.DeleteUserOrdersAsync(request.UserId);
return OperationResult<bool>.SuccessResult(true);
}
}
@@ -1,6 +0,0 @@
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Order.Commands;
public record DeleteUserOrdersCommand(int UserId):IRequest<OperationResult<bool>>;
@@ -1,25 +0,0 @@
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Order.Commands;
public class UpdateUserOrderCommandHandler(IUnitOfWork unitOfWork) : IRequestHandler<UpdateUserOrderCommand,OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(UpdateUserOrderCommand request, CancellationToken cancellationToken)
{
var order = await unitOfWork.OrderRepository.GetUserOrderByIdAndUserIdAsync(request.UserId, request.OrderId,
true);
if(order is null)
return OperationResult<bool>.NotFoundResult("Specified Order not found");
order.OrderName=request.OrderName;
await unitOfWork.CommitAsync();
return OperationResult<bool>.SuccessResult(true);
}
}
@@ -1,23 +0,0 @@
using System.Text.Json.Serialization;
using Baya.Application.Models.Common;
using Baya.SharedKernel.ValidationBase;
using Baya.SharedKernel.ValidationBase.Contracts;
using FluentValidation;
using Mediator;
namespace Baya.Application.Features.Order.Commands;
public record UpdateUserOrderCommand
(int OrderId, string OrderName) : IRequest<OperationResult<bool>>,IValidatableModel<UpdateUserOrderCommand>
{
[JsonIgnore]
public int UserId { get; set; }
public IValidator<UpdateUserOrderCommand> ValidateApplicationModel(ApplicationBaseValidationModelProvider<UpdateUserOrderCommand> validator)
{
validator.RuleFor(c => c.OrderId).NotEmpty().GreaterThan(0);
validator.RuleFor(c => c.OrderName).NotEmpty().NotNull();
return validator;
}
};
@@ -1,6 +0,0 @@
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Order.Queries.GetAllOrders;
public record GetAllOrdersQuery():IRequest<OperationResult<List<GetAllOrdersQueryResult>>>;
@@ -1,20 +0,0 @@
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using MapsterMapper;
using Mediator;
namespace Baya.Application.Features.Order.Queries.GetAllOrders
{
internal class GetAllOrdersQueryHandler(IUnitOfWork unitOfWork, IMapper mapper)
: IRequestHandler<GetAllOrdersQuery, OperationResult<List<GetAllOrdersQueryResult>>>
{
public async ValueTask<OperationResult<List<GetAllOrdersQueryResult>>> Handle(GetAllOrdersQuery request, CancellationToken cancellationToken)
{
var orders = await unitOfWork.OrderRepository.GetAllOrdersWithRelatedUserAsync();
var result = orders.Select(mapper.Map<Domain.Entities.Order.Order,GetAllOrdersQueryResult>).ToList();
return OperationResult<List<GetAllOrdersQueryResult>>.SuccessResult(result);
}
}
}
@@ -1,19 +0,0 @@
using Mapster;
namespace Baya.Application.Features.Order.Queries.GetAllOrders;
public record GetAllOrdersQueryResult(int OrderId, string OrderName, int OrderOwnerId, string OrderOwnerUserName);
class GetAllOrdersQueryResultMapping : IRegister
{
public void Register(TypeAdapterConfig config)
{
config.NewConfig<Domain.Entities.Order.Order, GetAllOrdersQueryResult>()
.Map(dest => dest.OrderId, src => src.Id)
.Map(dest => dest.OrderName, src => src.OrderName)
.Map(dest => dest.OrderOwnerId, src => src.User.Id)
.Map(dest => dest.OrderOwnerUserName, src => src.User.UserName);
}
}
@@ -1,21 +0,0 @@
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Order.Queries.GetUserOrders;
internal class GetUserOrdersQueryHandler(IUnitOfWork unitOfWork)
: IRequestHandler<GetUserOrdersQueryModel, OperationResult<List<GetUsersQueryResultModel>>>
{
public async ValueTask<OperationResult<List<GetUsersQueryResultModel>>> Handle(GetUserOrdersQueryModel request, CancellationToken cancellationToken)
{
var orders = await unitOfWork.OrderRepository.GetAllUserOrdersAsync(request.UserId);
if(!orders.Any())
return OperationResult<List<GetUsersQueryResultModel>>.NotFoundResult("You Don't Have Any Orders");
var result = orders.Select(c => new GetUsersQueryResultModel(c.Id, c.OrderName));
return OperationResult<List<GetUsersQueryResultModel>>.SuccessResult(result.ToList());
}
}
@@ -1,6 +0,0 @@
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Order.Queries.GetUserOrders;
public record GetUserOrdersQueryModel(int UserId) : IRequest<OperationResult<List<GetUsersQueryResultModel>>>;
@@ -1,3 +0,0 @@
namespace Baya.Application.Features.Order.Queries.GetUserOrders;
public record GetUsersQueryResultModel(int OrderId, string OrderName);
@@ -0,0 +1,16 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.System.Queries.Ping;
internal sealed class PingQueryHandler(IDateTimeProvider dateTimeProvider)
: IRequestHandler<PingQuery, OperationResult<PingQueryResult>>
{
public ValueTask<OperationResult<PingQueryResult>> Handle(PingQuery request, CancellationToken cancellationToken)
{
var result = new PingQueryResult("Baya.Web.Api", "ok", dateTimeProvider.UtcNow);
return ValueTask.FromResult(OperationResult<PingQueryResult>.SuccessResult(result));
}
}
@@ -0,0 +1,4 @@
namespace Baya.Application.Features.System.Queries.Ping;
/// <summary>Liveness payload proving the REST → Mediator → envelope pipeline end-to-end.</summary>
public record PingQueryResult(string Service, string Status, DateTimeOffset ServerTimeUtc);
@@ -0,0 +1,6 @@
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.System.Queries.Ping;
public record PingQuery : IRequest<OperationResult<PingQueryResult>>;
@@ -16,8 +16,10 @@ public static class ServiceCollectionExtension
options.Namespace = "Baya.Application.Mediator";
});
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(MetricsBehaviour<,>));
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(ValidateCommandBehavior<,>));
@@ -1,4 +1,4 @@
namespace Baya.Domain.Common;
namespace Baya.Domain.Common;
public interface IEntity
{
@@ -6,11 +6,21 @@ public interface IEntity
public interface ITimeModification
{
DateTime CreatedTime { get; set; }
DateTime? ModifiedDate { get; set; }
DateTimeOffset CreatedAt { get; set; }
DateTimeOffset? ModifiedAt { get; set; }
}
public abstract class BaseEntity<TKey> : IEntity, ITimeModification
/// <summary>
/// An entity whose create/modify timestamps and the acting user are stamped automatically by the
/// SaveChanges audit interceptor — handlers must never set these fields themselves.
/// </summary>
public interface IAuditableEntity : ITimeModification
{
int? CreatedById { get; set; }
int? ModifiedById { get; set; }
}
public abstract class BaseEntity<TKey> : IEntity, IAuditableEntity
{
public TKey Id { get; protected set; }
@@ -49,11 +59,13 @@ public abstract class BaseEntity<TKey> : IEntity, ITimeModification
return (GetType().ToString() + Id).GetHashCode();
}
public DateTime CreatedTime { get; set; }
public DateTime? ModifiedDate { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? ModifiedAt { get; set; }
public int? CreatedById { get; set; }
public int? ModifiedById { get; set; }
}
public abstract class BaseEntity : BaseEntity<int>
{
}
}
@@ -1,16 +0,0 @@
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Order;
public class Order:BaseEntity
{
public string OrderName { get; set; }
public bool IsDeleted { get; set; }
#region Navigation Properties
public User.User User { get; set; }
public int UserId { get; set; }
#endregion
}
@@ -19,11 +19,4 @@ public class User:IdentityUser<int>,IEntity
public ICollection<UserClaim> Claims { get; set; }
public ICollection<UserToken> Tokens { get; set; }
public ICollection<UserRefreshToken> UserRefreshTokens { get; set; }
#region Navigation Properties
public IList<Order.Order> Orders { get; set; }
#endregion
}
@@ -1,16 +1,10 @@
using Baya.Domain.Common;
using Baya.Domain.Common;
namespace Baya.Domain.Entities.User;
public class UserRefreshToken:BaseEntity<Guid>
{
public UserRefreshToken()
{
CreatedAt=DateTime.Now;
}
public int UserId { get; set; }
public User User { get; set; }
public DateTime CreatedAt { get; set; }
public bool IsValid { get; set; }
}
}
@@ -17,4 +17,7 @@
<ProjectReference Include="..\Baya.Infrastructure.Identity\Baya.Infrastructure.Identity.csproj" />
<ProjectReference Include="..\Baya.Infrastructure.Persistence\Baya.Infrastructure.Persistence.csproj" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
</Project>
@@ -0,0 +1,63 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Microsoft.Extensions.Options;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// Local-disk implementation of <see cref="IObjectStorage"/> — the mock seam. Blobs are stored as
/// files under a configured scratch root, keyed by an opaque storage key. The real implementation
/// swaps to MinIO/S3/ArvanCloud (presigned URLs) behind the same interface.
/// </summary>
public sealed class LocalDiskObjectStorage : IObjectStorage
{
private readonly string _root;
public LocalDiskObjectStorage(IOptions<SeamOptions> options)
{
var configured = options.Value.ObjectStorage.RootPath;
_root = string.IsNullOrWhiteSpace(configured)
? Path.Combine(Path.GetTempPath(), "balinyaar-object-storage")
: configured;
Directory.CreateDirectory(_root);
}
public async ValueTask PutAsync(string key, Stream content, string contentType, CancellationToken cancellationToken = default)
{
var path = ResolvePath(key);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
await using var file = File.Create(path);
await content.CopyToAsync(file, cancellationToken);
}
public ValueTask<Stream?> GetAsync(string key, CancellationToken cancellationToken = default)
{
var path = ResolvePath(key);
Stream? stream = File.Exists(path) ? File.OpenRead(path) : null;
return ValueTask.FromResult(stream);
}
public ValueTask DeleteAsync(string key, CancellationToken cancellationToken = default)
{
var path = ResolvePath(key);
if (File.Exists(path))
File.Delete(path);
return ValueTask.CompletedTask;
}
public string GetUrl(string key) => new Uri(Path.Combine(_root, SanitizeKey(key))).AbsoluteUri;
private string ResolvePath(string key) => Path.Combine(_root, SanitizeKey(key));
// Keep the key from escaping the storage root; treat '/' as a folder separator only.
private static string SanitizeKey(string key)
{
var normalized = key.Replace('\\', '/').TrimStart('/');
var segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries)
.Where(s => s != "." && s != "..");
return Path.Combine([.. segments]);
}
}
@@ -0,0 +1,22 @@
using Baya.Application.Contracts.Common;
using Microsoft.Extensions.Logging;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// No-op implementation of <see cref="INotificationDispatcher"/> — the mock seam. It logs that a
/// notification would be sent (no PII in the log). The real in-app write lands in backend-phase-15,
/// with SMS/push channels added behind the same interface.
/// </summary>
public sealed class LogNotificationDispatcher(ILogger<LogNotificationDispatcher> logger) : INotificationDispatcher
{
public ValueTask DispatchAsync(Notification notification, CancellationToken cancellationToken = default)
{
logger.LogInformation(
"Notification suppressed (mock dispatcher): channel {Channel} to user {UserId}",
notification.Channel,
notification.RecipientUserId);
return ValueTask.CompletedTask;
}
}
@@ -0,0 +1,47 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Microsoft.Extensions.Caching.Memory;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// In-process <see cref="IMemoryCache"/> implementation of <see cref="ICacheService"/> — the mock seam.
/// The real implementation swaps to Redis (StackExchange.Redis) while keeping the same key/TTL scheme.
/// </summary>
public sealed class MemoryCacheService(IMemoryCache cache) : ICacheService
{
public ValueTask<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default)
{
return ValueTask.FromResult(cache.TryGetValue(key, out T? value) ? value : default);
}
public ValueTask SetAsync<T>(string key, T value, TimeSpan? ttl = null, CancellationToken cancellationToken = default)
{
var options = new MemoryCacheEntryOptions();
if (ttl is { } expiry)
options.AbsoluteExpirationRelativeToNow = expiry;
cache.Set(key, value, options);
return ValueTask.CompletedTask;
}
public ValueTask RemoveAsync(string key, CancellationToken cancellationToken = default)
{
cache.Remove(key);
return ValueTask.CompletedTask;
}
public async ValueTask<T> GetOrCreateAsync<T>(
string key,
Func<CancellationToken, ValueTask<T>> factory,
TimeSpan? ttl = null,
CancellationToken cancellationToken = default)
{
if (cache.TryGetValue(key, out T? cached) && cached is not null)
return cached;
var value = await factory(cancellationToken);
await SetAsync(key, value, ttl, cancellationToken);
return value;
}
}
@@ -0,0 +1,28 @@
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// Options bound from the <c>Seams</c> configuration section. The mock seams read non-secret defaults
/// from here; production keys/paths come from environment variables or user-secrets, never committed.
/// </summary>
public sealed class SeamOptions
{
public const string SectionName = "Seams";
public FieldEncryptionOptions FieldEncryption { get; set; } = new();
public ObjectStorageOptions ObjectStorage { get; set; } = new();
}
public sealed class FieldEncryptionOptions
{
/// <summary>Base64 32-byte AES key. Local-dev default only; override per environment.</summary>
public string Key { get; set; } = string.Empty;
/// <summary>Base64 HMAC key used for deterministic lookup hashes.</summary>
public string HashKey { get; set; } = string.Empty;
}
public sealed class ObjectStorageOptions
{
/// <summary>Filesystem root the local-disk mock writes blobs under.</summary>
public string RootPath { get; set; } = string.Empty;
}
@@ -0,0 +1,86 @@
using System.Security.Cryptography;
using System.Text;
using Baya.Application.Contracts.Common;
using Microsoft.Extensions.Options;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// Local symmetric-key implementation of <see cref="IFieldEncryptor"/> — AES-256-CBC with a random
/// per-value IV (prepended to the ciphertext) for at-rest reversibility, and a keyed HMAC-SHA256 for
/// deterministic lookup hashes. This is the mock seam: the real implementation swaps to a KMS / Key
/// Vault provider behind the same interface. Plaintext is never logged.
/// </summary>
public sealed class SymmetricFieldEncryptor : IFieldEncryptor
{
private readonly byte[] _key;
private readonly byte[] _hashKey;
public SymmetricFieldEncryptor(IOptions<SeamOptions> options)
{
var settings = options.Value.FieldEncryption;
// Derive a stable 32-byte AES key from whatever the operator configured (any length/format),
// so a human-friendly secret still yields a valid key. SHA-256 of the configured material.
_key = SHA256.HashData(Encoding.UTF8.GetBytes(Require(settings.Key, nameof(settings.Key))));
var hashMaterial = string.IsNullOrEmpty(settings.HashKey) ? settings.Key : settings.HashKey;
_hashKey = SHA256.HashData(Encoding.UTF8.GetBytes(Require(hashMaterial, nameof(settings.HashKey))));
}
public string Encrypt(string plaintext)
{
if (string.IsNullOrEmpty(plaintext))
return plaintext;
using var aes = Aes.Create();
aes.Key = _key;
aes.GenerateIV();
using var encryptor = aes.CreateEncryptor();
var plainBytes = Encoding.UTF8.GetBytes(plaintext);
var cipherBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length);
var result = new byte[aes.IV.Length + cipherBytes.Length];
Buffer.BlockCopy(aes.IV, 0, result, 0, aes.IV.Length);
Buffer.BlockCopy(cipherBytes, 0, result, aes.IV.Length, cipherBytes.Length);
return Convert.ToBase64String(result);
}
public string Decrypt(string ciphertext)
{
if (string.IsNullOrEmpty(ciphertext))
return ciphertext;
var cipherWithIv = Convert.FromBase64String(ciphertext);
using var aes = Aes.Create();
aes.Key = _key;
var ivLength = aes.BlockSize / 8;
var iv = new byte[ivLength];
Buffer.BlockCopy(cipherWithIv, 0, iv, 0, ivLength);
aes.IV = iv;
using var decryptor = aes.CreateDecryptor();
var cipherBytes = decryptor.TransformFinalBlock(cipherWithIv, ivLength, cipherWithIv.Length - ivLength);
return Encoding.UTF8.GetString(cipherBytes);
}
public string Hash(string value)
{
if (string.IsNullOrEmpty(value))
return value;
using var hmac = new HMACSHA256(_hashKey);
var hashBytes = hmac.ComputeHash(Encoding.UTF8.GetBytes(value));
return Convert.ToHexString(hashBytes);
}
private static string Require(string value, string name) =>
string.IsNullOrWhiteSpace(value)
? throw new InvalidOperationException($"Seams:FieldEncryption:{name} must be configured.")
: value;
}
@@ -0,0 +1,9 @@
using Baya.Application.Contracts.Common;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>Real system clock. Tests substitute <see cref="IDateTimeProvider"/> to freeze time.</summary>
public sealed class SystemDateTimeProvider : IDateTimeProvider
{
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
}
@@ -0,0 +1,29 @@
using Baya.Application.Contracts.Common;
using Baya.Infrastructure.CrossCutting.Seams;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace Baya.Infrastructure.CrossCutting.ServiceConfiguration;
public static class ServiceCollectionExtension
{
/// <summary>
/// Registers the cross-cutting seams (time, PII encryption, cache, object storage, notifications)
/// with their in-memory/local mock implementations. Swapping in a real provider later is a
/// registration change here — callers depend only on the Application contracts.
/// </summary>
public static IServiceCollection AddCrossCuttingSeams(this IServiceCollection services, IConfiguration configuration)
{
services.Configure<SeamOptions>(configuration.GetSection(SeamOptions.SectionName));
services.AddMemoryCache();
services.AddSingleton<IDateTimeProvider, SystemDateTimeProvider>();
services.AddSingleton<IFieldEncryptor, SymmetricFieldEncryptor>();
services.AddSingleton<ICacheService, MemoryCacheService>();
services.AddSingleton<IObjectStorage, LocalDiskObjectStorage>();
services.AddScoped<INotificationDispatcher, LogNotificationDispatcher>();
return services;
}
}
@@ -0,0 +1,30 @@
#nullable enable
using System.Security.Claims;
using Baya.Application.Contracts.Common;
using Baya.SharedKernel.Extensions;
using Microsoft.AspNetCore.Http;
namespace Baya.Infrastructure.Identity.Identity.CurrentUser;
/// <summary>
/// Resolves the current user from the active HTTP request's claims. When there is no request or no
/// authenticated principal (background work, startup), all members report "no user".
/// </summary>
public sealed class HttpContextCurrentUser(IHttpContextAccessor httpContextAccessor) : ICurrentUser
{
private ClaimsPrincipal? Principal => httpContextAccessor.HttpContext?.User;
public int? UserId
{
get
{
var rawId = Principal?.Identity?.GetUserId();
return rawId.HasValue() && int.TryParse(rawId, out var id) ? id : null;
}
}
public bool IsAuthenticated => Principal?.Identity?.IsAuthenticated ?? false;
public IReadOnlyList<string> Roles =>
Principal?.FindAll(ClaimTypes.Role).Select(c => c.Value).ToArray() ?? [];
}
@@ -0,0 +1,16 @@
using Baya.Application.Contracts.Common;
namespace Baya.Infrastructure.Identity.Identity.CurrentUser;
/// <summary>
/// Null-object <see cref="ICurrentUser"/> for non-HTTP contexts (background jobs, tests) — reports an
/// unauthenticated caller so audit stamping leaves the user fields null rather than failing.
/// </summary>
public sealed class NullCurrentUser : ICurrentUser
{
public int? UserId => null;
public bool IsAuthenticated => false;
public IReadOnlyList<string> Roles => [];
}
@@ -1,10 +1,12 @@
using System.Security.Claims;
using System.Text;
using Baya.Application.Contracts;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Identity;
using Baya.Application.Models.ApiResult;
using Baya.Domain.Entities.User;
using Baya.Infrastructure.Identity.Identity;
using Baya.Infrastructure.Identity.Identity.CurrentUser;
using Baya.Infrastructure.Identity.Identity.Dtos;
using Baya.Infrastructure.Identity.Identity.Extensions;
using Baya.Infrastructure.Identity.Identity.Manager;
@@ -29,6 +31,9 @@ public static class ServiceCollectionExtension
{
public static IServiceCollection RegisterIdentityServices(this IServiceCollection services,IdentitySettings identitySettings)
{
services.AddHttpContextAccessor();
services.AddScoped<ICurrentUser, HttpContextCurrentUser>();
services.AddScoped<IJwtService, JwtService>();
services.AddScoped<IAppUserManager, AppUserManagerImplementation>();
services.AddScoped<ISeedDataBase, SeedDataBase>();
@@ -18,7 +18,6 @@ public class ApplicationDbContext: IdentityDbContext<User, Role, int, UserClaim,
private void OnSavingChanges(object sender, SavingChangesEventArgs e)
{
_cleanString();
ConfigureEntityDates();
}
private void _cleanString()
@@ -62,30 +61,4 @@ public class ApplicationDbContext: IdentityDbContext<User, Role, int, UserClaim,
}
private void ConfigureEntityDates()
{
var updatedEntities = ChangeTracker.Entries().Where(x =>
x.Entity is ITimeModification && x.State == EntityState.Modified).Select(x => x.Entity as ITimeModification);
var addedEntities = ChangeTracker.Entries().Where(x =>
x.Entity is ITimeModification && x.State == EntityState.Added).Select(x => x.Entity as ITimeModification);
foreach (var entity in updatedEntities)
{
if (entity != null)
{
entity.ModifiedDate = DateTime.Now;
}
}
foreach (var entity in addedEntities)
{
if (entity != null)
{
entity.CreatedTime = DateTime.Now;
entity.ModifiedDate = DateTime.Now;
}
}
}
}
@@ -1,15 +0,0 @@
using Baya.Domain.Entities.Order;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.OrderConfig;
internal class OrderConfig:IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.HasOne(c => c.User).WithMany(c => c.Orders).HasForeignKey(c => c.UserId);
builder.HasQueryFilter(c => !c.IsDeleted);
}
}
@@ -0,0 +1,67 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Domain.Common;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
namespace Baya.Infrastructure.Persistence.Interceptors;
/// <summary>
/// Stamps audit fields on every save: <c>CreatedAt</c>/<c>CreatedById</c> on insert and
/// <c>ModifiedAt</c>/<c>ModifiedById</c> on update, sourcing time from <see cref="IDateTimeProvider"/>
/// and the acting user from <see cref="ICurrentUser"/>. Handlers never set these fields.
/// This is the extension point backend-phase-1 builds on to also write append-only audit-log rows.
/// </summary>
public sealed class AuditFieldInterceptor(ICurrentUser currentUser, IDateTimeProvider dateTimeProvider)
: SaveChangesInterceptor
{
public override InterceptionResult<int> SavingChanges(
DbContextEventData eventData,
InterceptionResult<int> result)
{
Stamp(eventData.Context);
return base.SavingChanges(eventData, result);
}
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
Stamp(eventData.Context);
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
private void Stamp(DbContext? context)
{
if (context is null)
return;
var now = dateTimeProvider.UtcNow;
var userId = currentUser.UserId;
foreach (var entry in context.ChangeTracker.Entries<ITimeModification>())
{
switch (entry.State)
{
case EntityState.Added:
entry.Entity.CreatedAt = now;
entry.Entity.ModifiedAt = now;
if (entry.Entity is IAuditableEntity addedAuditable)
{
addedAuditable.CreatedById = userId;
addedAuditable.ModifiedById = userId;
}
break;
case EntityState.Modified:
entry.Entity.ModifiedAt = now;
if (entry.Entity is IAuditableEntity modifiedAuditable)
modifiedAuditable.ModifiedById = userId;
break;
}
}
}
}
@@ -1,374 +0,0 @@
// <auto-generated />
using System;
using Baya.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Persistence;
namespace Persistence.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20210327210004_Init")]
partial class Init
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.UseIdentityColumns()
.HasAnnotation("Relational:MaxIdentifierLength", 128)
.HasAnnotation("ProductVersion", "5.0.0");
modelBuilder.Entity("Domain.Entities.User.Role", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.UseIdentityColumn();
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("CreatedDate")
.HasColumnType("datetime2");
b.Property<string>("DisplayName")
.HasColumnType("nvarchar(max)");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex")
.HasFilter("[NormalizedName] IS NOT NULL");
b.ToTable("Roles", "usr");
});
modelBuilder.Entity("Domain.Entities.User.RoleClaim", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.UseIdentityColumn();
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("CreatedClaim")
.HasColumnType("datetime2");
b.Property<int>("RoleId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("RoleClaims", "usr");
});
modelBuilder.Entity("Domain.Entities.User.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasColumnName("UserId")
.UseIdentityColumn();
b.Property<int>("AccessFailedCount")
.HasColumnType("int");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("bit");
b.Property<string>("FamilyName")
.HasColumnType("nvarchar(max)");
b.Property<string>("GeneratedCode")
.HasColumnType("nvarchar(max)");
b.Property<bool>("LockoutEnabled")
.HasColumnType("bit");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("datetimeoffset");
b.Property<string>("Name")
.HasColumnType("nvarchar(max)");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("PasswordHash")
.HasColumnType("nvarchar(max)");
b.Property<string>("PhoneNumber")
.HasColumnType("nvarchar(max)");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("bit");
b.Property<string>("SecurityStamp")
.HasColumnType("nvarchar(max)");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("bit");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex")
.HasFilter("[NormalizedUserName] IS NOT NULL");
b.ToTable("Users", "usr");
});
modelBuilder.Entity("Domain.Entities.User.UserClaim", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.UseIdentityColumn();
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UserClaims", "usr");
});
modelBuilder.Entity("Domain.Entities.User.UserLogin", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("ProviderKey")
.HasColumnType("nvarchar(450)");
b.Property<DateTime>("LoggedOn")
.HasColumnType("datetime2");
b.Property<string>("ProviderDisplayName")
.HasColumnType("nvarchar(max)");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("UserLogins", "usr");
});
modelBuilder.Entity("Domain.Entities.User.UserRefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<DateTime>("CreatedTime")
.HasColumnType("datetime2");
b.Property<bool>("IsValid")
.HasColumnType("bit");
b.Property<DateTime?>("ModifiedDate")
.HasColumnType("datetime2");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UserRefreshTokens", "usr");
});
modelBuilder.Entity("Domain.Entities.User.UserRole", b =>
{
b.Property<int>("UserId")
.HasColumnType("int");
b.Property<int>("RoleId")
.HasColumnType("int");
b.Property<DateTime>("CreatedUserRoleDate")
.HasColumnType("datetime2");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("UserRoles", "usr");
});
modelBuilder.Entity("Domain.Entities.User.UserToken", b =>
{
b.Property<int>("UserId")
.HasColumnType("int");
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("Name")
.HasColumnType("nvarchar(450)");
b.Property<DateTime>("GeneratedTime")
.HasColumnType("datetime2");
b.Property<string>("Value")
.HasColumnType("nvarchar(max)");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("UserTokens", "usr");
});
modelBuilder.Entity("Domain.Entities.User.RoleClaim", b =>
{
b.HasOne("Domain.Entities.User.Role", "Role")
.WithMany("Claims")
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Role");
});
modelBuilder.Entity("Domain.Entities.User.UserClaim", b =>
{
b.HasOne("Domain.Entities.User.User", "User")
.WithMany("Claims")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Domain.Entities.User.UserLogin", b =>
{
b.HasOne("Domain.Entities.User.User", "User")
.WithMany("Logins")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Domain.Entities.User.UserRefreshToken", b =>
{
b.HasOne("Domain.Entities.User.User", "User")
.WithMany("UserRefreshTokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Domain.Entities.User.UserRole", b =>
{
b.HasOne("Domain.Entities.User.Role", "Role")
.WithMany("Users")
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Domain.Entities.User.User", "User")
.WithMany("UserRoles")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Role");
b.Navigation("User");
});
modelBuilder.Entity("Domain.Entities.User.UserToken", b =>
{
b.HasOne("Domain.Entities.User.User", "User")
.WithMany("Tokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Domain.Entities.User.Role", b =>
{
b.Navigation("Claims");
b.Navigation("Users");
});
modelBuilder.Entity("Domain.Entities.User.User", b =>
{
b.Navigation("Claims");
b.Navigation("Logins");
b.Navigation("Tokens");
b.Navigation("UserRefreshTokens");
b.Navigation("UserRoles");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,422 +0,0 @@
// <auto-generated />
using System;
using Baya.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Persistence;
#nullable disable
namespace Persistence.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20221205084354_AddedOrderAndUserRelation")]
partial class AddedOrderAndUserRelation
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "7.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("Domain.Entities.Order.Order", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedTime")
.HasColumnType("datetime2");
b.Property<DateTime?>("ModifiedDate")
.HasColumnType("datetime2");
b.Property<string>("OrderName")
.HasColumnType("nvarchar(max)");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Orders");
});
modelBuilder.Entity("Domain.Entities.User.Role", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("CreatedDate")
.HasColumnType("datetime2");
b.Property<string>("DisplayName")
.HasColumnType("nvarchar(max)");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex")
.HasFilter("[NormalizedName] IS NOT NULL");
b.ToTable("Roles", "usr");
});
modelBuilder.Entity("Domain.Entities.User.RoleClaim", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("CreatedClaim")
.HasColumnType("datetime2");
b.Property<int>("RoleId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("RoleClaims", "usr");
});
modelBuilder.Entity("Domain.Entities.User.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasColumnName("UserId");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("AccessFailedCount")
.HasColumnType("int");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("bit");
b.Property<string>("FamilyName")
.HasColumnType("nvarchar(max)");
b.Property<string>("GeneratedCode")
.HasColumnType("nvarchar(max)");
b.Property<bool>("LockoutEnabled")
.HasColumnType("bit");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("datetimeoffset");
b.Property<string>("Name")
.HasColumnType("nvarchar(max)");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("PasswordHash")
.HasColumnType("nvarchar(max)");
b.Property<string>("PhoneNumber")
.HasColumnType("nvarchar(max)");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("bit");
b.Property<string>("SecurityStamp")
.HasColumnType("nvarchar(max)");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("bit");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex")
.HasFilter("[NormalizedUserName] IS NOT NULL");
b.ToTable("Users", "usr");
});
modelBuilder.Entity("Domain.Entities.User.UserClaim", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UserClaims", "usr");
});
modelBuilder.Entity("Domain.Entities.User.UserLogin", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("ProviderKey")
.HasColumnType("nvarchar(450)");
b.Property<DateTime>("LoggedOn")
.HasColumnType("datetime2");
b.Property<string>("ProviderDisplayName")
.HasColumnType("nvarchar(max)");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("UserLogins", "usr");
});
modelBuilder.Entity("Domain.Entities.User.UserRefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<DateTime>("CreatedTime")
.HasColumnType("datetime2");
b.Property<bool>("IsValid")
.HasColumnType("bit");
b.Property<DateTime?>("ModifiedDate")
.HasColumnType("datetime2");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UserRefreshTokens", "usr");
});
modelBuilder.Entity("Domain.Entities.User.UserRole", b =>
{
b.Property<int>("UserId")
.HasColumnType("int");
b.Property<int>("RoleId")
.HasColumnType("int");
b.Property<DateTime>("CreatedUserRoleDate")
.HasColumnType("datetime2");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("UserRoles", "usr");
});
modelBuilder.Entity("Domain.Entities.User.UserToken", b =>
{
b.Property<int>("UserId")
.HasColumnType("int");
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("Name")
.HasColumnType("nvarchar(450)");
b.Property<DateTime>("GeneratedTime")
.HasColumnType("datetime2");
b.Property<string>("Value")
.HasColumnType("nvarchar(max)");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("UserTokens", "usr");
});
modelBuilder.Entity("Domain.Entities.Order.Order", b =>
{
b.HasOne("Domain.Entities.User.User", "User")
.WithMany("Orders")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Domain.Entities.User.RoleClaim", b =>
{
b.HasOne("Domain.Entities.User.Role", "Role")
.WithMany("Claims")
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Role");
});
modelBuilder.Entity("Domain.Entities.User.UserClaim", b =>
{
b.HasOne("Domain.Entities.User.User", "User")
.WithMany("Claims")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Domain.Entities.User.UserLogin", b =>
{
b.HasOne("Domain.Entities.User.User", "User")
.WithMany("Logins")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Domain.Entities.User.UserRefreshToken", b =>
{
b.HasOne("Domain.Entities.User.User", "User")
.WithMany("UserRefreshTokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Domain.Entities.User.UserRole", b =>
{
b.HasOne("Domain.Entities.User.Role", "Role")
.WithMany("Users")
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Domain.Entities.User.User", "User")
.WithMany("UserRoles")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Role");
b.Navigation("User");
});
modelBuilder.Entity("Domain.Entities.User.UserToken", b =>
{
b.HasOne("Domain.Entities.User.User", "User")
.WithMany("Tokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Domain.Entities.User.Role", b =>
{
b.Navigation("Claims");
b.Navigation("Users");
});
modelBuilder.Entity("Domain.Entities.User.User", b =>
{
b.Navigation("Claims");
b.Navigation("Logins");
b.Navigation("Orders");
b.Navigation("Tokens");
b.Navigation("UserRefreshTokens");
b.Navigation("UserRoles");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,50 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Persistence.Migrations
{
/// <inheritdoc />
public partial class AddedOrderAndUserRelation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Orders",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
OrderName = table.Column<string>(type: "nvarchar(max)", nullable: true),
UserId = table.Column<int>(type: "int", nullable: false),
CreatedTime = table.Column<DateTime>(type: "datetime2", nullable: false),
ModifiedDate = table.Column<DateTime>(type: "datetime2", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Orders", x => x.Id);
table.ForeignKey(
name: "FK_Orders_Users_UserId",
column: x => x.UserId,
principalSchema: "usr",
principalTable: "Users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_Orders_UserId",
table: "Orders",
column: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Orders");
}
}
}
@@ -1,29 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Persistence.Migrations
{
/// <inheritdoc />
public partial class AddedOrderDeleteFlag : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsDeleted",
table: "Orders",
type: "bit",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsDeleted",
table: "Orders");
}
}
}
@@ -9,52 +9,22 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Persistence.Migrations
namespace Baya.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20231126140035_AddedOrderDeleteFlag")]
partial class AddedOrderDeleteFlag
[Migration("20260628191947_InitialBaseline")]
partial class InitialBaseline
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.0")
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("Baya.Domain.Entities.Order.Order", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedTime")
.HasColumnType("datetime2");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTime?>("ModifiedDate")
.HasColumnType("datetime2");
b.Property<string>("OrderName")
.HasColumnType("nvarchar(max)");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Orders");
});
modelBuilder.Entity("Baya.Domain.Entities.User.Role", b =>
{
b.Property<int>("Id")
@@ -250,17 +220,20 @@ namespace Persistence.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<DateTime>("CreatedTime")
.HasColumnType("datetime2");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<bool>("IsValid")
.HasColumnType("bit");
b.Property<DateTime?>("ModifiedDate")
.HasColumnType("datetime2");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<int>("UserId")
.HasColumnType("int");
@@ -312,17 +285,6 @@ namespace Persistence.Migrations
b.ToTable("UserTokens", "usr");
});
modelBuilder.Entity("Baya.Domain.Entities.Order.Order", b =>
{
b.HasOne("Baya.Domain.Entities.User.User", "User")
.WithMany("Orders")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Baya.Domain.Entities.User.RoleClaim", b =>
{
b.HasOne("Baya.Domain.Entities.User.Role", "Role")
@@ -410,8 +372,6 @@ namespace Persistence.Migrations
b.Navigation("Logins");
b.Navigation("Orders");
b.Navigation("Tokens");
b.Navigation("UserRefreshTokens");
@@ -1,10 +1,14 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
namespace Persistence.Migrations
#nullable disable
namespace Baya.Infrastructure.Persistence.Migrations
{
public partial class Init : Migration
/// <inheritdoc />
public partial class InitialBaseline : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
@@ -135,10 +139,11 @@ namespace Persistence.Migrations
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
UserId = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
IsValid = table.Column<bool>(type: "bit", nullable: false),
CreatedTime = table.Column<DateTime>(type: "datetime2", nullable: false),
ModifiedDate = table.Column<DateTime>(type: "datetime2", nullable: true)
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
@@ -256,6 +261,7 @@ namespace Persistence.Migrations
filter: "[NormalizedUserName] IS NOT NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
@@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Persistence.Migrations
namespace Baya.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
partial class ApplicationDbContextModelSnapshot : ModelSnapshot
@@ -17,41 +17,11 @@ namespace Persistence.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.0")
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("Baya.Domain.Entities.Order.Order", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedTime")
.HasColumnType("datetime2");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTime?>("ModifiedDate")
.HasColumnType("datetime2");
b.Property<string>("OrderName")
.HasColumnType("nvarchar(max)");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Orders", (string)null);
});
modelBuilder.Entity("Baya.Domain.Entities.User.Role", b =>
{
b.Property<int>("Id")
@@ -247,17 +217,20 @@ namespace Persistence.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<DateTime>("CreatedTime")
.HasColumnType("datetime2");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<bool>("IsValid")
.HasColumnType("bit");
b.Property<DateTime?>("ModifiedDate")
.HasColumnType("datetime2");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<int>("UserId")
.HasColumnType("int");
@@ -309,17 +282,6 @@ namespace Persistence.Migrations
b.ToTable("UserTokens", "usr");
});
modelBuilder.Entity("Baya.Domain.Entities.Order.Order", b =>
{
b.HasOne("Baya.Domain.Entities.User.User", "User")
.WithMany("Orders")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Baya.Domain.Entities.User.RoleClaim", b =>
{
b.HasOne("Baya.Domain.Entities.User.Role", "Role")
@@ -407,8 +369,6 @@ namespace Persistence.Migrations
b.Navigation("Logins");
b.Navigation("Orders");
b.Navigation("Tokens");
b.Navigation("UserRefreshTokens");
@@ -7,13 +7,11 @@ public class UnitOfWork : IUnitOfWork
private readonly ApplicationDbContext _db;
public IUserRefreshTokenRepository UserRefreshTokenRepository { get; }
public IOrderRepository OrderRepository { get; }
public UnitOfWork(ApplicationDbContext db)
{
_db = db;
UserRefreshTokenRepository = new UserRefreshTokenRepository(_db);
OrderRepository= new OrderRepository(_db);
}
public Task CommitAsync()
@@ -1,41 +0,0 @@
using Baya.Application.Contracts.Persistence;
using Baya.Domain.Entities.Order;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Repositories;
internal class OrderRepository(ApplicationDbContext dbContext) : BaseAsyncRepository<Order>(dbContext), IOrderRepository
{
public async Task AddOrderAsync(Order order)
{
await base.AddAsync(order);
}
public async Task<List<Order>> GetAllUserOrdersAsync(int userId)
{
return await base.TableNoTracking.Where(c => c.UserId == userId).ToListAsync();
}
public async Task<List<Order>> GetAllOrdersWithRelatedUserAsync()
{
var orders = await base.TableNoTracking.Include(c => c.User).ToListAsync();
return orders;
}
public async Task<Order> GetUserOrderByIdAndUserIdAsync(int userId, int orderId,bool trackEntity)
{
var order = await base.TableNoTracking.FirstOrDefaultAsync(c => c.UserId == userId && c.Id == orderId);
if (order is not null && trackEntity)
base.DbContext.Attach(order);
return order;
}
public async Task DeleteUserOrdersAsync(int userId)
{
await UpdateAsync(c => c.UserId == userId, p => p.SetProperty(order => order.IsDeleted, true));
}
}
@@ -1,4 +1,5 @@
using Baya.Application.Contracts.Persistence;
using Baya.Infrastructure.Persistence.Interceptors;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Microsoft.AspNetCore.Builder;
using Microsoft.EntityFrameworkCore;
@@ -13,10 +14,13 @@ public static class ServiceCollectionExtensions
{
services.AddScoped<IUnitOfWork, UnitOfWork>();
services.AddDbContext<ApplicationDbContext>(options =>
services.AddScoped<AuditFieldInterceptor>();
services.AddDbContext<ApplicationDbContext>((serviceProvider, options) =>
{
options
.UseSqlServer(configuration.GetConnectionString("SqlServer"));
.UseSqlServer(configuration.GetConnectionString("SqlServer"))
.AddInterceptors(serviceProvider.GetRequiredService<AuditFieldInterceptor>());
});
return services;
@@ -0,0 +1,95 @@
using Baya.Application.Contracts.Common;
using Baya.Domain.Common;
using Baya.Infrastructure.Persistence.Interceptors;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
namespace Baya.Test.Foundation;
public class AuditFieldInterceptorTests
{
// A throwaway auditable entity + context so the interceptor is tested in isolation, without the
// full Identity schema.
private sealed class Widget : BaseEntity
{
public string Name { get; set; } = string.Empty;
}
private sealed class AuditTestDbContext(DbContextOptions options) : DbContext(options)
{
public DbSet<Widget> Widgets => Set<Widget>();
}
private static AuditTestDbContext CreateContext(AuditFieldInterceptor interceptor)
{
var connection = new SqliteConnection("DataSource=:memory:");
connection.Open();
var options = new DbContextOptionsBuilder<AuditTestDbContext>()
.UseSqlite(connection)
.AddInterceptors(interceptor)
.Options;
var context = new AuditTestDbContext(options);
context.Database.EnsureCreated();
return context;
}
[Fact]
public async Task SavingChanges_OnAdd_StampsCreatedAtAndCreatedById()
{
// Arrange
var fixedNow = new DateTimeOffset(2026, 6, 28, 10, 0, 0, TimeSpan.Zero);
var clock = Substitute.For<IDateTimeProvider>();
clock.UtcNow.Returns(fixedNow);
var currentUser = Substitute.For<ICurrentUser>();
currentUser.UserId.Returns(42);
await using var context = CreateContext(new AuditFieldInterceptor(currentUser, clock));
var widget = new Widget { Name = "test" };
context.Widgets.Add(widget);
// Act
await context.SaveChangesAsync();
// Assert
Assert.Equal(fixedNow, widget.CreatedAt);
Assert.Equal(fixedNow, widget.ModifiedAt);
Assert.Equal(42, widget.CreatedById);
Assert.Equal(42, widget.ModifiedById);
}
[Fact]
public async Task SavingChanges_OnUpdate_StampsModifiedButNotCreatedBy()
{
// Arrange
var createNow = new DateTimeOffset(2026, 6, 28, 10, 0, 0, TimeSpan.Zero);
var updateNow = new DateTimeOffset(2026, 6, 28, 12, 0, 0, TimeSpan.Zero);
var clock = Substitute.For<IDateTimeProvider>();
var currentUser = Substitute.For<ICurrentUser>();
clock.UtcNow.Returns(createNow);
currentUser.UserId.Returns(7);
await using var context = CreateContext(new AuditFieldInterceptor(currentUser, clock));
var widget = new Widget { Name = "before" };
context.Widgets.Add(widget);
await context.SaveChangesAsync();
// Act — a different user edits the row later.
clock.UtcNow.Returns(updateNow);
currentUser.UserId.Returns(9);
widget.Name = "after";
await context.SaveChangesAsync();
// Assert
Assert.Equal(createNow, widget.CreatedAt);
Assert.Equal(7, widget.CreatedById);
Assert.Equal(updateNow, widget.ModifiedAt);
Assert.Equal(9, widget.ModifiedById);
}
}
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Baya.Tests.Setup\Baya.Tests.Setup.csproj" />
<ProjectReference Include="..\..\Infrastructure\Baya.Infrastructure.CrossCutting\Baya.Infrastructure.CrossCutting.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,28 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Features.System.Queries.Ping;
using NSubstitute;
namespace Baya.Test.Foundation;
public class PingQueryHandlerTests
{
[Fact]
public async Task Handle_ReturnsSuccess_WithServerTimeFromProvider()
{
// Arrange
var now = new DateTimeOffset(2026, 6, 28, 9, 30, 0, TimeSpan.Zero);
var clock = Substitute.For<IDateTimeProvider>();
clock.UtcNow.Returns(now);
var handler = new PingQueryHandler(clock);
// Act
var result = await handler.Handle(new PingQuery(), CancellationToken.None);
// Assert
Assert.True(result.IsSuccess);
Assert.NotNull(result.Result);
Assert.Equal("ok", result.Result!.Status);
Assert.Equal(now, result.Result.ServerTimeUtc);
}
}
@@ -0,0 +1,70 @@
using Baya.Infrastructure.CrossCutting.Seams;
using Microsoft.Extensions.Options;
namespace Baya.Test.Foundation;
public class SymmetricFieldEncryptorTests
{
private static SymmetricFieldEncryptor CreateEncryptor()
{
var options = Options.Create(new SeamOptions
{
FieldEncryption = new FieldEncryptionOptions
{
Key = "unit-test-field-encryption-key",
HashKey = "unit-test-hash-key"
}
});
return new SymmetricFieldEncryptor(options);
}
[Fact]
public void Decrypt_OfEncrypt_ReturnsOriginalPlaintext()
{
// Arrange
var encryptor = CreateEncryptor();
const string plaintext = "09123456789";
// Act
var cipher = encryptor.Encrypt(plaintext);
var roundTripped = encryptor.Decrypt(cipher);
// Assert
Assert.NotEqual(plaintext, cipher);
Assert.Equal(plaintext, roundTripped);
}
[Fact]
public void Encrypt_SameInputTwice_ProducesDifferentCiphertext()
{
// Arrange — a random IV per call means ciphertext is not deterministic (semantic security).
var encryptor = CreateEncryptor();
const string plaintext = "IR820540102680020817909002";
// Act
var first = encryptor.Encrypt(plaintext);
var second = encryptor.Encrypt(plaintext);
// Assert
Assert.NotEqual(first, second);
Assert.Equal(plaintext, encryptor.Decrypt(first));
Assert.Equal(plaintext, encryptor.Decrypt(second));
}
[Fact]
public void Hash_IsDeterministic_ForLookups()
{
// Arrange
var encryptor = CreateEncryptor();
const string value = "IR820540102680020817909002";
// Act
var first = encryptor.Hash(value);
var second = encryptor.Hash(value);
// Assert
Assert.Equal(first, second);
Assert.NotEqual(value, first);
}
}
@@ -0,0 +1 @@
global using Xunit;