initial commit

This commit is contained in:
Mathias Beaulieu-Duncan 2025-01-02 15:10:52 -05:00
commit 9032527af0
72 changed files with 2075 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
.idea/
bin/
obj/
/packages/
riderModule.iml
/_ReSharper.Caches/

BIN
DigitalOps.Api/.DS_Store vendored Normal file

Binary file not shown.

View File

@ -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<DalModule>();
services.AddModule<CommandModule>();
services.AddModule<QueryModule>();
services.AddModule<AuthorityModule>();
return services;
}
}

View File

@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.1" NoWarn="NU1605" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.1" NoWarn="NU1605" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.11"/>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2"/>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="OpenHarbor.CQRS.DynamicQuery.AspNetCore" Version="8.0.0-preview.6" />
<PackageReference Include="PoweredSoft.Data.EntityFrameworkCore" Version="3.0.0" />
<PackageReference Include="FluentValidation.AspNetCore" Version="10.4.0" />
<PackageReference Include="OpenHarbor.CQRS" Version="8.0.0-preview.6" />
<PackageReference Include="OpenHarbor.CQRS.AspNetCore" Version="8.0.0-preview.6" />
<PackageReference Include="OpenHarbor.CQRS.FluentValidation" Version="8.0.0-preview.6" />
<PackageReference Include="PoweredSoft.Module.Abstractions" Version="2.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DigitalOps.Authority\DigitalOps.Authority.csproj" />
<ProjectReference Include="..\DigitalOps.CQRS\DigitalOps.CQRS.csproj" />
<ProjectReference Include="..\DigitalOps.Dal\DigitalOps.Dal.csproj" />
</ItemGroup>
</Project>

127
DigitalOps.Api/Program.cs Normal file
View File

@ -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<ForwardedHeadersOptions>(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<AppModule>();
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<string>("ConnectionString");
var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString);
dataSourceBuilder.MapEnum<OrganizationRole>("organization_role");
var dataSource = dataSourceBuilder.Build();
builder.Services.AddDbContextPool<MainDbContext>(options =>
{
options.UseNpgsql(dataSource, o =>
{
// todo: ef 9.0+
// o.MapEnum<RouteFileType>("route_file_type");
});
if (builder.Configuration.GetValue<bool>("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<string> {"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();

View File

@ -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"
}
}
}
}

View File

@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

@ -0,0 +1,10 @@
namespace DigitalOps.Authority.Attributes;
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public class AllowGuestAttribute : System.Attribute
{
public AllowGuestAttribute()
{
}
}

View File

@ -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<IQueryAuthorizationService, CQAuthorizationService>();
services
.AddScoped<ICommandAuthorizationService, CQAuthorizationService>();
services.AddScoped<UserIdentityService>();
return services;
}
}

View File

@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="OpenHarbor.CQRS.Abstractions" Version="8.0.0-preview.6" />
<PackageReference Include="OpenHarbor.CQRS.DynamicQuery" Version="8.0.0-preview.6" />
<PackageReference Include="PoweredSoft.Module.Abstractions" Version="2.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DigitalOps.Dal\DigitalOps.Dal.csproj" />
</ItemGroup>
<ItemGroup>
<Reference Include="Microsoft.AspNetCore.Http.Abstractions">
<HintPath>..\..\..\..\..\..\..\usr\local\share\dotnet\shared\Microsoft.AspNetCore.App\8.0.11\Microsoft.AspNetCore.Http.Abstractions.dll</HintPath>
</Reference>
</ItemGroup>
</Project>

View File

@ -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<AuthorizationResult> 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<AllowGuestAttribute>(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<AuthorizationResult> IQueryAuthorizationService.IsAllowedAsync(Type queryType,
CancellationToken cancellationToken)
=> IsAllowedAsync(true, queryType, cancellationToken);
Task<AuthorizationResult> ICommandAuthorizationService.IsAllowedAsync(Type commandType, CancellationToken cancellationToken)
=> IsAllowedAsync(false, commandType, cancellationToken);
}

View File

@ -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<User?> 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<bool> IsAuthorizedAsync(CancellationToken cancellationToken)
{
var user = await GetUserOrDefaultAsync(cancellationToken);
return null != user;
}
private async Task<UserOidc> 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<User> 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;
}
}

View File

@ -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<long>
{
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<Client, long>(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);
});
}
}

View File

@ -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<long>
{
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<bool> 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<OrganizationRole>();
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;
}
}

View File

@ -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<RegisterOrganizationCommandsModule>();
services.AddModule<RegisterProjectCommandsModule>();
services.AddModule<RegisterClientCommandsModule>();
return services;
}
}

View File

@ -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<AddClientCommand>
{
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<AddClientCommand>
{
public AddClientCommandValidator(MainDbContext dbContext, UserIdentityService userIdentityService)
{
RuleFor(command => command.Name)
.NotEmpty()
.MinimumLength(3);
RuleFor(command => command.OrganizationId)
.Cascade(CascadeMode.Stop)
.NotEmpty()
.SetValidator(new DbEntityExistValidator<Dal.DbEntity.Organization, long>(dbContext))
.SetValidator(new HasAccessToOrganizationValidator(OrganizationRole.Member, dbContext, userIdentityService));
}
}

View File

@ -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<RegisterClientServicesModule>();
services.AddCommand<AddClientCommand, AddClientCommandHandler,
AddClientCommandValidator>();
return services;
}
}

View File

@ -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<AddOrganizationCommand>
{
public async Task HandleAsync(AddOrganizationCommand command, CancellationToken cancellationToken = new CancellationToken())
{
await organizationService.RegisterOrganizationAsync(new RegisterOrganizationOptions
{
Name = command.Name
}, cancellationToken);
}
}
public class AddOrganizationCommandValidator : AbstractValidator<AddOrganizationCommand>
{
public AddOrganizationCommandValidator()
{
RuleFor(x => x.Name)
.NotEmpty()
.MinimumLength(3);
}
}

View File

@ -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<RegisterOrganizationServicesModule>();
services.AddCommand<AddOrganizationCommand, AddOrganizationCommandHandler,
AddOrganizationCommandValidator>();
return services;
}
}

View File

@ -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<AddProjectCommand>
{
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<AddProjectCommand>
{
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));
}
}

View File

@ -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<RegisterProjectServicesModule>();
services.AddCommand<AddProjectCommand, AddProjectCommandHandler,
AddProjectCommandValidator>();
return services;
}
}

View File

@ -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<AddTimeEntryCommand>
{
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<AddTimeEntryCommand>
{
public AddTimeEntryCommandValidator(MainDbContext dbContext, UserIdentityService userIdentityService)
{
RuleFor(command => command.ProjectId)
.NotEmpty()
.SetValidator(new DbEntityExistValidator<Dal.DbEntity.Project, long>(dbContext));
}
}

View File

@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentValidation" Version="10.4.0" />
<PackageReference Include="OpenHarbor.CQRS.Abstractions" Version="8.0.0-preview.6" />
<PackageReference Include="OpenHarbor.CQRS.DynamicQuery" Version="8.0.0-preview.6" />
<PackageReference Include="OpenHarbor.CQRS.FluentValidation" Version="8.0.0-preview.6" />
<PackageReference Include="PoweredSoft.Module.Abstractions" Version="2.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DigitalOps.Authority\DigitalOps.Authority.csproj" />
<ProjectReference Include="..\DigitalOps.Dal\DigitalOps.Dal.csproj" />
</ItemGroup>
</Project>

View File

@ -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<long> 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<ClientItem>
{
public async Task<IQueryable<ClientItem>> 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;
}
}

View File

@ -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<ClientItem>()
.AddQueryableProviderOverride<ClientItem, ClientQueryableProvider>();
return services;
}
}

View File

@ -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<HealthQuery, bool>
{
public Task<bool> HandleAsync(HealthQuery query, CancellationToken cancellationToken = new CancellationToken())
=> Task.FromResult(true);
}

View File

@ -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<HealthQuery, bool, HealthQueryHandler>();
return services;
}
}

View File

@ -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<OrganizationItem>
{
public async Task<IQueryable<OrganizationItem>> 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;
}
}

View File

@ -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<OrganizationItem>()
.AddQueryableProviderOverride<OrganizationItem, OrganizationQueryableProvider>();
return services;
}
}

View File

@ -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<ProjectItem>
{
public async Task<IQueryable<ProjectItem>> GetQueryableAsync(object query, CancellationToken cancellationToken = default)
{
long? organizationId = null;
long? clientId = null;
if (query is IDynamicQueryParams<ProjectQueryParams> 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<ProjectItem>().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;
}
}

View File

@ -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<ProjectItem, ProjectQueryParams>()
.AddQueryableProviderOverride<ProjectItem, ProjectQueryableProvider>();
return services;
}
}

View File

@ -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<RegisterOrganizationQueriesModule>();
services.AddModule<RegisterClientQueriesModule>();
services.AddModule<RegisterProjectQueriesModule>();
return services;
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1,7 @@
namespace DigitalOps.CQRS.Services.Client.Options;
public class AddClientOptions
{
public string Name { get; set; }
public long OrganizationId { get; set; }
}

View File

@ -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<ClientService>();
return services;
}
}

View File

@ -0,0 +1,6 @@
namespace DigitalOps.CQRS.Services.Organization.Options;
public class RegisterOrganizationOptions
{
public required string Name { get; set; }
}

View File

@ -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);
}
}

View File

@ -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<OrganizationService>();
return services;
}
}

View File

@ -0,0 +1,7 @@
namespace DigitalOps.CQRS.Services.Project.Options;
public class AddProjectOptions
{
public string Name { get; set; }
public long ClientId { get; set; }
}

View File

@ -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);
}
}

View File

@ -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<ProjectService>();
return services;
}
}

View File

@ -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; }
}

View File

@ -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);
}
}

View File

@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,6 @@
namespace DigitalOps.Dal.Abstractions;
public interface IHasId<T>
{
public T Id { get; set; }
}

View File

@ -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<IAsyncQueryableHandlerService, InMemoryQueryableHandlerService>()
.AddTransient(typeof(IQueryableProvider<>), typeof(DefaultQueryableProvider<>));
}
}

View File

@ -0,0 +1,8 @@
using DigitalOps.Dal.Abstractions;
namespace DigitalOps.Dal.DbEntity;
public partial class Client : IHasId<long>
{
}

View File

@ -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<OrganizationClient> OrganizationClients { get; set; } = new List<OrganizationClient>();
public virtual ICollection<Project> Projects { get; set; } = new List<Project>();
}

View File

@ -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!;
}

View File

@ -0,0 +1,8 @@
using DigitalOps.Dal.Abstractions;
namespace DigitalOps.Dal.DbEntity;
public partial class Organization : IHasId<long>
{
}

View File

@ -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<OidcProvider> OidcProviders { get; set; } = new List<OidcProvider>();
public virtual ICollection<OrganizationClient> OrganizationClients { get; set; } = new List<OrganizationClient>();
public virtual ICollection<OrganizationUser> OrganizationUsers { get; set; } = new List<OrganizationUser>();
}

View File

@ -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!;
}

View File

@ -0,0 +1,15 @@
using NpgsqlTypes;
namespace DigitalOps.Dal.DbEntity;
public enum OrganizationRole
{
[PgName("owner")]
Owner,
[PgName("admin")]
Admin,
[PgName("member")]
Member
}

View File

@ -0,0 +1,6 @@
namespace DigitalOps.Dal.DbEntity;
public partial class OrganizationUser
{
public OrganizationRole Role { get; set; }
}

View File

@ -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!;
}

View File

@ -0,0 +1,8 @@
using DigitalOps.Dal.Abstractions;
namespace DigitalOps.Dal.DbEntity;
public partial class Project : IHasId<long>
{
}

View File

@ -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<ProjectTimeEntry> ProjectTimeEntries { get; set; } = new List<ProjectTimeEntry>();
}

View File

@ -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!;
}

View File

@ -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<OrganizationUser> OrganizationUsers { get; set; } = new List<OrganizationUser>();
public virtual ICollection<ProjectTimeEntry> ProjectTimeEntryApprovedByNavigations { get; set; } = new List<ProjectTimeEntry>();
public virtual ICollection<ProjectTimeEntry> ProjectTimeEntryEntryUsers { get; set; } = new List<ProjectTimeEntry>();
public virtual ICollection<ProjectTimeEntry> ProjectTimeEntryUsers { get; set; } = new List<ProjectTimeEntry>();
public virtual ICollection<UserOidc> UserOidcs { get; set; } = new List<UserOidc>();
}

View File

@ -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!;
}

View File

@ -0,0 +1,15 @@
using OpenHarbor.CQRS.DynamicQuery.Abstractions;
namespace DigitalOps.Dal;
public class DefaultQueryableProvider<TEntity>(MainDbContext context, 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(context.Set<TEntity>().AsQueryable());
}
}

View File

@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="FluentValidation" Version="10.4.0" />
<PackageReference Include="OpenHarbor.CQRS.DynamicQuery.Abstractions" Version="8.0.0-preview.6" />
<PackageReference Include="PoweredSoft.Data.Core" Version="3.0.0" />
<PackageReference Include="PoweredSoft.Module.Abstractions" Version="2.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DigitalOps.Dal.Abstractions\DigitalOps.Dal.Abstractions.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,6 @@
namespace DigitalOps.Dal;
public interface IQueryableProviderOverride<T>
{
Task<IQueryable<T>> GetQueryableAsync(object query, CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,48 @@
using System.Linq.Expressions;
using PoweredSoft.Data.Core;
namespace DigitalOps.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,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<OrganizationRole>("organization_role");
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.HasPostgresEnum<OrganizationRole>("organization_role");
modelBuilder.Entity<OrganizationUser>(entity =>
{
entity.Property(organizationUser => organizationUser.Role)
.HasColumnName("role")
.HasColumnType("organization_role");
});
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
return;
}
}

View File

@ -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<Client> Clients { get; set; }
public virtual DbSet<OidcProvider> OidcProviders { get; set; }
public virtual DbSet<Organization> Organizations { get; set; }
public virtual DbSet<OrganizationClient> OrganizationClients { get; set; }
public virtual DbSet<OrganizationUser> OrganizationUsers { get; set; }
public virtual DbSet<Project> Projects { get; set; }
public virtual DbSet<ProjectTimeEntry> ProjectTimeEntries { get; set; }
public virtual DbSet<User> Users { get; set; }
public virtual DbSet<UserOidc> 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<Client>(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<OidcProvider>(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<Organization>(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<OrganizationClient>(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<OrganizationUser>(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<Project>(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<ProjectTimeEntry>(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<User>(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<UserOidc>(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);
}

View File

@ -0,0 +1,12 @@
using Microsoft.Extensions.DependencyInjection;
namespace DigitalOps.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>();
}
}

View File

@ -0,0 +1,25 @@
using DigitalOps.Dal.Abstractions;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
namespace DigitalOps.Dal.Validators;
public class DbEntityExistValidator<TEntity, TValue> : AbstractValidator<TValue>
where TEntity : class, IHasId<TValue>
{
private readonly MainDbContext _dbContext;
public DbEntityExistValidator(MainDbContext dbContext)
{
_dbContext = dbContext;
RuleFor(value => value)
.NotNull()
.MustAsync(IsEntityExist)
.WithMessage("Invalid Entity");
}
private Task<bool> IsEntityExist(TValue id,
CancellationToken cancellationToken)
=> _dbContext.Set<TEntity>().AnyAsync(entity => entity.Id!.Equals(id), cancellationToken);
}

40
DigitalOps.sln Normal file
View File

@ -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

View File

@ -0,0 +1,6 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAsyncTaskMethodBuilderT_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fe358d67bcd5815a5083706ec88db8489a9872915d46156ba24277cd21bd662_003FAsyncTaskMethodBuilderT_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AClaimTypes_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F55b2fe7c224b4edab7d70d62ecb210be15800_003F15_003Ff9f21e74_003FClaimTypes_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AList_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fe747192abb38e2df82cbdb37e721567726f559914a7b81f8b26ba537de632f4_003FList_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APropertyRule_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003F2129ab9af120518a1b3bb7cdb229807958261bc78f10563bba7f66ce332ba1_003FPropertyRule_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AServiceCollectionExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F58886d0933cc48d2ad52601392d2f56b1400_003F6d_003F95c7d0d2_003FServiceCollectionExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String></wpf:ResourceDictionary>

3
readme.md Normal file
View File

@ -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`