add command update name,email add health query and dal queryable

This commit is contained in:
DavidGudEnough 2025-01-03 15:38:14 -05:00
parent 1de67205e0
commit f8829cd8bd
Signed by: david.nguyen
GPG Key ID: 0B95DC36355BEB37
26 changed files with 413 additions and 52 deletions

View File

@ -9,7 +9,7 @@ public class AppModule : IModule
{
public IServiceCollection ConfigureServices(IServiceCollection services)
{
//services.AddModule<DalModule>();
services.AddModule<DalModule>();
services.AddModule<SharedModule>();
services.AddModule<CommandModule>();
services.AddModule<QueryModule>();

View File

@ -39,14 +39,16 @@ builder.Services.AddDefaultCommandDiscovery();
builder.Services.AddDefaultQueryDiscovery();
builder.Services.AddFluentValidation();
builder.Services.AddModule<AppModule>();
builder.Services.AddDefaultCommandDiscovery();
builder.Services.AddDefaultQueryDiscovery();
if (builder.Configuration.GetValue<bool>("Swagger:Enable"))
{
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo { Title = "Plan B Route Service Api", Version = "0.1.0" });
options.SwaggerDoc("v1", new OpenApiInfo { Title = "Constellation Heating Api", Version = "0.1.0" });
options.AddSecurityDefinition("oauth2", new OpenApiSecurityScheme
{
@ -105,6 +107,7 @@ mvcBuilder
mvcBuilder
.AddOpenHarborQueries()
.AddOpenHarborDynamicQueries();
var connectionString = builder.Configuration.GetSection("Database").GetValue<string>("ConnectionString");
var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString);
@ -112,13 +115,7 @@ var dataSource = dataSourceBuilder.Build();
builder.Services.AddDbContextPool<CHDbContext>(options =>
{
options.UseNpgsql(dataSource, o =>
{
// todo: ef 9.0+
// o.MapEnum<RouteFileType>("route_file_type");
});
options.UseNpgsql(dataSource);
if (builder.Configuration.GetValue<bool>("Debug"))
{
AppContext.SetSwitch("Npgsql.EnableConnectionStringLogging", true);

View File

@ -21,7 +21,7 @@
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7184;http://localhost:5274",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"

View File

@ -1,22 +1,18 @@
using CH.Dal;
using CH.Dal.DbEntity;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Text;
using System.Threading.Tasks;
namespace CH.Authority.Services;
public class UserIdentityService
{
private User? _user;
private User? user;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IConfiguration _configuration;
private readonly CHDbContext _dbContext;
private readonly IHttpContextAccessor httpContextAccessor;
private readonly IConfiguration configuration;
private readonly CHDbContext dbContext;
public string? SubjectId { get; }
public string? Email { get; }
@ -28,9 +24,9 @@ public class UserIdentityService
public UserIdentityService(CHDbContext dbContext, IHttpContextAccessor httpContextAccessor,
IConfiguration configuration)
{
_dbContext = dbContext;
_httpContextAccessor = httpContextAccessor;
_configuration = configuration;
this.dbContext = dbContext;
this.httpContextAccessor = httpContextAccessor;
this.configuration = configuration;
// microsoft you twats! NameIdentifier = Sub
SubjectId = ResolveClaim(ClaimTypes.NameIdentifier);
@ -48,7 +44,7 @@ public class UserIdentityService
public bool IsAuthenticated()
{
var isAuthenticated = _httpContextAccessor.HttpContext?.User?.Identity?.IsAuthenticated ?? false;
var isAuthenticated = httpContextAccessor.HttpContext?.User?.Identity?.IsAuthenticated ?? false;
if (!isAuthenticated || null == EmailVerified)
return false;
@ -58,11 +54,11 @@ public class UserIdentityService
private string? ResolveClaim(params string[] name)
{
var isAuthenticated = _httpContextAccessor.HttpContext?.User?.Identity?.IsAuthenticated ?? false;
var isAuthenticated = httpContextAccessor.HttpContext?.User?.Identity?.IsAuthenticated ?? false;
if (false == isAuthenticated)
return null;
var claim = _httpContextAccessor.HttpContext.User.Claims.FirstOrDefault(claim => name.Contains(claim.Type));
var claim = httpContextAccessor.HttpContext?.User.Claims.FirstOrDefault(claim => name.Contains(claim.Type));
return claim?.Value;
}
public async Task<User?> GetUserOrDefaultAsync(CancellationToken cancellationToken = default)
@ -70,26 +66,23 @@ public class UserIdentityService
if (false == IsAuthenticated())
return null;
if (null != _user)
return _user;
if (null != user)
return user;
if (string.IsNullOrWhiteSpace(Email))
return null;
// otherwise search for the user by email
var email = Email?.ToLower() ?? "";
//_user = await _dbContext.Users
// .Include(user => user.SubjectId)
// .FirstOrDefaultAsync(user => user.Email.ToLower() == email, cancellationToken);
user = await dbContext.Users
.AsNoTracking()
.FirstOrDefaultAsync(user => user.SubjectId == SubjectId, cancellationToken);
// create user if it doesn't exist
if (null == _user)
{
_user = await CreateUserAsync(cancellationToken);
return _user;
}
return _user;
if (null == user)
user = await CreateUserAsync(cancellationToken);
return user;
}
public async Task<bool> IsAuthorizedAsync(CancellationToken cancellationToken)
{
@ -100,12 +93,17 @@ public class UserIdentityService
{
var user = new User
{
Email = Email,
FirstName = FirstName,
LastName = LastName,
Email = Email,
SubjectId = SubjectId,
Verified = EmailVerified ?? false,
};
//await _dbContext.SaveChangesAsync(cancellationToken);
dbContext.Users.Add(user);
await dbContext.SaveChangesAsync(cancellationToken);
return user;
}

View File

@ -15,6 +15,7 @@
<PackageReference Include="OpenHarbor.Storage.S3" Version="1.1.0" />
</ItemGroup>
<ItemGroup>
<Folder Include="Command\User\UpdateUserName\" />
<ProjectReference Include="..\CH.Authority\CH.Authority.csproj" />
<ProjectReference Include="..\CH.Dal\CH.Dal.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,14 @@
using Microsoft.Extensions.DependencyInjection;
using OpenHarbor.CQRS.FluentValidation;
namespace CH.CQRS.Command.User;
public static class ServiceCollectionExtension
{
public static IServiceCollection AddUserCommand(this IServiceCollection services)
{
services.AddCommand<UpdateNameCommand, UpdateNameCommandHandler, UpdateNameCommandValidator>();
services.AddCommand<UpdateEmailCommand, UpdateEmailCommandHandler, UpdateEmailCommandValidator>();
return services;
}
}

View File

@ -0,0 +1,46 @@
using CH.CQRS.Service.User;
using CH.CQRS.Service.User.Options;
using CH.Dal;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
using OpenHarbor.CQRS.Abstractions;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace CH.CQRS.Command.User;
public class UpdateEmailCommand
{
public required string Email { get; set; }
}
public class UpdateEmailCommandHandler(UserService userService) : ICommandHandler<UpdateEmailCommand>
{
public Task HandleAsync(UpdateEmailCommand command, CancellationToken cancellationToken = default)
{
return userService.UpdateEmailAsync(new UpdateEmailCommandOptions
{
Email = command.Email
}, cancellationToken);
}
}
public class UpdateEmailCommandValidator : AbstractValidator<UpdateEmailCommand>
{
public UpdateEmailCommandValidator(CHDbContext dbContext)
{
RuleFor(command => command.Email)
.NotEmpty()
.EmailAddress()
.MustAsync(async (email, CancellationToken) =>
{
var emailComparaison = email.Trim().ToLower();
var emailInUse = await dbContext.Users.AnyAsync(user => user.Email.ToLower() == emailComparaison);
return false == emailInUse;
})
.WithMessage("This email is already in use by another user.");
}
}

View File

@ -0,0 +1,35 @@
using CH.CQRS.Service.User;
using CH.CQRS.Service.User.Options;
using CH.Dal;
using FluentValidation;
using OpenHarbor.CQRS.Abstractions;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace CH.CQRS.Command.User;
public class UpdateNameCommand
{
public string? FirstName { get; set; }
public string? LastName { get; set; }
}
public class UpdateNameCommandHandler(UserService userService) : ICommandHandler<UpdateNameCommand>
{
public Task HandleAsync(UpdateNameCommand command, CancellationToken cancellationToken = default)
{
return userService.UpdateNameAsync(new UpdateNameCommandOptions
{
FirstName = command.FirstName,
LastName = command.LastName,
}, cancellationToken);
}
}
public class UpdateNameCommandValidator : AbstractValidator<UpdateNameCommand>
{
public UpdateNameCommandValidator()
{
}
}

View File

@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
using CH.CQRS.Command.User;
using Microsoft.Extensions.DependencyInjection;
using PoweredSoft.Module.Abstractions;
using System;
using System.Collections.Generic;
@ -11,7 +12,7 @@ public class CommandModule : IModule
{
public IServiceCollection ConfigureServices(IServiceCollection services)
{
services.AddUserCommand();
return services;
}

View File

@ -0,0 +1,51 @@
using OpenHarbor.CQRS.Abstractions;
using System.Diagnostics;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using CH.Dal;
namespace CH.CQRS.Query.Health;
public class HealthQuery
{
}
public class HealthQueryHandler(CHDbContext dbContext, IMemoryCache memoryCache) : IQueryHandler<HealthQuery, HealthQueryResult>
{
private const string HealthCheckCacheKey = "HealthStatus";
private const int CacheDurationInSeconds = 60;
public async Task<HealthQueryResult> HandleAsync(HealthQuery query, CancellationToken cancellationToken = new CancellationToken())
{
if (memoryCache.TryGetValue(HealthCheckCacheKey, out var memory))
{
return (HealthQueryResult)memory;
}
var (databaseStatus, databaseLatency) = await MeasureLatencyAsync(CheckDatabaseHealthAsync);
var healthCheckResult = new HealthQueryResult
{
ApiStatus = true,
DatabaseStatus = databaseStatus,
DatabaseLatency = $"{databaseLatency}ms",
};
memoryCache.Set(HealthCheckCacheKey, healthCheckResult, TimeSpan.FromSeconds(CacheDurationInSeconds));
return healthCheckResult;
}
public static async Task<(bool isHealthy, long latencyMs)> MeasureLatencyAsync(Func<Task<bool>> healthCheckAction)
{
var stopwatch = Stopwatch.StartNew();
var isHealthy = await healthCheckAction();
stopwatch.Stop();
return (isHealthy, stopwatch.ElapsedMilliseconds);
}
private async Task<bool> CheckDatabaseHealthAsync()
{
try
{
await dbContext.Database.ExecuteSqlRawAsync("SELECT 1");
return true;
}
catch
{
return false;
}
}
}

View File

@ -0,0 +1,14 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace CH.CQRS.Query.Health;
public class HealthQueryResult
{
public bool ApiStatus { get; set; }
public bool DatabaseStatus { get; set; }
public string? DatabaseLatency { get; set; }
}

View File

@ -1,4 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
using CH.CQRS.Query.Health;
using Microsoft.Extensions.DependencyInjection;
using OpenHarbor.CQRS.Abstractions;
using PoweredSoft.Module.Abstractions;
using System;
using System.Collections.Generic;
@ -11,7 +13,7 @@ public class QueryModule : IModule
{
public IServiceCollection ConfigureServices(IServiceCollection services)
{
services.AddQuery<HealthQuery, HealthQueryResult, HealthQueryHandler>();
return services;
}
}

View File

@ -0,0 +1,12 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace CH.CQRS.Service.User.Options;
public class UpdateEmailCommandOptions
{
public required string Email { get; set; }
}

View File

@ -0,0 +1,13 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace CH.CQRS.Service.User.Options;
public class UpdateNameCommandOptions
{
public string? FirstName { get; set; }
public string? LastName { get; set; }
}

View File

@ -0,0 +1,44 @@
using CH.Authority.Services;
using CH.CQRS.Service.User.Options;
using CH.Dal;
using Microsoft.EntityFrameworkCore;
using Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.Mapping;
namespace CH.CQRS.Service.User;
public class UserService(CHDbContext dbContext, UserIdentityService identityService)
{
public async Task UpdateEmailAsync(UpdateEmailCommandOptions options,CancellationToken cancellationToken)
{
// todo: change on oauth server first, make sure to persist changes if db save fail after keycloak api is succesful
var user = await dbContext.Users.FirstOrDefaultAsync(user => user.SubjectId == identityService.SubjectId, cancellationToken);
if (null != user)
{
if (user.Email != options.Email)
{
user.Email = options.Email;
}
dbContext.Users.Update(user);
await dbContext.SaveChangesAsync(cancellationToken);
}
return;
}
public async Task UpdateNameAsync(UpdateNameCommandOptions options,CancellationToken cancellationToken)
{
var user = await dbContext.Users.FirstOrDefaultAsync( user => user.SubjectId == identityService.SubjectId, cancellationToken);
if(null != user)
{
if (!String.IsNullOrWhiteSpace(options.FirstName) && user.FirstName != options.FirstName)
{
user.FirstName = options.FirstName;
}
if (!String.IsNullOrWhiteSpace(options.LastName) && user.LastName != options.LastName)
{
user.LastName = options.LastName;
}
dbContext.Users.Update(user);
await dbContext.SaveChangesAsync(cancellationToken);
}
return;
}
}

View File

@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
using CH.CQRS.Service.User;
using Microsoft.Extensions.DependencyInjection;
using PoweredSoft.Module.Abstractions;
namespace CH.CQRS;
@ -6,6 +7,7 @@ public class SharedModule : IModule
{
public IServiceCollection ConfigureServices(IServiceCollection services)
{
services.AddScoped<UserService>();
return services;
}
}

View File

@ -12,8 +12,9 @@ public class CHDbContext(DbContextOptions options) : CHDbScaffoldedContext(optio
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
return;

View File

@ -34,9 +34,13 @@ public partial class CHDbScaffoldedContext : DbContext
entity.ToTable("location");
entity.Property(e => e.Id).HasColumnName("id");
entity.Property(e => e.CreatedAt)
.HasDefaultValueSql("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC'::text)")
.HasColumnName("created_at");
entity.Property(e => e.Name)
.HasMaxLength(255)
.HasColumnName("name");
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at");
});
modelBuilder.Entity<Machine>(entity =>
@ -49,11 +53,15 @@ public partial class CHDbScaffoldedContext : DbContext
entity.Property(e => e.Address)
.HasMaxLength(255)
.HasColumnName("address");
entity.Property(e => e.CreatedAt)
.HasDefaultValueSql("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC'::text)")
.HasColumnName("created_at");
entity.Property(e => e.LocationId).HasColumnName("location_id");
entity.Property(e => e.Name)
.HasMaxLength(255)
.HasColumnName("name");
entity.Property(e => e.Status).HasColumnName("status");
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at");
entity.Property(e => e.UserId).HasColumnName("user_id");
entity.HasOne(d => d.Location).WithMany(p => p.Machines)
@ -67,19 +75,28 @@ public partial class CHDbScaffoldedContext : DbContext
modelBuilder.Entity<User>(entity =>
{
entity.HasKey(e => e.Id).HasName("user_pkey");
entity.HasKey(e => e.Id).HasName("users_pkey");
entity.ToTable("user");
entity.ToTable("users");
entity.Property(e => e.Id).HasColumnName("id");
entity.Property(e => e.CreatedAt)
.HasDefaultValueSql("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC'::text)")
.HasColumnName("created_at");
entity.Property(e => e.Email)
.HasMaxLength(255)
.HasColumnName("email");
entity.Property(e => e.FirstName).HasMaxLength(255);
entity.Property(e => e.LastName).HasMaxLength(255);
entity.Property(e => e.FirstName)
.HasMaxLength(255)
.HasColumnName("first_name");
entity.Property(e => e.LastName)
.HasMaxLength(255)
.HasColumnName("last_name");
entity.Property(e => e.SubjectId)
.HasMaxLength(255)
.HasColumnName("subject_id");
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at");
entity.Property(e => e.Verified).HasColumnName("verified");
});
OnModelCreatingPartial(modelBuilder);

View File

@ -8,6 +8,7 @@ public class DalModule : IModule
{
public IServiceCollection ConfigureServices(IServiceCollection services)
{
return services;
return services.AddSingleton<IAsyncQueryableHandlerService, InMemoryQueryableHandlerService>()
.AddTransient(typeof(IQueryableProvider<>), typeof(DefaultQueryableProvider<>));
}
}

View File

@ -9,5 +9,9 @@ public partial class Location
public string Name { get; set; } = null!;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public virtual ICollection<Machine> Machines { get; set; } = new List<Machine>();
}

View File

@ -17,6 +17,10 @@ public partial class Machine
public long? UserId { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public virtual Location? Location { get; set; }
public virtual User? User { get; set; }

View File

@ -15,5 +15,11 @@ public partial class User
public string SubjectId { get; set; } = null!;
public bool Verified { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public virtual ICollection<Machine> Machines { get; set; } = new List<Machine>();
}

View File

@ -0,0 +1,19 @@
using OpenHarbor.CQRS.DynamicQuery.Abstractions;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace CH.Dal;
public class DefaultQueryableProvider<TEntity>(CHDbContext dbContext, IServiceProvider serviceProvider) : IQueryableProvider<TEntity>
where TEntity : class
{
public Task<IQueryable<TEntity>> GetQueryableAsync(object query, CancellationToken cancellationToken = default)
{
if (serviceProvider.GetService(typeof(IQueryableProviderOverride<TEntity>)) is IQueryableProviderOverride<TEntity> queryableProviderOverride)
return queryableProviderOverride.GetQueryableAsync(query, cancellationToken);
return Task.FromResult(dbContext.Set<TEntity>().AsQueryable());
}
}

View File

@ -0,0 +1,11 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace CH.Dal;
public interface IQueryableProviderOverride<T>
{
Task<IQueryable<T>> GetQueryableAsync(object query, CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,52 @@
using PoweredSoft.Data.Core;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Text;
using System.Threading.Tasks;
namespace CH.Dal;
public class InMemoryQueryableHandlerService : IAsyncQueryableHandlerService
{
public Task<bool> AnyAsync<T>(IQueryable<T> queryable, Expression<Func<T, bool>> predicate, CancellationToken cancellationToken = default)
{
return Task.FromResult(queryable.Any(predicate));
}
public Task<bool> AnyAsync<T>(IQueryable<T> queryable, CancellationToken cancellationToken = default)
{
return Task.FromResult(queryable.Any());
}
public bool CanHandle<T>(IQueryable<T> queryable)
{
var result = queryable is EnumerableQuery<T>;
return result;
}
public Task<int> CountAsync<T>(IQueryable<T> queryable, CancellationToken cancellationToken = default)
{
return Task.FromResult(queryable.Count());
}
public Task<T> FirstOrDefaultAsync<T>(IQueryable<T> queryable, CancellationToken cancellationToken = default)
{
return Task.FromResult(queryable.FirstOrDefault());
}
public Task<T> FirstOrDefaultAsync<T>(IQueryable<T> queryable, Expression<Func<T, bool>> predicate, CancellationToken cancellationToken = default)
{
return Task.FromResult(queryable.FirstOrDefault(predicate));
}
public Task<long> LongCountAsync<T>(IQueryable<T> queryable, CancellationToken cancellationToken = default)
{
return Task.FromResult(queryable.LongCount());
}
public Task<List<T>> ToListAsync<T>(IQueryable<T> queryable, CancellationToken cancellationToken = default)
{
return Task.FromResult(queryable.ToList());
}
}

View File

@ -0,0 +1,16 @@
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace CH.Dal;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddQueryableProviderOverride<T, TService>(this IServiceCollection services)
where TService : class, IQueryableProviderOverride<T>
{
return services.AddTransient<IQueryableProviderOverride<T>, TService>();
}
}