commit 9032527af0a8187105c3465456f6da8047cf8597 Author: Mathias Beaulieu-Duncan Date: Thu Jan 2 15:10:52 2025 -0500 initial commit diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..755fc91 Binary files /dev/null and b/.DS_Store differ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3e2471e --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.idea/ +bin/ +obj/ +/packages/ +riderModule.iml +/_ReSharper.Caches/ \ No newline at end of file diff --git a/DigitalOps.Api/.DS_Store b/DigitalOps.Api/.DS_Store new file mode 100644 index 0000000..acbe96b Binary files /dev/null and b/DigitalOps.Api/.DS_Store differ diff --git a/DigitalOps.Api/AppModule.cs b/DigitalOps.Api/AppModule.cs new file mode 100644 index 0000000..c73bb5f --- /dev/null +++ b/DigitalOps.Api/AppModule.cs @@ -0,0 +1,20 @@ +using DigitalOps.Authority; +using DigitalOps.CQRS; +using DigitalOps.Dal; +using PoweredSoft.Module.Abstractions; + +namespace DigitalOps.Api; + +public class AppModule : IModule +{ + public IServiceCollection ConfigureServices(IServiceCollection services) + { + services.AddModule(); + + services.AddModule(); + services.AddModule(); + + services.AddModule(); + return services; + } +} \ No newline at end of file diff --git a/DigitalOps.Api/DigitalOps.Api.csproj b/DigitalOps.Api/DigitalOps.Api.csproj new file mode 100644 index 0000000..ecbe660 --- /dev/null +++ b/DigitalOps.Api/DigitalOps.Api.csproj @@ -0,0 +1,38 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + diff --git a/DigitalOps.Api/Program.cs b/DigitalOps.Api/Program.cs new file mode 100644 index 0000000..650b5e1 --- /dev/null +++ b/DigitalOps.Api/Program.cs @@ -0,0 +1,127 @@ +using System.Text.Json.Serialization; +using DigitalOps.Api; +using DigitalOps.Dal; +using DigitalOps.Dal.DbEntity; +using FluentValidation.AspNetCore; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using OpenHarbor.CQRS; +using OpenHarbor.CQRS.AspNetCore.Mvc; +using OpenHarbor.CQRS.DynamicQuery.AspNetCore; +using PoweredSoft.Data; +using PoweredSoft.Data.EntityFrameworkCore; +using PoweredSoft.DynamicQuery; +using PoweredSoft.Module.Abstractions; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.Configure(options => +{ + options.ForwardedHeaders = + ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost; + + options.KnownNetworks.Clear(); + options.KnownProxies.Clear(); + options.ForwardLimit = 2; +}); + +builder.Services.AddHttpContextAccessor(); +builder.Services.AddCors(); + +builder.Services.AddPoweredSoftDataServices(); +builder.Services.AddPoweredSoftEntityFrameworkCoreDataServices(); +builder.Services.AddPoweredSoftDynamicQuery(); +builder.Services.AddDefaultCommandDiscovery(); +builder.Services.AddDefaultQueryDiscovery(); + +builder.Services + .AddFluentValidation(); + +builder.Services.AddModule(); + +var mvcBuilder = builder.Services + .AddControllers() + .AddJsonOptions(jsonOptions => + { + jsonOptions.JsonSerializerOptions.Converters.Insert(0, new JsonStringEnumConverter()); + }); + +mvcBuilder + .AddOpenHarborCommands(); + +mvcBuilder + .AddOpenHarborQueries() + .AddOpenHarborDynamicQueries(); + + +// Add services to the container. +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options => + { + options.Authority = builder.Configuration["JwtBearer:Authority"]; + // check how to set up AudienceValidator to whitelist sites using it + options.TokenValidationParameters.ValidateAudience = false; + }); + +builder.Services.AddAuthorization(); + +var connectionString = builder.Configuration.GetSection("Database").GetValue("ConnectionString"); +var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString); +dataSourceBuilder.MapEnum("organization_role"); + +var dataSource = dataSourceBuilder.Build(); + +builder.Services.AddDbContextPool(options => +{ + options.UseNpgsql(dataSource, o => + { + // todo: ef 9.0+ + // o.MapEnum("route_file_type"); + }); + + if (builder.Configuration.GetValue("Debug")) + { + AppContext.SetSwitch("Npgsql.EnableConnectionStringLogging", true); + options + .EnableSensitiveDataLogging() + .EnableDetailedErrors(); + } +}); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseForwardedHeaders(); +app.UseHttpsRedirection(); + +app.UseCors(options => +{ + var origins = new List {"https://hoppscotch.io"}; + if (builder.Environment.IsDevelopment()) + { + origins.Add("http://localhost:8100"); + } + + options.WithOrigins(origins.ToArray()); + options.AllowCredentials(); + options.AllowAnyHeader(); + options.AllowAnyMethod(); +}); + +app.UseAuthentication(); +app.UseAuthorization(); +app.MapControllers(); + +app.Run(); \ No newline at end of file diff --git a/DigitalOps.Api/Properties/launchSettings.json b/DigitalOps.Api/Properties/launchSettings.json new file mode 100644 index 0000000..000c142 --- /dev/null +++ b/DigitalOps.Api/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:28117", + "sslPort": 44365 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5155", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7199;http://localhost:5155", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/DigitalOps.Api/appsettings.json b/DigitalOps.Api/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/DigitalOps.Api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/DigitalOps.Authority/Attributes/GuestAttribute.cs b/DigitalOps.Authority/Attributes/GuestAttribute.cs new file mode 100644 index 0000000..a51b80f --- /dev/null +++ b/DigitalOps.Authority/Attributes/GuestAttribute.cs @@ -0,0 +1,10 @@ +namespace DigitalOps.Authority.Attributes; + +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public class AllowGuestAttribute : System.Attribute +{ + public AllowGuestAttribute() + { + + } +} \ No newline at end of file diff --git a/DigitalOps.Authority/AuthorityModule.cs b/DigitalOps.Authority/AuthorityModule.cs new file mode 100644 index 0000000..f653562 --- /dev/null +++ b/DigitalOps.Authority/AuthorityModule.cs @@ -0,0 +1,21 @@ +using DigitalOps.Authority.Services; +using Microsoft.Extensions.DependencyInjection; +using OpenHarbor.CQRS.Abstractions.Security; +using PoweredSoft.Module.Abstractions; + +namespace DigitalOps.Authority; + +public class AuthorityModule : IModule +{ + public IServiceCollection ConfigureServices(IServiceCollection services) + { + services + .AddScoped(); + + services + .AddScoped(); + + services.AddScoped(); + return services; + } +} \ No newline at end of file diff --git a/DigitalOps.Authority/DigitalOps.Authority.csproj b/DigitalOps.Authority/DigitalOps.Authority.csproj new file mode 100644 index 0000000..c68edf1 --- /dev/null +++ b/DigitalOps.Authority/DigitalOps.Authority.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + ..\..\..\..\..\..\..\usr\local\share\dotnet\shared\Microsoft.AspNetCore.App\8.0.11\Microsoft.AspNetCore.Http.Abstractions.dll + + + + diff --git a/DigitalOps.Authority/Services/CQAuthorizationService.cs b/DigitalOps.Authority/Services/CQAuthorizationService.cs new file mode 100644 index 0000000..c4ed934 --- /dev/null +++ b/DigitalOps.Authority/Services/CQAuthorizationService.cs @@ -0,0 +1,43 @@ +using System.Reflection; +using DigitalOps.Authority.Attributes; +using OpenHarbor.CQRS.Abstractions.Discovery; +using OpenHarbor.CQRS.Abstractions.Security; +using OpenHarbor.CQRS.DynamicQuery.Discover; + +namespace DigitalOps.Authority.Services; + +public class CQAuthorizationService(IQueryDiscovery queryDiscovery, UserIdentityService userIdentityService) : IQueryAuthorizationService, ICommandAuthorizationService +{ + public async Task IsAllowedAsync(bool isQuery, Type queryOrCommandType, CancellationToken cancellationToken) + { + // determine subject type. + Type subjectType = queryOrCommandType; + if (isQuery) + { + var queryMeta = queryDiscovery.FindQuery(queryOrCommandType); + if (queryMeta != null && queryMeta is DynamicQueryMeta dqMeta) + { + subjectType = dqMeta.DestinationType; + } + } + + // allow guest calls. + var allowGuestAttributes = subjectType.GetCustomAttribute(true); + if (null != allowGuestAttributes) + return AuthorizationResult.Allowed; + + if (false == userIdentityService.IsAuthenticated()) + return AuthorizationResult.Unauthorized; + + + var isAllowed = await userIdentityService.IsAuthorizedAsync(cancellationToken); + return isAllowed ? AuthorizationResult.Allowed : AuthorizationResult.Forbidden; + } + + Task IQueryAuthorizationService.IsAllowedAsync(Type queryType, + CancellationToken cancellationToken) + => IsAllowedAsync(true, queryType, cancellationToken); + + Task ICommandAuthorizationService.IsAllowedAsync(Type commandType, CancellationToken cancellationToken) + => IsAllowedAsync(false, commandType, cancellationToken); +} \ No newline at end of file diff --git a/DigitalOps.Authority/Services/UserIdentityService.cs b/DigitalOps.Authority/Services/UserIdentityService.cs new file mode 100644 index 0000000..666c739 --- /dev/null +++ b/DigitalOps.Authority/Services/UserIdentityService.cs @@ -0,0 +1,148 @@ +using System.Security.Claims; +using DigitalOps.Dal; +using DigitalOps.Dal.DbEntity; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; + +namespace DigitalOps.Authority.Services; + +public class UserIdentityService +{ + private User? _user; + + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IConfiguration _configuration; + private readonly MainDbContext _dbContext; + + public string? SubjectId { get; } + public string? Email { get; } + public string? FirstName { get; } + public string? LastName { get; } + public bool? EmailVerified { get; } + public string? AuthIssuer { get; } + + public UserIdentityService(MainDbContext dbContext, IHttpContextAccessor httpContextAccessor, + IConfiguration configuration) + { + _dbContext = dbContext; + _httpContextAccessor = httpContextAccessor; + _configuration = configuration; + + // microsoft you twats! NameIdentifier = Sub + SubjectId = ResolveClaim(ClaimTypes.NameIdentifier); + Email = ResolveClaim(ClaimTypes.Email); + FirstName = ResolveClaim(ClaimTypes.GivenName); + LastName = ResolveClaim(ClaimTypes.Surname); + var emailVerifiedString = ResolveClaim("email_verified"); + if (null != emailVerifiedString && bool.TryParse(emailVerifiedString, out var emailVerified)) + { + EmailVerified = emailVerified; + } + + AuthIssuer = ResolveClaim("iss"); + } + + public bool IsAuthenticated() + { + var isAuthenticated = _httpContextAccessor.HttpContext?.User?.Identity?.IsAuthenticated ?? false; + + if (!isAuthenticated || null == EmailVerified) + return false; + + return EmailVerified ?? false; + } + + private string? ResolveClaim(params string[] name) + { + 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)); + return claim?.Value; + } + + public async Task GetUserOrDefaultAsync(CancellationToken cancellationToken = default) + { + if (false == IsAuthenticated()) + return null; + + if (null != _user) + return _user; + + if (string.IsNullOrWhiteSpace(Email)) + return null; + + var userOidc = await _dbContext.UserOidcs + .AsNoTracking() + .Include(oidc => oidc.User) + .FirstOrDefaultAsync(oidc => oidc.Issuer == AuthIssuer && oidc.Subject == SubjectId, cancellationToken); + + if (userOidc != null) + { + _user = userOidc.User; + return _user; + } + + // otherwise search for the user by email + var email = Email?.ToLower() ?? ""; + + _user = await _dbContext.Users + .Include(user => user.UserOidcs) + .FirstOrDefaultAsync(user => user.Email.ToLower() == email, cancellationToken); + + // create user if it doesn't exist + if (null == _user) + { + _user = await CreateUserAsync(cancellationToken); + return _user; + } + + await CreateUserOidcAsync(_user, true, cancellationToken); + return _user; + } + + public async Task IsAuthorizedAsync(CancellationToken cancellationToken) + { + var user = await GetUserOrDefaultAsync(cancellationToken); + return null != user; + } + + private async Task CreateUserOidcAsync(User user, bool save, CancellationToken cancellationToken) + { + // if user exist but oidc doesn't, we need to link the account + // todo: security concerns: a thirdparty oidc could be use to fake email verification to hijack an account, this has to be addressed before public release + + var mainIssuer = _configuration["JwtBearer:Authority"]; + var userOidc = new UserOidc + { + User = user, + Issuer = AuthIssuer, + Subject = SubjectId, + VerifiedAt = (mainIssuer == AuthIssuer) ? DateTime.UtcNow : null + }; + + _dbContext.UserOidcs.Add(userOidc); + + if (save) + await _dbContext.SaveChangesAsync(cancellationToken); + + return userOidc; + } + + private async Task CreateUserAsync(CancellationToken cancellationToken = default) + { + var user = new User + { + Email = Email, + FirstName = FirstName, + LastName = LastName, + }; + + await CreateUserOidcAsync(user, false, cancellationToken); + await _dbContext.SaveChangesAsync(cancellationToken); + + return user; + } +} \ No newline at end of file diff --git a/DigitalOps.Authority/Validators/HasAccessToClientValidator.cs b/DigitalOps.Authority/Validators/HasAccessToClientValidator.cs new file mode 100644 index 0000000..e665a87 --- /dev/null +++ b/DigitalOps.Authority/Validators/HasAccessToClientValidator.cs @@ -0,0 +1,42 @@ +using DigitalOps.Authority.Services; +using DigitalOps.Dal; +using DigitalOps.Dal.DbEntity; +using DigitalOps.Dal.Validators; +using FluentValidation; +using Microsoft.EntityFrameworkCore; + +namespace DigitalOps.Authority.Validators; + +public class HasAccessToClientValidator : AbstractValidator +{ + private readonly OrganizationRole _role; + private readonly MainDbContext _dbContext; + private readonly UserIdentityService _userIdentityService; + + public HasAccessToClientValidator(OrganizationRole role, MainDbContext dbContext, UserIdentityService userIdentityService) + { + _role = role; + _userIdentityService = userIdentityService; + _dbContext = dbContext; + + RuleFor(clientId => clientId) + .Cascade(CascadeMode.Stop) + .SetValidator(new DbEntityExistValidator(dbContext)) + .CustomAsync(async (clientId, validationContext, cancellationToken) => + { + var organizationClient = await _dbContext.OrganizationClients + .AsNoTracking() + .FirstOrDefaultAsync(organizationClient => organizationClient.ClientId == clientId, cancellationToken); + + if (organizationClient is null) + return; + + var validation = new HasAccessToOrganizationValidator(_role, _dbContext, _userIdentityService); + var validationResult = validation.Validate(organizationClient.OrganizationId); + + if (!validationResult.IsValid) + foreach (var error in validationResult.Errors) + validationContext.AddFailure(error); + }); + } +} \ No newline at end of file diff --git a/DigitalOps.Authority/Validators/HasAccessToOrganizationValidator.cs b/DigitalOps.Authority/Validators/HasAccessToOrganizationValidator.cs new file mode 100644 index 0000000..aba1b9a --- /dev/null +++ b/DigitalOps.Authority/Validators/HasAccessToOrganizationValidator.cs @@ -0,0 +1,51 @@ +using DigitalOps.Authority.Services; +using DigitalOps.Dal; +using DigitalOps.Dal.DbEntity; +using FluentValidation; +using Microsoft.EntityFrameworkCore; + +namespace DigitalOps.Authority.Validators; + +public class HasAccessToOrganizationValidator : AbstractValidator +{ + private readonly OrganizationRole _role; + private readonly MainDbContext _dbContext; + private readonly UserIdentityService _userIdentityService; + + public HasAccessToOrganizationValidator(OrganizationRole role, MainDbContext dbContext, UserIdentityService userIdentityService) + { + _role = role; + _userIdentityService = userIdentityService; + _dbContext = dbContext; + + RuleFor(organizationId => organizationId) + .MustAsync(HasAccess) + .WithMessage("You do not have the proper role to this organization for this action."); + } + + private async Task HasAccess(long organizationId, CancellationToken cancellationToken) + { + var user = await _userIdentityService.GetUserOrDefaultAsync(cancellationToken); + if (user is null) + return false; + + // todo: extract this into a reusable helper class + var roles = new List(); + if (_role == OrganizationRole.Owner) + roles.AddRange([OrganizationRole.Owner]); + + if (_role == OrganizationRole.Admin) + roles.AddRange([OrganizationRole.Owner, OrganizationRole.Admin]); + + if (_role == OrganizationRole.Member) + roles.AddRange([OrganizationRole.Owner, OrganizationRole.Admin, OrganizationRole.Member]); + + var result = await _dbContext.OrganizationUsers + .AsNoTracking() + .Where(organizationUser => organizationUser.OrganizationId == organizationId) + .Where(organizationUser => roles.Contains(organizationUser.Role)) + .AnyAsync(cancellationToken); + + return result; + } +} \ No newline at end of file diff --git a/DigitalOps.CQRS/CommandModule.cs b/DigitalOps.CQRS/CommandModule.cs new file mode 100644 index 0000000..597a493 --- /dev/null +++ b/DigitalOps.CQRS/CommandModule.cs @@ -0,0 +1,19 @@ +using DigitalOps.CQRS.Commands.Client; +using DigitalOps.CQRS.Commands.Organization; +using DigitalOps.CQRS.Commands.Project; +using Microsoft.Extensions.DependencyInjection; +using PoweredSoft.Module.Abstractions; + +namespace DigitalOps.CQRS; + +public class CommandModule : IModule +{ + public IServiceCollection ConfigureServices(IServiceCollection services) + { + services.AddModule(); + services.AddModule(); + services.AddModule(); + + return services; + } +} \ No newline at end of file diff --git a/DigitalOps.CQRS/Commands/Client/AddClientCommand.cs b/DigitalOps.CQRS/Commands/Client/AddClientCommand.cs new file mode 100644 index 0000000..e096f57 --- /dev/null +++ b/DigitalOps.CQRS/Commands/Client/AddClientCommand.cs @@ -0,0 +1,45 @@ +using DigitalOps.Authority.Services; +using DigitalOps.Authority.Validators; +using DigitalOps.CQRS.Services.Client; +using DigitalOps.CQRS.Services.Client.Options; +using DigitalOps.Dal; +using DigitalOps.Dal.DbEntity; +using DigitalOps.Dal.Validators; +using FluentValidation; +using OpenHarbor.CQRS.Abstractions; + +namespace DigitalOps.CQRS.Commands.Client; + +public class AddClientCommand +{ + public long OrganizationId { get; set; } + public required string Name { get; set; } +} + +public class AddClientCommandHandler(ClientService clientService) : ICommandHandler +{ + public async Task HandleAsync(AddClientCommand command, CancellationToken cancellationToken = default) + { + await clientService.AddClientAsync(new AddClientOptions + { + Name = command.Name, + OrganizationId = command.OrganizationId + }, cancellationToken); + } +} + +public class AddClientCommandValidator : AbstractValidator +{ + public AddClientCommandValidator(MainDbContext dbContext, UserIdentityService userIdentityService) + { + RuleFor(command => command.Name) + .NotEmpty() + .MinimumLength(3); + + RuleFor(command => command.OrganizationId) + .Cascade(CascadeMode.Stop) + .NotEmpty() + .SetValidator(new DbEntityExistValidator(dbContext)) + .SetValidator(new HasAccessToOrganizationValidator(OrganizationRole.Member, dbContext, userIdentityService)); + } +} \ No newline at end of file diff --git a/DigitalOps.CQRS/Commands/Client/RegisterClientCommandsModule.cs b/DigitalOps.CQRS/Commands/Client/RegisterClientCommandsModule.cs new file mode 100644 index 0000000..53084d4 --- /dev/null +++ b/DigitalOps.CQRS/Commands/Client/RegisterClientCommandsModule.cs @@ -0,0 +1,19 @@ +using DigitalOps.CQRS.Services.Client; +using Microsoft.Extensions.DependencyInjection; +using OpenHarbor.CQRS.FluentValidation; +using PoweredSoft.Module.Abstractions; + +namespace DigitalOps.CQRS.Commands.Client; + +public class RegisterClientCommandsModule : IModule +{ + public IServiceCollection ConfigureServices(IServiceCollection services) + { + services.AddModule(); + + services.AddCommand(); + + return services; + } +} \ No newline at end of file diff --git a/DigitalOps.CQRS/Commands/Organization/AddOrganizationCommand.cs b/DigitalOps.CQRS/Commands/Organization/AddOrganizationCommand.cs new file mode 100644 index 0000000..6826551 --- /dev/null +++ b/DigitalOps.CQRS/Commands/Organization/AddOrganizationCommand.cs @@ -0,0 +1,32 @@ +using DigitalOps.CQRS.Services.Organization; +using DigitalOps.CQRS.Services.Organization.Options; +using FluentValidation; +using OpenHarbor.CQRS.Abstractions; + +namespace DigitalOps.CQRS.Commands.Organization; + +public class AddOrganizationCommand +{ + public required string Name { get; set; } +} + +public class AddOrganizationCommandHandler(OrganizationService organizationService) : ICommandHandler +{ + public async Task HandleAsync(AddOrganizationCommand command, CancellationToken cancellationToken = new CancellationToken()) + { + await organizationService.RegisterOrganizationAsync(new RegisterOrganizationOptions + { + Name = command.Name + }, cancellationToken); + } +} + +public class AddOrganizationCommandValidator : AbstractValidator +{ + public AddOrganizationCommandValidator() + { + RuleFor(x => x.Name) + .NotEmpty() + .MinimumLength(3); + } +} \ No newline at end of file diff --git a/DigitalOps.CQRS/Commands/Organization/RegisterOrganizationCommandsModule.cs b/DigitalOps.CQRS/Commands/Organization/RegisterOrganizationCommandsModule.cs new file mode 100644 index 0000000..bda9986 --- /dev/null +++ b/DigitalOps.CQRS/Commands/Organization/RegisterOrganizationCommandsModule.cs @@ -0,0 +1,19 @@ +using DigitalOps.CQRS.Services.Organization; +using Microsoft.Extensions.DependencyInjection; +using OpenHarbor.CQRS.FluentValidation; +using PoweredSoft.Module.Abstractions; + +namespace DigitalOps.CQRS.Commands.Organization; + +public class RegisterOrganizationCommandsModule : IModule +{ + public IServiceCollection ConfigureServices(IServiceCollection services) + { + services.AddModule(); + + services.AddCommand(); + + return services; + } +} \ No newline at end of file diff --git a/DigitalOps.CQRS/Commands/Project/AddProjectCommand.cs b/DigitalOps.CQRS/Commands/Project/AddProjectCommand.cs new file mode 100644 index 0000000..600c96c --- /dev/null +++ b/DigitalOps.CQRS/Commands/Project/AddProjectCommand.cs @@ -0,0 +1,42 @@ +using DigitalOps.Authority.Services; +using DigitalOps.Authority.Validators; +using DigitalOps.CQRS.Services.Project; +using DigitalOps.CQRS.Services.Project.Options; +using DigitalOps.Dal; +using DigitalOps.Dal.DbEntity; +using FluentValidation; +using OpenHarbor.CQRS.Abstractions; + +namespace DigitalOps.CQRS.Commands.Project; + +public class AddProjectCommand +{ + public long ClientId { get; set; } + public required string Name { get; set; } +} + +public class AddProjectCommandHandler(ProjectService projectService) : ICommandHandler +{ + public async Task HandleAsync(AddProjectCommand command, CancellationToken cancellationToken = new CancellationToken()) + { + await projectService.AddProjectAsync(new AddProjectOptions + { + Name = command.Name, + ClientId = command.ClientId, + }, cancellationToken); + } +} + +public class AddProjectCommandValidator : AbstractValidator +{ + public AddProjectCommandValidator(MainDbContext dbContext, UserIdentityService userIdentityService) + { + RuleFor(command => command.Name) + .NotEmpty() + .MinimumLength(3); + + RuleFor(command => command.ClientId) + .NotEmpty() + .SetValidator(new HasAccessToClientValidator(OrganizationRole.Member, dbContext, userIdentityService)); + } +} \ No newline at end of file diff --git a/DigitalOps.CQRS/Commands/Project/RegisterProjectCommandsModule.cs b/DigitalOps.CQRS/Commands/Project/RegisterProjectCommandsModule.cs new file mode 100644 index 0000000..94d032f --- /dev/null +++ b/DigitalOps.CQRS/Commands/Project/RegisterProjectCommandsModule.cs @@ -0,0 +1,19 @@ +using DigitalOps.CQRS.Services.Project; +using Microsoft.Extensions.DependencyInjection; +using OpenHarbor.CQRS.FluentValidation; +using PoweredSoft.Module.Abstractions; + +namespace DigitalOps.CQRS.Commands.Project; + +public class RegisterProjectCommandsModule : IModule +{ + public IServiceCollection ConfigureServices(IServiceCollection services) + { + services.AddModule(); + + services.AddCommand(); + + return services; + } +} \ No newline at end of file diff --git a/DigitalOps.CQRS/Commands/TimeEntry/AddTimeEntryCommand.cs b/DigitalOps.CQRS/Commands/TimeEntry/AddTimeEntryCommand.cs new file mode 100644 index 0000000..0e1e5df --- /dev/null +++ b/DigitalOps.CQRS/Commands/TimeEntry/AddTimeEntryCommand.cs @@ -0,0 +1,45 @@ +using DigitalOps.Authority.Services; +using DigitalOps.CQRS.Services.TimeEntry; +using DigitalOps.CQRS.Services.TimeEntry.Options; +using DigitalOps.Dal; +using DigitalOps.Dal.Validators; +using FluentValidation; +using OpenHarbor.CQRS.Abstractions; + +namespace DigitalOps.CQRS.Commands.TimeEntry; + +public class AddTimeEntryCommand +{ + public long ProjectId { get; set; } + public long? ForUser { get; set; } + public required string Description { get; set; } + public DateTime StartAt { get; set; } + public DateTime? EndAt { get; set; } + public TimeOnly? Offset { get; set; } +} + +public class AddTimeEntryCommandHandler(TimeEntryService timeEntryService) : ICommandHandler +{ + public async Task HandleAsync(AddTimeEntryCommand command, CancellationToken cancellationToken = new CancellationToken()) + { + await timeEntryService.AddTimeEntryAsync(new AddTimeEntryOptions + { + ProjectId = command.ProjectId, + ForUser = command.ForUser, + Description = command.Description, + StartAt = command.StartAt, + EndAt = command.EndAt, + Offset = command.Offset, + }, cancellationToken); + } +} + +public class AddTimeEntryCommandValidator : AbstractValidator +{ + public AddTimeEntryCommandValidator(MainDbContext dbContext, UserIdentityService userIdentityService) + { + RuleFor(command => command.ProjectId) + .NotEmpty() + .SetValidator(new DbEntityExistValidator(dbContext)); + } +} \ No newline at end of file diff --git a/DigitalOps.CQRS/DigitalOps.CQRS.csproj b/DigitalOps.CQRS/DigitalOps.CQRS.csproj new file mode 100644 index 0000000..f50e255 --- /dev/null +++ b/DigitalOps.CQRS/DigitalOps.CQRS.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + + diff --git a/DigitalOps.CQRS/Queries/Client/ClientQuery.cs b/DigitalOps.CQRS/Queries/Client/ClientQuery.cs new file mode 100644 index 0000000..4f880e9 --- /dev/null +++ b/DigitalOps.CQRS/Queries/Client/ClientQuery.cs @@ -0,0 +1,38 @@ +using DigitalOps.Authority.Services; +using DigitalOps.Dal; +using Microsoft.EntityFrameworkCore; +using PoweredSoft.DynamicLinq; + +namespace DigitalOps.CQRS.Queries.Client; + +public class ClientItem +{ + public long Id { get; set; } + public IEnumerable OrganizationIds { get; set; } + public required string Name { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } +} + +public class ClientQueryableProvider(MainDbContext dbContext, UserIdentityService userIdentityService): IQueryableProviderOverride +{ + public async Task> GetQueryableAsync(object query, CancellationToken cancellationToken = default) + { + var user = await userIdentityService.GetUserOrDefaultAsync(cancellationToken); + + var queryable = dbContext.Clients + .AsNoTracking() + .Where(client => + client.OrganizationClients.Any(organizationClient => organizationClient.Organization.OrganizationUsers.Any(organizationUser => organizationUser.UserId == user!.Id))) + .Select(client => new ClientItem + { + Id = client.Id, + OrganizationIds = client.OrganizationClients.Select(organizationClient => organizationClient.OrganizationId), + Name = client.Name, + CreatedAt = client.CreatedAt, + UpdatedAt = client.UpdatedAt + }); + + return queryable; + } +} \ No newline at end of file diff --git a/DigitalOps.CQRS/Queries/Client/RegisterOrganizationQueriesModule.cs b/DigitalOps.CQRS/Queries/Client/RegisterOrganizationQueriesModule.cs new file mode 100644 index 0000000..3e941ac --- /dev/null +++ b/DigitalOps.CQRS/Queries/Client/RegisterOrganizationQueriesModule.cs @@ -0,0 +1,17 @@ +using DigitalOps.Dal; +using Microsoft.Extensions.DependencyInjection; +using OpenHarbor.CQRS.DynamicQuery; +using PoweredSoft.Module.Abstractions; + +namespace DigitalOps.CQRS.Queries.Client; + +public class RegisterClientQueriesModule : IModule +{ + public IServiceCollection ConfigureServices(IServiceCollection services) + { + services.AddDynamicQuery() + .AddQueryableProviderOverride(); + + return services; + } +} \ No newline at end of file diff --git a/DigitalOps.CQRS/Queries/Health/HealthQuery.cs b/DigitalOps.CQRS/Queries/Health/HealthQuery.cs new file mode 100644 index 0000000..2be4c32 --- /dev/null +++ b/DigitalOps.CQRS/Queries/Health/HealthQuery.cs @@ -0,0 +1,16 @@ +using DigitalOps.Authority.Attributes; +using OpenHarbor.CQRS.Abstractions; + +namespace DigitalOps.CQRS.Queries.Health; + +[AllowGuest] +public class HealthQuery +{ + +} + +public class HealthQueryHandler : IQueryHandler +{ + public Task HandleAsync(HealthQuery query, CancellationToken cancellationToken = new CancellationToken()) + => Task.FromResult(true); +} \ No newline at end of file diff --git a/DigitalOps.CQRS/Queries/Health/ServiceCollectionExtensions.cs b/DigitalOps.CQRS/Queries/Health/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..38990e1 --- /dev/null +++ b/DigitalOps.CQRS/Queries/Health/ServiceCollectionExtensions.cs @@ -0,0 +1,14 @@ +using Microsoft.Extensions.DependencyInjection; +using OpenHarbor.CQRS.Abstractions; + +namespace DigitalOps.CQRS.Queries.Health; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddHealthQueries(this IServiceCollection services) + { + services.AddQuery(); + + return services; + } +} \ No newline at end of file diff --git a/DigitalOps.CQRS/Queries/Organization/OrganizationQuery.cs b/DigitalOps.CQRS/Queries/Organization/OrganizationQuery.cs new file mode 100644 index 0000000..e6e31db --- /dev/null +++ b/DigitalOps.CQRS/Queries/Organization/OrganizationQuery.cs @@ -0,0 +1,35 @@ +using DigitalOps.Authority.Services; +using DigitalOps.Dal; +using Microsoft.EntityFrameworkCore; + +namespace DigitalOps.CQRS.Queries.Organization; + +public class OrganizationItem +{ + public long Id { get; set; } + public required string Name { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } +} + +public class OrganizationQueryableProvider(MainDbContext dbContext, UserIdentityService userIdentityService): IQueryableProviderOverride +{ + public async Task> GetQueryableAsync(object query, CancellationToken cancellationToken = default) + { + var user = await userIdentityService.GetUserOrDefaultAsync(cancellationToken); + + var queryable = dbContext.Organizations + .AsNoTracking() + .Where(organization => + organization.OrganizationUsers.Any(organizationUser => organizationUser.UserId == user!.Id)) + .Select(organization => new OrganizationItem + { + Id = organization.Id, + Name = organization.Name, + CreatedAt = organization.CreatedAt, + UpdatedAt = organization.UpdatedAt + }); + + return queryable; + } +} \ No newline at end of file diff --git a/DigitalOps.CQRS/Queries/Organization/RegisterOrganizationQueriesModule.cs b/DigitalOps.CQRS/Queries/Organization/RegisterOrganizationQueriesModule.cs new file mode 100644 index 0000000..fde478a --- /dev/null +++ b/DigitalOps.CQRS/Queries/Organization/RegisterOrganizationQueriesModule.cs @@ -0,0 +1,17 @@ +using DigitalOps.Dal; +using Microsoft.Extensions.DependencyInjection; +using OpenHarbor.CQRS.DynamicQuery; +using PoweredSoft.Module.Abstractions; + +namespace DigitalOps.CQRS.Queries.Organization; + +public class RegisterOrganizationQueriesModule : IModule +{ + public IServiceCollection ConfigureServices(IServiceCollection services) + { + services.AddDynamicQuery() + .AddQueryableProviderOverride(); + + return services; + } +} \ No newline at end of file diff --git a/DigitalOps.CQRS/Queries/Project/ProjectQuery.cs b/DigitalOps.CQRS/Queries/Project/ProjectQuery.cs new file mode 100644 index 0000000..c046232 --- /dev/null +++ b/DigitalOps.CQRS/Queries/Project/ProjectQuery.cs @@ -0,0 +1,72 @@ +using DigitalOps.Authority.Services; +using DigitalOps.Dal; +using Microsoft.EntityFrameworkCore; +using OpenHarbor.CQRS.DynamicQuery.Abstractions; + +namespace DigitalOps.CQRS.Queries.Project; + +public class ProjectQueryParams +{ + public long? OrganizationId { get; set; } + public long? ClientId { get; set; } +} + +public class ProjectItem +{ + public long Id { get; set; } + public long ClientId { get; set; } + public required string Name { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } +} + +public class ProjectQueryableProvider(MainDbContext dbContext, UserIdentityService userIdentityService): IQueryableProviderOverride +{ + public async Task> GetQueryableAsync(object query, CancellationToken cancellationToken = default) + { + long? organizationId = null; + long? clientId = null; + + if (query is IDynamicQueryParams dynamicQuery) + { + var queryParams = dynamicQuery.GetParams(); + organizationId = queryParams?.OrganizationId; + clientId = queryParams?.ClientId; + } + + var user = await userIdentityService.GetUserOrDefaultAsync(cancellationToken); + + var queryable = dbContext.Projects + .AsNoTracking() + .AsQueryable(); + + if (organizationId is null) + { + // don't bother to call the database if organizationId is not set + return Enumerable.Empty().AsQueryable(); + } + + var organizationUser = await dbContext.OrganizationUsers + .FirstOrDefaultAsync(organizationUser => organizationUser.UserId == user!.Id, cancellationToken); + + if (clientId is not null) + { + queryable = queryable.Where(project => project.ClientId == clientId); + } + + queryable = queryable + .Where(project => project.Client.OrganizationClients.Any(organizationClient => + organizationClient.OrganizationId == organizationUser!.OrganizationId)); + + var result = queryable.Select(project => new ProjectItem + { + Id = project.Id, + ClientId = project.ClientId, + Name = project.Name, + CreatedAt = project.CreatedAt, + UpdatedAt = project.UpdatedAt + }); + + return result; + } +} \ No newline at end of file diff --git a/DigitalOps.CQRS/Queries/Project/RegisterProjectQueriesModule.cs b/DigitalOps.CQRS/Queries/Project/RegisterProjectQueriesModule.cs new file mode 100644 index 0000000..cd3502a --- /dev/null +++ b/DigitalOps.CQRS/Queries/Project/RegisterProjectQueriesModule.cs @@ -0,0 +1,17 @@ +using DigitalOps.Dal; +using Microsoft.Extensions.DependencyInjection; +using OpenHarbor.CQRS.DynamicQuery; +using PoweredSoft.Module.Abstractions; + +namespace DigitalOps.CQRS.Queries.Project; + +public class RegisterProjectQueriesModule : IModule +{ + public IServiceCollection ConfigureServices(IServiceCollection services) + { + services.AddDynamicQueryWithParams() + .AddQueryableProviderOverride(); + + return services; + } +} \ No newline at end of file diff --git a/DigitalOps.CQRS/QueryModule.cs b/DigitalOps.CQRS/QueryModule.cs new file mode 100644 index 0000000..6fc1f01 --- /dev/null +++ b/DigitalOps.CQRS/QueryModule.cs @@ -0,0 +1,22 @@ +using DigitalOps.CQRS.Queries.Client; +using DigitalOps.CQRS.Queries.Health; +using DigitalOps.CQRS.Queries.Organization; +using DigitalOps.CQRS.Queries.Project; +using Microsoft.Extensions.DependencyInjection; +using PoweredSoft.Module.Abstractions; + +namespace DigitalOps.CQRS; + +public class QueryModule : IModule +{ + public IServiceCollection ConfigureServices(IServiceCollection services) + { + services.AddHealthQueries(); + + services.AddModule(); + services.AddModule(); + services.AddModule(); + + return services; + } +} \ No newline at end of file diff --git a/DigitalOps.CQRS/Services/Client/ClientService.cs b/DigitalOps.CQRS/Services/Client/ClientService.cs new file mode 100644 index 0000000..7a118a8 --- /dev/null +++ b/DigitalOps.CQRS/Services/Client/ClientService.cs @@ -0,0 +1,36 @@ +using DigitalOps.Authority.Services; +using DigitalOps.CQRS.Services.Client.Options; +using DigitalOps.Dal; +using DigitalOps.Dal.DbEntity; +using Microsoft.EntityFrameworkCore; + +namespace DigitalOps.CQRS.Services.Client; + +public class ClientService(MainDbContext dbContext, UserIdentityService userIdentityService) +{ + public async Task AddClientAsync(AddClientOptions options, CancellationToken cancellationToken = default) + { + var user = await userIdentityService.GetUserOrDefaultAsync(cancellationToken); + if (null == user) + return; + + var organization = await dbContext.Organizations.FirstOrDefaultAsync(organization => organization.Id == options.OrganizationId, cancellationToken); + + if (null == organization) + return; + + var client = new Dal.DbEntity.Client + { + Name = options.Name + }; + + var organizationClient = new OrganizationClient + { + Organization = organization, + Client = client + }; + + organization.OrganizationClients.Add(organizationClient); + await dbContext.SaveChangesAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/DigitalOps.CQRS/Services/Client/Options/AddClientOptions.cs b/DigitalOps.CQRS/Services/Client/Options/AddClientOptions.cs new file mode 100644 index 0000000..fb6070b --- /dev/null +++ b/DigitalOps.CQRS/Services/Client/Options/AddClientOptions.cs @@ -0,0 +1,7 @@ +namespace DigitalOps.CQRS.Services.Client.Options; + +public class AddClientOptions +{ + public string Name { get; set; } + public long OrganizationId { get; set; } +} \ No newline at end of file diff --git a/DigitalOps.CQRS/Services/Client/RegisterClientServicesModule.cs b/DigitalOps.CQRS/Services/Client/RegisterClientServicesModule.cs new file mode 100644 index 0000000..9434c5e --- /dev/null +++ b/DigitalOps.CQRS/Services/Client/RegisterClientServicesModule.cs @@ -0,0 +1,13 @@ +using Microsoft.Extensions.DependencyInjection; +using PoweredSoft.Module.Abstractions; + +namespace DigitalOps.CQRS.Services.Client; + +public class RegisterClientServicesModule : IModule +{ + public IServiceCollection ConfigureServices(IServiceCollection services) + { + services.AddScoped(); + return services; + } +} \ No newline at end of file diff --git a/DigitalOps.CQRS/Services/Organization/Options/RegisterOrganizationOptions.cs b/DigitalOps.CQRS/Services/Organization/Options/RegisterOrganizationOptions.cs new file mode 100644 index 0000000..a66f9b3 --- /dev/null +++ b/DigitalOps.CQRS/Services/Organization/Options/RegisterOrganizationOptions.cs @@ -0,0 +1,6 @@ +namespace DigitalOps.CQRS.Services.Organization.Options; + +public class RegisterOrganizationOptions +{ + public required string Name { get; set; } +} \ No newline at end of file diff --git a/DigitalOps.CQRS/Services/Organization/OrganizationService.cs b/DigitalOps.CQRS/Services/Organization/OrganizationService.cs new file mode 100644 index 0000000..43fea20 --- /dev/null +++ b/DigitalOps.CQRS/Services/Organization/OrganizationService.cs @@ -0,0 +1,32 @@ +using DigitalOps.Authority.Services; +using DigitalOps.CQRS.Services.Organization.Options; +using DigitalOps.Dal; +using DigitalOps.Dal.DbEntity; + +namespace DigitalOps.CQRS.Services.Organization; + +public class OrganizationService(MainDbContext dbContext, UserIdentityService userIdentityService) +{ + public async Task RegisterOrganizationAsync(RegisterOrganizationOptions options, CancellationToken cancellationToken = default) + { + var organization = new Dal.DbEntity.Organization + { + Name = options.Name + }; + + var user = await userIdentityService.GetUserOrDefaultAsync(cancellationToken); + + if (null == user) + return; + + var organizationUser = new OrganizationUser + { + Organization = organization, + Role = OrganizationRole.Owner, + UserId = user.Id, + }; + + dbContext.OrganizationUsers.Add(organizationUser); + await dbContext.SaveChangesAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/DigitalOps.CQRS/Services/Organization/RegisterOrganizationServicesModule.cs b/DigitalOps.CQRS/Services/Organization/RegisterOrganizationServicesModule.cs new file mode 100644 index 0000000..ce05bde --- /dev/null +++ b/DigitalOps.CQRS/Services/Organization/RegisterOrganizationServicesModule.cs @@ -0,0 +1,13 @@ +using Microsoft.Extensions.DependencyInjection; +using PoweredSoft.Module.Abstractions; + +namespace DigitalOps.CQRS.Services.Organization; + +public class RegisterOrganizationServicesModule : IModule +{ + public IServiceCollection ConfigureServices(IServiceCollection services) + { + services.AddScoped(); + return services; + } +} \ No newline at end of file diff --git a/DigitalOps.CQRS/Services/Project/Options/AddProjectOptions.cs b/DigitalOps.CQRS/Services/Project/Options/AddProjectOptions.cs new file mode 100644 index 0000000..c2290ab --- /dev/null +++ b/DigitalOps.CQRS/Services/Project/Options/AddProjectOptions.cs @@ -0,0 +1,7 @@ +namespace DigitalOps.CQRS.Services.Project.Options; + +public class AddProjectOptions +{ + public string Name { get; set; } + public long ClientId { get; set; } +} \ No newline at end of file diff --git a/DigitalOps.CQRS/Services/Project/ProjectService.cs b/DigitalOps.CQRS/Services/Project/ProjectService.cs new file mode 100644 index 0000000..f18b9b5 --- /dev/null +++ b/DigitalOps.CQRS/Services/Project/ProjectService.cs @@ -0,0 +1,29 @@ +using DigitalOps.Authority.Services; +using DigitalOps.CQRS.Services.Project.Options; +using DigitalOps.Dal; +using Microsoft.EntityFrameworkCore; + +namespace DigitalOps.CQRS.Services.Project; + +public class ProjectService(MainDbContext dbContext, UserIdentityService userIdentityService) +{ + public async Task AddProjectAsync(AddProjectOptions options, CancellationToken cancellationToken) + { + var user = await userIdentityService.GetUserOrDefaultAsync(cancellationToken); + if (null == user) + return; + + var client = await dbContext.Clients.FirstOrDefaultAsync(client => client.Id == options.ClientId, cancellationToken); + if (null == client) + return; + + var project = new Dal.DbEntity.Project + { + Name = options.Name, + Client = client + }; + + await dbContext.Projects.AddAsync(project, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/DigitalOps.CQRS/Services/Project/RegisterProjectServicesModule.cs b/DigitalOps.CQRS/Services/Project/RegisterProjectServicesModule.cs new file mode 100644 index 0000000..50c3ce4 --- /dev/null +++ b/DigitalOps.CQRS/Services/Project/RegisterProjectServicesModule.cs @@ -0,0 +1,13 @@ +using Microsoft.Extensions.DependencyInjection; +using PoweredSoft.Module.Abstractions; + +namespace DigitalOps.CQRS.Services.Project; + +public class RegisterProjectServicesModule : IModule +{ + public IServiceCollection ConfigureServices(IServiceCollection services) + { + services.AddScoped(); + return services; + } +} \ No newline at end of file diff --git a/DigitalOps.CQRS/Services/TimeEntry/Options/AddTimeEntryOptions.cs b/DigitalOps.CQRS/Services/TimeEntry/Options/AddTimeEntryOptions.cs new file mode 100644 index 0000000..c79524e --- /dev/null +++ b/DigitalOps.CQRS/Services/TimeEntry/Options/AddTimeEntryOptions.cs @@ -0,0 +1,11 @@ +namespace DigitalOps.CQRS.Services.TimeEntry.Options; + +public class AddTimeEntryOptions +{ + public long ProjectId { get; set; } + public long? ForUser { get; set; } + public required string Description { get; set; } + public DateTime StartAt { get; set; } + public DateTime? EndAt { get; set; } + public TimeOnly? Offset { get; set; } +} \ No newline at end of file diff --git a/DigitalOps.CQRS/Services/TimeEntry/TimeEntryService.cs b/DigitalOps.CQRS/Services/TimeEntry/TimeEntryService.cs new file mode 100644 index 0000000..0cc195f --- /dev/null +++ b/DigitalOps.CQRS/Services/TimeEntry/TimeEntryService.cs @@ -0,0 +1,30 @@ +using DigitalOps.Authority.Services; +using DigitalOps.CQRS.Services.TimeEntry.Options; +using DigitalOps.Dal; +using DigitalOps.Dal.DbEntity; + +namespace DigitalOps.CQRS.Services.TimeEntry; + +public class TimeEntryService(MainDbContext dbContext, UserIdentityService userIdentityService) +{ + public async Task AddTimeEntryAsync(AddTimeEntryOptions options, CancellationToken cancellationToken = default) + { + var user = await userIdentityService.GetUserOrDefaultAsync(cancellationToken); + if (user is null) + return; + + var timeEntry = new ProjectTimeEntry + { + ProjectId = options.ProjectId, + EntryUserId = user.Id, + UserId = options.ForUser ?? user.Id, + Description = options.Description, + Offset = options.Offset, + StartedAt = options.StartAt, + EndedAt = options.EndAt + }; + + dbContext.ProjectTimeEntries.Add(timeEntry); + await dbContext.SaveChangesAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/DigitalOps.Dal.Abstractions/DigitalOps.Dal.Abstractions.csproj b/DigitalOps.Dal.Abstractions/DigitalOps.Dal.Abstractions.csproj new file mode 100644 index 0000000..fa71b7a --- /dev/null +++ b/DigitalOps.Dal.Abstractions/DigitalOps.Dal.Abstractions.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + diff --git a/DigitalOps.Dal.Abstractions/IHasId.cs b/DigitalOps.Dal.Abstractions/IHasId.cs new file mode 100644 index 0000000..d1b2536 --- /dev/null +++ b/DigitalOps.Dal.Abstractions/IHasId.cs @@ -0,0 +1,6 @@ +namespace DigitalOps.Dal.Abstractions; + +public interface IHasId +{ + public T Id { get; set; } +} \ No newline at end of file diff --git a/DigitalOps.Dal/DalModule.cs b/DigitalOps.Dal/DalModule.cs new file mode 100644 index 0000000..7c23a69 --- /dev/null +++ b/DigitalOps.Dal/DalModule.cs @@ -0,0 +1,15 @@ +using Microsoft.Extensions.DependencyInjection; +using OpenHarbor.CQRS.DynamicQuery.Abstractions; +using PoweredSoft.Data.Core; +using PoweredSoft.Module.Abstractions; + +namespace DigitalOps.Dal; + +public class DalModule : IModule +{ + public IServiceCollection ConfigureServices(IServiceCollection services) + { + return services.AddSingleton() + .AddTransient(typeof(IQueryableProvider<>), typeof(DefaultQueryableProvider<>)); + } +} \ No newline at end of file diff --git a/DigitalOps.Dal/DbEntity/Client.Extensions.cs b/DigitalOps.Dal/DbEntity/Client.Extensions.cs new file mode 100644 index 0000000..974c1b5 --- /dev/null +++ b/DigitalOps.Dal/DbEntity/Client.Extensions.cs @@ -0,0 +1,8 @@ +using DigitalOps.Dal.Abstractions; + +namespace DigitalOps.Dal.DbEntity; + +public partial class Client : IHasId +{ + +} \ No newline at end of file diff --git a/DigitalOps.Dal/DbEntity/Client.cs b/DigitalOps.Dal/DbEntity/Client.cs new file mode 100644 index 0000000..a2b2bfd --- /dev/null +++ b/DigitalOps.Dal/DbEntity/Client.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; + +namespace DigitalOps.Dal.DbEntity; + +public partial class Client +{ + public long Id { get; set; } + + public string Name { get; set; } = null!; + + public DateTime CreatedAt { get; set; } + + public DateTime? UpdatedAt { get; set; } + + public virtual ICollection OrganizationClients { get; set; } = new List(); + + public virtual ICollection Projects { get; set; } = new List(); +} diff --git a/DigitalOps.Dal/DbEntity/OidcProvider.cs b/DigitalOps.Dal/DbEntity/OidcProvider.cs new file mode 100644 index 0000000..72cdb46 --- /dev/null +++ b/DigitalOps.Dal/DbEntity/OidcProvider.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; + +namespace DigitalOps.Dal.DbEntity; + +public partial class OidcProvider +{ + public long Id { get; set; } + + public long OrganizationId { get; set; } + + public string Label { get; set; } = null!; + + public string Issuer { get; set; } = null!; + + public DateTime CreatedAt { get; set; } + + public DateTime? UpdatedAt { get; set; } + + public virtual Organization Organization { get; set; } = null!; +} diff --git a/DigitalOps.Dal/DbEntity/Organization.Extensions.cs b/DigitalOps.Dal/DbEntity/Organization.Extensions.cs new file mode 100644 index 0000000..671fbbf --- /dev/null +++ b/DigitalOps.Dal/DbEntity/Organization.Extensions.cs @@ -0,0 +1,8 @@ +using DigitalOps.Dal.Abstractions; + +namespace DigitalOps.Dal.DbEntity; + +public partial class Organization : IHasId +{ + +} \ No newline at end of file diff --git a/DigitalOps.Dal/DbEntity/Organization.cs b/DigitalOps.Dal/DbEntity/Organization.cs new file mode 100644 index 0000000..07f7d24 --- /dev/null +++ b/DigitalOps.Dal/DbEntity/Organization.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; + +namespace DigitalOps.Dal.DbEntity; + +public partial class Organization +{ + public long Id { get; set; } + + public string Name { get; set; } = null!; + + public DateTime CreatedAt { get; set; } + + public DateTime? UpdatedAt { get; set; } + + public virtual ICollection OidcProviders { get; set; } = new List(); + + public virtual ICollection OrganizationClients { get; set; } = new List(); + + public virtual ICollection OrganizationUsers { get; set; } = new List(); +} diff --git a/DigitalOps.Dal/DbEntity/OrganizationClient.cs b/DigitalOps.Dal/DbEntity/OrganizationClient.cs new file mode 100644 index 0000000..b493e44 --- /dev/null +++ b/DigitalOps.Dal/DbEntity/OrganizationClient.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; + +namespace DigitalOps.Dal.DbEntity; + +public partial class OrganizationClient +{ + public long Id { get; set; } + + public long OrganizationId { get; set; } + + public long ClientId { get; set; } + + public DateTime CreatedAt { get; set; } + + public DateTime? UpdatedAt { get; set; } + + public virtual Client Client { get; set; } = null!; + + public virtual Organization Organization { get; set; } = null!; +} diff --git a/DigitalOps.Dal/DbEntity/OrganizationRole.cs b/DigitalOps.Dal/DbEntity/OrganizationRole.cs new file mode 100644 index 0000000..ffc86a3 --- /dev/null +++ b/DigitalOps.Dal/DbEntity/OrganizationRole.cs @@ -0,0 +1,15 @@ +using NpgsqlTypes; + +namespace DigitalOps.Dal.DbEntity; + +public enum OrganizationRole +{ + [PgName("owner")] + Owner, + + [PgName("admin")] + Admin, + + [PgName("member")] + Member +} \ No newline at end of file diff --git a/DigitalOps.Dal/DbEntity/OrganizationUser.Extensions.cs b/DigitalOps.Dal/DbEntity/OrganizationUser.Extensions.cs new file mode 100644 index 0000000..5bcdc54 --- /dev/null +++ b/DigitalOps.Dal/DbEntity/OrganizationUser.Extensions.cs @@ -0,0 +1,6 @@ +namespace DigitalOps.Dal.DbEntity; + +public partial class OrganizationUser +{ + public OrganizationRole Role { get; set; } +} \ No newline at end of file diff --git a/DigitalOps.Dal/DbEntity/OrganizationUser.cs b/DigitalOps.Dal/DbEntity/OrganizationUser.cs new file mode 100644 index 0000000..8c971b3 --- /dev/null +++ b/DigitalOps.Dal/DbEntity/OrganizationUser.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; + +namespace DigitalOps.Dal.DbEntity; + +public partial class OrganizationUser +{ + public long Id { get; set; } + + public long OrganizationId { get; set; } + + public long UserId { get; set; } + + public DateTime CreatedAt { get; set; } + + public DateTime? UpdatedAt { get; set; } + + public virtual Organization Organization { get; set; } = null!; + + public virtual User User { get; set; } = null!; +} diff --git a/DigitalOps.Dal/DbEntity/Project.Extensions.cs b/DigitalOps.Dal/DbEntity/Project.Extensions.cs new file mode 100644 index 0000000..3e31674 --- /dev/null +++ b/DigitalOps.Dal/DbEntity/Project.Extensions.cs @@ -0,0 +1,8 @@ +using DigitalOps.Dal.Abstractions; + +namespace DigitalOps.Dal.DbEntity; + +public partial class Project : IHasId +{ + +} \ No newline at end of file diff --git a/DigitalOps.Dal/DbEntity/Project.cs b/DigitalOps.Dal/DbEntity/Project.cs new file mode 100644 index 0000000..d77e2e7 --- /dev/null +++ b/DigitalOps.Dal/DbEntity/Project.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; + +namespace DigitalOps.Dal.DbEntity; + +public partial class Project +{ + public long Id { get; set; } + + public long ClientId { get; set; } + + public string Name { get; set; } = null!; + + public DateTime CreatedAt { get; set; } + + public DateTime? UpdatedAt { get; set; } + + public virtual Client Client { get; set; } = null!; + + public virtual ICollection ProjectTimeEntries { get; set; } = new List(); +} diff --git a/DigitalOps.Dal/DbEntity/ProjectTimeEntry.cs b/DigitalOps.Dal/DbEntity/ProjectTimeEntry.cs new file mode 100644 index 0000000..b3567f6 --- /dev/null +++ b/DigitalOps.Dal/DbEntity/ProjectTimeEntry.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; + +namespace DigitalOps.Dal.DbEntity; + +public partial class ProjectTimeEntry +{ + public long Id { get; set; } + + public long EntryUserId { get; set; } + + public long UserId { get; set; } + + public long ProjectId { get; set; } + + public string Description { get; set; } = null!; + + public string? AiDescription { get; set; } + + public string? RejectedComment { get; set; } + + public long? ApprovedBy { get; set; } + + public DateTime StartedAt { get; set; } + + public DateTime? EndedAt { get; set; } + + public TimeOnly? Offset { get; set; } + + public DateTime? ApprovedAt { get; set; } + + public DateTime? RejectedAt { get; set; } + + public DateTime CreatedAt { get; set; } + + public DateTime? UpdatedAt { get; set; } + + public virtual User? ApprovedByNavigation { get; set; } + + public virtual User EntryUser { get; set; } = null!; + + public virtual Project Project { get; set; } = null!; + + public virtual User User { get; set; } = null!; +} diff --git a/DigitalOps.Dal/DbEntity/User.cs b/DigitalOps.Dal/DbEntity/User.cs new file mode 100644 index 0000000..192f1d8 --- /dev/null +++ b/DigitalOps.Dal/DbEntity/User.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; + +namespace DigitalOps.Dal.DbEntity; + +public partial class User +{ + public long Id { get; set; } + + public string Email { get; set; } = null!; + + public string FirstName { get; set; } = null!; + + public string? LastName { get; set; } + + public DateTime? RegisteredAt { get; set; } + + public DateTime CreatedAt { get; set; } + + public DateTime? UpdatedAt { get; set; } + + public virtual ICollection OrganizationUsers { get; set; } = new List(); + + public virtual ICollection ProjectTimeEntryApprovedByNavigations { get; set; } = new List(); + + public virtual ICollection ProjectTimeEntryEntryUsers { get; set; } = new List(); + + public virtual ICollection ProjectTimeEntryUsers { get; set; } = new List(); + + public virtual ICollection UserOidcs { get; set; } = new List(); +} diff --git a/DigitalOps.Dal/DbEntity/UserOidc.cs b/DigitalOps.Dal/DbEntity/UserOidc.cs new file mode 100644 index 0000000..bffc016 --- /dev/null +++ b/DigitalOps.Dal/DbEntity/UserOidc.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; + +namespace DigitalOps.Dal.DbEntity; + +public partial class UserOidc +{ + public long Id { get; set; } + + public long UserId { get; set; } + + public string Issuer { get; set; } = null!; + + public string Subject { get; set; } = null!; + + public DateTime? VerifiedAt { get; set; } + + public DateTime CreatedAt { get; set; } + + public DateTime? UpdatedAt { get; set; } + + public virtual User User { get; set; } = null!; +} diff --git a/DigitalOps.Dal/DefaultQueryableProvider.cs b/DigitalOps.Dal/DefaultQueryableProvider.cs new file mode 100644 index 0000000..1f367be --- /dev/null +++ b/DigitalOps.Dal/DefaultQueryableProvider.cs @@ -0,0 +1,15 @@ +using OpenHarbor.CQRS.DynamicQuery.Abstractions; + +namespace DigitalOps.Dal; + +public class DefaultQueryableProvider(MainDbContext context, IServiceProvider serviceProvider) : IQueryableProvider + where TEntity : class +{ + public Task> GetQueryableAsync(object query, CancellationToken cancellationToken = default) + { + if (serviceProvider.GetService(typeof(IQueryableProviderOverride)) is IQueryableProviderOverride queryableProviderOverride) + return queryableProviderOverride.GetQueryableAsync(query, cancellationToken); + + return Task.FromResult(context.Set().AsQueryable()); + } +} \ No newline at end of file diff --git a/DigitalOps.Dal/DigitalOps.Dal.csproj b/DigitalOps.Dal/DigitalOps.Dal.csproj new file mode 100644 index 0000000..bcee4e5 --- /dev/null +++ b/DigitalOps.Dal/DigitalOps.Dal.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + enable + enable + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + diff --git a/DigitalOps.Dal/IQueryableProviderOverride.cs b/DigitalOps.Dal/IQueryableProviderOverride.cs new file mode 100644 index 0000000..b974f09 --- /dev/null +++ b/DigitalOps.Dal/IQueryableProviderOverride.cs @@ -0,0 +1,6 @@ +namespace DigitalOps.Dal; + +public interface IQueryableProviderOverride +{ + Task> GetQueryableAsync(object query, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/DigitalOps.Dal/InMemoryQueryableHandlerService.cs b/DigitalOps.Dal/InMemoryQueryableHandlerService.cs new file mode 100644 index 0000000..8d4b3d2 --- /dev/null +++ b/DigitalOps.Dal/InMemoryQueryableHandlerService.cs @@ -0,0 +1,48 @@ +using System.Linq.Expressions; +using PoweredSoft.Data.Core; + +namespace DigitalOps.Dal; + +public class InMemoryQueryableHandlerService : IAsyncQueryableHandlerService +{ + public Task AnyAsync(IQueryable queryable, Expression> predicate, CancellationToken cancellationToken = default) + { + return Task.FromResult(queryable.Any(predicate)); + } + + public Task AnyAsync(IQueryable queryable, CancellationToken cancellationToken = default) + { + return Task.FromResult(queryable.Any()); + } + + public bool CanHandle(IQueryable queryable) + { + var result = queryable is EnumerableQuery; + return result; + } + + public Task CountAsync(IQueryable queryable, CancellationToken cancellationToken = default) + { + return Task.FromResult(queryable.Count()); + } + + public Task FirstOrDefaultAsync(IQueryable queryable, CancellationToken cancellationToken = default) + { + return Task.FromResult(queryable.FirstOrDefault()); + } + + public Task FirstOrDefaultAsync(IQueryable queryable, Expression> predicate, CancellationToken cancellationToken = default) + { + return Task.FromResult(queryable.FirstOrDefault(predicate)); + } + + public Task LongCountAsync(IQueryable queryable, CancellationToken cancellationToken = default) + { + return Task.FromResult(queryable.LongCount()); + } + + public Task> ToListAsync(IQueryable queryable, CancellationToken cancellationToken = default) + { + return Task.FromResult(queryable.ToList()); + } +} \ No newline at end of file diff --git a/DigitalOps.Dal/MainDbContext.cs b/DigitalOps.Dal/MainDbContext.cs new file mode 100644 index 0000000..31f5035 --- /dev/null +++ b/DigitalOps.Dal/MainDbContext.cs @@ -0,0 +1,30 @@ +using DigitalOps.Dal.DbEntity; +using Microsoft.EntityFrameworkCore; +using Npgsql; + +namespace DigitalOps.Dal; + +public class MainDbContext(DbContextOptions options) : MainDbScaffoldedContext(options) +{ + static MainDbContext() => NpgsqlConnection.GlobalTypeMapper + .MapEnum("organization_role"); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.HasPostgresEnum("organization_role"); + + modelBuilder.Entity(entity => + { + entity.Property(organizationUser => organizationUser.Role) + .HasColumnName("role") + .HasColumnType("organization_role"); + }); + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + return; + } +} \ No newline at end of file diff --git a/DigitalOps.Dal/MainDbScaffoldedContext.cs b/DigitalOps.Dal/MainDbScaffoldedContext.cs new file mode 100644 index 0000000..daba6ba --- /dev/null +++ b/DigitalOps.Dal/MainDbScaffoldedContext.cs @@ -0,0 +1,276 @@ +using DigitalOps.Dal.DbEntity; +using Microsoft.EntityFrameworkCore; + +namespace DigitalOps.Dal; + +public partial class MainDbScaffoldedContext : DbContext +{ + public MainDbScaffoldedContext() + { + } + + public MainDbScaffoldedContext(DbContextOptions options) + : base(options) + { + } + + public virtual DbSet Clients { get; set; } + + public virtual DbSet OidcProviders { get; set; } + + public virtual DbSet Organizations { get; set; } + + public virtual DbSet OrganizationClients { get; set; } + + public virtual DbSet OrganizationUsers { get; set; } + + public virtual DbSet Projects { get; set; } + + public virtual DbSet ProjectTimeEntries { get; set; } + + public virtual DbSet Users { get; set; } + + public virtual DbSet UserOidcs { get; set; } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Name=Database:ConnectionString"); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasPostgresEnum("organization_role", new[] { "owner", "admin", "member" }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("client_pkey"); + + entity.ToTable("client"); + + 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(entity => + { + entity.HasKey(e => e.Id).HasName("oidc_provider_pkey"); + + entity.ToTable("oidc_provider"); + + 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.Issuer) + .HasMaxLength(1024) + .HasColumnName("issuer"); + entity.Property(e => e.Label) + .HasMaxLength(255) + .HasColumnName("label"); + entity.Property(e => e.OrganizationId).HasColumnName("organization_id"); + entity.Property(e => e.UpdatedAt).HasColumnName("updated_at"); + + entity.HasOne(d => d.Organization).WithMany(p => p.OidcProviders) + .HasForeignKey(d => d.OrganizationId) + .OnDelete(DeleteBehavior.ClientSetNull) + .HasConstraintName("oidc_provider_organization_id_fkey"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("organization_pkey"); + + entity.ToTable("organization"); + + 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(entity => + { + entity.HasKey(e => e.Id).HasName("organization_client_pkey"); + + entity.ToTable("organization_client"); + + entity.Property(e => e.Id).HasColumnName("id"); + entity.Property(e => e.ClientId).HasColumnName("client_id"); + entity.Property(e => e.CreatedAt) + .HasDefaultValueSql("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC'::text)") + .HasColumnName("created_at"); + entity.Property(e => e.OrganizationId).HasColumnName("organization_id"); + entity.Property(e => e.UpdatedAt).HasColumnName("updated_at"); + + entity.HasOne(d => d.Client).WithMany(p => p.OrganizationClients) + .HasForeignKey(d => d.ClientId) + .OnDelete(DeleteBehavior.ClientSetNull) + .HasConstraintName("organization_client_client_id_fkey"); + + entity.HasOne(d => d.Organization).WithMany(p => p.OrganizationClients) + .HasForeignKey(d => d.OrganizationId) + .OnDelete(DeleteBehavior.ClientSetNull) + .HasConstraintName("organization_client_organization_id_fkey"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("organization_user_pkey"); + + entity.ToTable("organization_user"); + + 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.OrganizationId).HasColumnName("organization_id"); + entity.Property(e => e.UpdatedAt).HasColumnName("updated_at"); + entity.Property(e => e.UserId).HasColumnName("user_id"); + + entity.HasOne(d => d.Organization).WithMany(p => p.OrganizationUsers) + .HasForeignKey(d => d.OrganizationId) + .OnDelete(DeleteBehavior.ClientSetNull) + .HasConstraintName("organization_user_organization_id_fkey"); + + entity.HasOne(d => d.User).WithMany(p => p.OrganizationUsers) + .HasForeignKey(d => d.UserId) + .OnDelete(DeleteBehavior.ClientSetNull) + .HasConstraintName("organization_user_user_id_fkey"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("project_pkey"); + + entity.ToTable("project"); + + entity.Property(e => e.Id).HasColumnName("id"); + entity.Property(e => e.ClientId).HasColumnName("client_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"); + + entity.HasOne(d => d.Client).WithMany(p => p.Projects) + .HasForeignKey(d => d.ClientId) + .OnDelete(DeleteBehavior.ClientSetNull) + .HasConstraintName("project_client_id_fkey"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("project_time_entry_pkey"); + + entity.ToTable("project_time_entry"); + + entity.Property(e => e.Id).HasColumnName("id"); + entity.Property(e => e.AiDescription) + .HasMaxLength(2048) + .HasColumnName("ai_description"); + entity.Property(e => e.ApprovedAt).HasColumnName("approved_at"); + entity.Property(e => e.ApprovedBy).HasColumnName("approved_by"); + entity.Property(e => e.CreatedAt) + .HasDefaultValueSql("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC'::text)") + .HasColumnName("created_at"); + entity.Property(e => e.Description) + .HasMaxLength(2048) + .HasColumnName("description"); + entity.Property(e => e.EndedAt).HasColumnName("ended_at"); + entity.Property(e => e.EntryUserId).HasColumnName("entry_user_id"); + entity.Property(e => e.Offset).HasColumnName("offset"); + entity.Property(e => e.ProjectId).HasColumnName("project_id"); + entity.Property(e => e.RejectedAt).HasColumnName("rejected_at"); + entity.Property(e => e.RejectedComment) + .HasMaxLength(2048) + .HasColumnName("rejected_comment"); + entity.Property(e => e.StartedAt) + .HasDefaultValueSql("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC'::text)") + .HasColumnName("started_at"); + entity.Property(e => e.UpdatedAt).HasColumnName("updated_at"); + entity.Property(e => e.UserId).HasColumnName("user_id"); + + entity.HasOne(d => d.ApprovedByNavigation).WithMany(p => p.ProjectTimeEntryApprovedByNavigations) + .HasForeignKey(d => d.ApprovedBy) + .HasConstraintName("project_time_entry_approved_by_fkey"); + + entity.HasOne(d => d.EntryUser).WithMany(p => p.ProjectTimeEntryEntryUsers) + .HasForeignKey(d => d.EntryUserId) + .OnDelete(DeleteBehavior.ClientSetNull) + .HasConstraintName("project_time_entry_entry_user_id_fkey"); + + entity.HasOne(d => d.Project).WithMany(p => p.ProjectTimeEntries) + .HasForeignKey(d => d.ProjectId) + .OnDelete(DeleteBehavior.ClientSetNull) + .HasConstraintName("project_time_entry_project_id_fkey"); + + entity.HasOne(d => d.User).WithMany(p => p.ProjectTimeEntryUsers) + .HasForeignKey(d => d.UserId) + .OnDelete(DeleteBehavior.ClientSetNull) + .HasConstraintName("project_time_entry_user_id_fkey"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("user_pkey"); + + entity.ToTable("user"); + + 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) + .HasColumnName("first_name"); + entity.Property(e => e.LastName) + .HasMaxLength(255) + .HasColumnName("last_name"); + entity.Property(e => e.RegisteredAt).HasColumnName("registered_at"); + entity.Property(e => e.UpdatedAt).HasColumnName("updated_at"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("user_oidc_pkey"); + + entity.ToTable("user_oidc"); + + 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.Issuer) + .HasMaxLength(1024) + .HasColumnName("issuer"); + entity.Property(e => e.Subject) + .HasMaxLength(255) + .HasColumnName("subject"); + entity.Property(e => e.UpdatedAt).HasColumnName("updated_at"); + entity.Property(e => e.UserId).HasColumnName("user_id"); + entity.Property(e => e.VerifiedAt).HasColumnName("verified_at"); + + entity.HasOne(d => d.User).WithMany(p => p.UserOidcs) + .HasForeignKey(d => d.UserId) + .OnDelete(DeleteBehavior.ClientSetNull) + .HasConstraintName("user_oidc_user_id_fkey"); + }); + + OnModelCreatingPartial(modelBuilder); + } + + partial void OnModelCreatingPartial(ModelBuilder modelBuilder); +} diff --git a/DigitalOps.Dal/ServiceCollectionExtensions.cs b/DigitalOps.Dal/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..69c726e --- /dev/null +++ b/DigitalOps.Dal/ServiceCollectionExtensions.cs @@ -0,0 +1,12 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace DigitalOps.Dal; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddQueryableProviderOverride(this IServiceCollection services) + where TService : class, IQueryableProviderOverride + { + return services.AddTransient, TService>(); + } +} \ No newline at end of file diff --git a/DigitalOps.Dal/Validators/DbEntityExistValidator.cs b/DigitalOps.Dal/Validators/DbEntityExistValidator.cs new file mode 100644 index 0000000..126370b --- /dev/null +++ b/DigitalOps.Dal/Validators/DbEntityExistValidator.cs @@ -0,0 +1,25 @@ +using DigitalOps.Dal.Abstractions; +using FluentValidation; +using Microsoft.EntityFrameworkCore; + +namespace DigitalOps.Dal.Validators; + +public class DbEntityExistValidator : AbstractValidator + where TEntity : class, IHasId +{ + private readonly MainDbContext _dbContext; + + public DbEntityExistValidator(MainDbContext dbContext) + { + _dbContext = dbContext; + + RuleFor(value => value) + .NotNull() + .MustAsync(IsEntityExist) + .WithMessage("Invalid Entity"); + } + + private Task IsEntityExist(TValue id, + CancellationToken cancellationToken) + => _dbContext.Set().AnyAsync(entity => entity.Id!.Equals(id), cancellationToken); +} \ No newline at end of file diff --git a/DigitalOps.sln b/DigitalOps.sln new file mode 100644 index 0000000..f864710 --- /dev/null +++ b/DigitalOps.sln @@ -0,0 +1,40 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalOps.Api", "DigitalOps.Api\DigitalOps.Api.csproj", "{D850A48A-7D42-4909-BC42-573267D08969}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalOps.Dal", "DigitalOps.Dal\DigitalOps.Dal.csproj", "{A7333968-E66C-44DA-9CC3-51BD106B7562}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalOps.CQRS", "DigitalOps.CQRS\DigitalOps.CQRS.csproj", "{4C3ABA8F-C754-464C-A98D-32DC197ED787}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalOps.Authority", "DigitalOps.Authority\DigitalOps.Authority.csproj", "{19F8E730-6612-460B-ADB7-B09E9D1CB4E3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalOps.Dal.Abstractions", "DigitalOps.Dal.Abstractions\DigitalOps.Dal.Abstractions.csproj", "{A080C05D-2C84-4CFE-9CA5-658C255E9E3D}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {D850A48A-7D42-4909-BC42-573267D08969}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D850A48A-7D42-4909-BC42-573267D08969}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D850A48A-7D42-4909-BC42-573267D08969}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D850A48A-7D42-4909-BC42-573267D08969}.Release|Any CPU.Build.0 = Release|Any CPU + {A7333968-E66C-44DA-9CC3-51BD106B7562}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A7333968-E66C-44DA-9CC3-51BD106B7562}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A7333968-E66C-44DA-9CC3-51BD106B7562}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A7333968-E66C-44DA-9CC3-51BD106B7562}.Release|Any CPU.Build.0 = Release|Any CPU + {4C3ABA8F-C754-464C-A98D-32DC197ED787}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4C3ABA8F-C754-464C-A98D-32DC197ED787}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4C3ABA8F-C754-464C-A98D-32DC197ED787}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4C3ABA8F-C754-464C-A98D-32DC197ED787}.Release|Any CPU.Build.0 = Release|Any CPU + {19F8E730-6612-460B-ADB7-B09E9D1CB4E3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {19F8E730-6612-460B-ADB7-B09E9D1CB4E3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {19F8E730-6612-460B-ADB7-B09E9D1CB4E3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {19F8E730-6612-460B-ADB7-B09E9D1CB4E3}.Release|Any CPU.Build.0 = Release|Any CPU + {A080C05D-2C84-4CFE-9CA5-658C255E9E3D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A080C05D-2C84-4CFE-9CA5-658C255E9E3D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A080C05D-2C84-4CFE-9CA5-658C255E9E3D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A080C05D-2C84-4CFE-9CA5-658C255E9E3D}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/DigitalOps.sln.DotSettings.user b/DigitalOps.sln.DotSettings.user new file mode 100644 index 0000000..4305d87 --- /dev/null +++ b/DigitalOps.sln.DotSettings.user @@ -0,0 +1,6 @@ + + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded \ No newline at end of file diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..44339ba --- /dev/null +++ b/readme.md @@ -0,0 +1,3 @@ +## Database Scaffolding to generate Entities + +`dotnet ef dbcontext scaffold "Name=Database:ConnectionString" Npgsql.EntityFrameworkCore.PostgreSQL --context MainDbScaffoldedContext --context-dir ./ --output-dir DbEntity --startup-project DigitalOps.Api --project DigitalOps.Dal --force`