Create models and Clean Arch boilerplate

This commit is contained in:
danial23 2025-05-19 16:13:58 -04:00
parent 3926db5446
commit 872dc1e263
Signed by: danial23
SSH key fingerprint: SHA256:IJ8VP0j2WMUVweTYnzUUnEjNgPnGx+mAt+RhqWZ01bU
21 changed files with 576 additions and 27 deletions

View file

@ -1,6 +0,0 @@
namespace CSR.Application;
public class Class1
{
}

View file

@ -0,0 +1,13 @@
namespace CSR.Application.Interfaces;
using CSR.Domain.Entities;
public interface IRoleRepository
{
Task<Role?> GetByIdAsync(int id);
// Task<Role?> GetByNameAsync(string name);
// Task<IEnumerable<Role>> GetAllAsync();
Task AddAsync(Role role);
// Task UpdateAsync(Role role);
Task DeleteAsync(int id);
}

View file

@ -0,0 +1,14 @@
namespace CSR.Application.Interfaces;
using CSR.Domain.Entities;
public interface IUserRepository
{
Task<User?> GetByIdAsync(int id);
// Task<User?> GetByEmailAsync(string email);
// Task<User?> GetByUsernameAsync(string username);
// Task<IEnumerable<User>> GetAllAsync();
Task AddAsync(User user);
Task UpdateAsync(User user);
Task DeleteAsync(int id);
}

View file

@ -0,0 +1,8 @@
namespace CSR.Application.Interfaces;
public interface IUserService
{
public record CreateUserResult(Domain.Entities.User? User, string? ErrorMessage);
public void CreateUser(string username, string email, string password, bool isAdmin = false);
}

View file

@ -0,0 +1,14 @@
namespace CSR.Application.Services;
using CSR.Application.Interfaces;
using CSR.Domain.Entities;
public class UserService(IUserRepository userRepository) : IUserService
{
private readonly IUserRepository _userRepository = userRepository;
public void CreateNewUser(string username, string email, string password, bool isAdmin = false)
{
}
}

View file

@ -6,4 +6,8 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="CSR.Infrastructure" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,33 @@
namespace CSR.Domain.Entities;
public record Role
{
public int Id { get; private set; }
public string Name { get; private set; }
private Role(int id, string name)
{
Id = id;
Name = name;
}
internal static Role LoadExisting(int id, string name)
{
return new Role(id, name);
}
public static Role Admin
{
get
{
return new Role(1, "Admin");
}
}
public static Role User
{
get
{
return new Role(2, "User");
}
}
}

173
CSR.Domain/Entities/User.cs Normal file
View file

@ -0,0 +1,173 @@
namespace CSR.Domain.Entities;
using System.Net.Mail;
using CSR.Domain.Interfaces;
public class User
{
public int Id { get; private set; }
public string Username { get; private set; }
public string Email { get; private set; }
public string PasswordHash { get; private set; }
public int RoleId { get; private set; }
public Role Role { get; private set; }
private User(int id, string username, string email, string passwordHash, int roleId, Role role)
{
Id = id;
Username = username;
Email = email;
PasswordHash = passwordHash;
RoleId = roleId;
Role = role;
}
// This role is used when a new user is created
public static readonly Role DefaultRole = Role.User;
internal static User LoadExisting(int id, string username, string email, string passwordHash, int roleId, Role role)
{
return new User(id, username, email, passwordHash, roleId, role);
}
public (bool Success, IEnumerable<string>? Errors) UpdatePassword(string password, IPasswordHasher passwordHasher, User requestingUser)
{
if (requestingUser.Id != Id || requestingUser.Role.Name != "Admin")
{
throw new UnauthorizedAccessException("Only admins or the same user can update passwords.");
}
var validityCheck = IsValidPassword(password);
if (validityCheck.IsValid)
{
PasswordHash = passwordHasher.HashPassword(password);
}
return validityCheck;
}
public bool VerifyPasswordAgainstHash(string providedPassword, IPasswordHasher passwordHasher, User requestingUser)
{
if (requestingUser.Id != Id || requestingUser.Role.Name != "Admin")
{
throw new UnauthorizedAccessException("Only admins or the same user can verify passwords.");
}
return passwordHasher.VerifyHashedPassword(PasswordHash, providedPassword);
}
public static (bool IsValid, IEnumerable<string>? Errors) IsValidPassword(string password)
{
var Errors = new List<string>();
if (password.Length < 8 || password.Length > 32)
{
Errors.Add("Password must be between 8 and 32 characters long.");
}
if (!password.Any(char.IsUpper))
{
Errors.Add("Password must contain at least one uppercase letter.");
}
if (!password.Any(char.IsLower))
{
Errors.Add("Password must contain at least one lowercase letter.");
}
if (!password.Any(char.IsDigit))
{
Errors.Add("Password must contain at least one digit.");
}
if (Errors.Count > 0)
{
return (false, Errors);
}
return (true, null);
}
public (bool Success, IEnumerable<string>? Errors) UpdateEmail(string email, User requestingUser)
{
if (requestingUser.Id != Id || requestingUser.Role.Name != "Admin")
{
throw new UnauthorizedAccessException("Only admins or the same user can update email.");
}
if (IsValidEmail(email))
{
Email = email;
return (true, null);
}
return (false, new List<string> { "Invalid email format." });
}
public static bool IsValidEmail(string email)
{
try
{
var mailAddress = new MailAddress(email);
return true;
}
catch (FormatException)
{
return false;
}
}
public (bool Success, IEnumerable<string>? Errors) UpdateUsername(string username, User requestingUser)
{
if (requestingUser.Id != Id || requestingUser.Role.Name != "Admin")
{
throw new UnauthorizedAccessException("Only admins can update username.");
}
var validityCheck = IsValidUsername(username);
if (validityCheck.IsValid)
{
Username = username;
}
return validityCheck;
}
public static (bool IsValid, IEnumerable<string>? Errors) IsValidUsername(string username)
{
var Errors = new List<string>();
if (!username.All(char.IsLetterOrDigit))
{
Errors.Add("Username must be alphanumeric.");
}
if (string.IsNullOrWhiteSpace(username) || username.Length < 3)
{
Errors.Add("Username must be at least 3 characters long.");
}
if (username.Length > 32)
{
Errors.Add("Username must be less than 32 characters long.");
}
if (Errors.Count > 0)
{
return (false, Errors);
}
return (true, null);
}
public void UpdateRole(Role role, User requestingUser)
{
if (requestingUser.Role.Name != "Admin")
{
throw new UnauthorizedAccessException("Only admins can assign roles.");
}
Role = role;
RoleId = role.Id;
}
}

View file

@ -0,0 +1,7 @@
namespace CSR.Domain.Interfaces;
public interface IPasswordHasher
{
string HashPassword(string password);
bool VerifyHashedPassword(string hashedPassword, string providedPassword);
}

View file

@ -1,6 +0,0 @@
namespace CSR.Domain;
public class Role
{
}

View file

@ -1,6 +0,0 @@
namespace CSR.Domain;
public class User
{
}

View file

@ -2,6 +2,14 @@
<ItemGroup>
<ProjectReference Include="..\CSR.Application\CSR.Application.csproj" />
<ProjectReference Include="..\CSR.Domain\CSR.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<PropertyGroup>

View file

@ -1,6 +0,0 @@
namespace CSR.Infrastructure;
public class Class1
{
}

View file

@ -0,0 +1,37 @@
namespace CSR.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using CSR.Infrastructure.Persistence.Configurations;
public class CSRDbContext(DbContextOptions<CSRDbContext> options) : DbContext(options)
{
public DbSet<User> Users { get; set; }
public DbSet<Role> Roles { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfiguration(new UserConfiguration());
modelBuilder.ApplyConfiguration(new RoleConfiguration());
modelBuilder.Entity<User>()
.HasIndex(u => u.Username)
.IsUnique();
modelBuilder.Entity<User>()
.HasOne(u => u.Role)
.WithMany(r => r.Users)
.HasForeignKey(u => u.RoleId);
// --- Seed data --- //
var adminRole = new Role { Id = 1, Name = "Admin" };
var userRole = new Role { Id = 2, Name = "User" };
modelBuilder.Entity<Role>()
.HasData(adminRole, userRole);
base.OnModelCreating(modelBuilder);
}
}

View file

@ -0,0 +1,19 @@
namespace CSR.Infrastructure.Persistence.Configurations;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class RoleConfiguration : IEntityTypeConfiguration<Role>
{
public void Configure(EntityTypeBuilder<Role> builder)
{
builder.Property(u => u.Id)
.HasColumnName("RoleId")
.IsRequired();
builder.Property(u => u.Name)
.HasColumnName("RoleName")
.IsRequired();
}
}

View file

@ -0,0 +1,15 @@
namespace CSR.Infrastructure.Persistence.Configurations;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class UserConfiguration : IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> builder)
{
builder.Property(u => u.PasswordHash)
.HasColumnName("Password")
.IsRequired();
}
}

View file

@ -0,0 +1,55 @@
namespace Csr.Infrastructure.Persistence.Repositories;
using CSR.Domain.Entities;
using CSR.Application.Interfaces;
using Microsoft.EntityFrameworkCore;
public class RoleRepository(CSR.Infrastructure.Persistence.CSRDbContext context) : IRoleRepository
{
private readonly CSR.Infrastructure.Persistence.CSRDbContext _context = context;
public async Task<Role?> GetByIdAsync(int id)
{
var roleEntity = await _context.Roles
.Include(r => r.Id)
.SingleOrDefaultAsync(r => r.Id == id);
if (roleEntity == null)
{
return null; // No entity found, return null domain model
}
var role = Role.LoadExisting(
roleEntity.Id,
roleEntity.Name
);
return role;
}
public async Task AddAsync(Role role)
{
var roleEntity = new CSR.Infrastructure.Persistence.Role
{
Id = role.Id,
Name = role.Name
};
_context.Roles.Add(roleEntity);
await _context.SaveChangesAsync();
}
public async Task DeleteAsync(int id)
{
var roleEntity = new CSR.Infrastructure.Persistence.Role
{
Id = id,
Name = string.Empty
};
_context.Roles.Remove(roleEntity);
await _context.SaveChangesAsync();
}
}

View file

@ -0,0 +1,105 @@
namespace Csr.Infrastructure.Persistence.Repositories;
using CSR.Domain.Entities;
using CSR.Application.Interfaces;
using Microsoft.EntityFrameworkCore;
public class UserRepository(CSR.Infrastructure.Persistence.CSRDbContext context) : IUserRepository
{
private readonly CSR.Infrastructure.Persistence.CSRDbContext _context = context;
public async Task<User?> GetByIdAsync(int id)
{
var userEntity = await _context.Users
.Include(u => u.Role)
.SingleOrDefaultAsync(u => u.Id == id);
if (userEntity == null)
{
return null; // No entity found, return null domain model
}
var user = User.LoadExisting(
userEntity.Id,
userEntity.Username,
userEntity.Email,
userEntity.PasswordHash,
userEntity.RoleId,
Role.LoadExisting(
userEntity.Role.Id,
userEntity.Role.Name
)
);
return user;
}
public async Task AddAsync(User user)
{
var userEntity = new CSR.Infrastructure.Persistence.User
{
Username = user.Username,
Email = user.Email,
PasswordHash = user.PasswordHash,
RoleId = user.RoleId,
Role = new CSR.Infrastructure.Persistence.Role
{
Id = user.Role.Id,
Name = user.Role.Name
}
};
_context.Users.Add(userEntity);
await _context.SaveChangesAsync();
}
public async Task UpdateAsync(User user)
{
var userEntity = await _context.Users
.Include(u => u.Role)
.FirstOrDefaultAsync(u => u.Id == user.Id);
if (userEntity == null)
{
// NOTE should I throw an exception here?
return;
}
userEntity.Id = user.Id;
userEntity.Username = user.Username;
userEntity.Email = user.Email;
userEntity.PasswordHash = user.PasswordHash;
userEntity.RoleId = user.RoleId;
userEntity.Role = new CSR.Infrastructure.Persistence.Role { Id = user.Role.Id, Name = user.Role.Name };
// Prevent EF from trying to update the Role entity
_context.Entry(userEntity.Role).State = EntityState.Unchanged;
_context.Users.Update(userEntity);
await _context.SaveChangesAsync();
}
public async Task DeleteAsync(int id)
{
var userEntity = new CSR.Infrastructure.Persistence.User
{
Id = id,
Username = string.Empty,
Email = string.Empty,
PasswordHash = string.Empty,
RoleId = 0,
Role = new CSR.Infrastructure.Persistence.Role
{
Id = 0,
Name = string.Empty
}
};
_context.Users.Remove(userEntity);
await _context.SaveChangesAsync();
}
}

View file

@ -0,0 +1,10 @@
namespace CSR.Infrastructure.Persistence;
public class Role
{
public int Id { get; set; }
public required string Name { get; set; }
// Navigation property
public ICollection<User> Users { get; set; } = new HashSet<User>();
}

View file

@ -0,0 +1,17 @@
namespace CSR.Infrastructure.Persistence;
public class User
{
public int Id { get; set; }
public required string Username { get; set; }
public required string Email { get; set; }
public required string PasswordHash { get; set; }
public required int RoleId { get; set; }
// Navigation property
public Role Role { get; set; } = null!;
// prevent direct instantiation
// use UserService to create a new user
internal User() { }
}

View file

@ -1,16 +1,57 @@
using CSR.Infrastructure.Persistence;
using CSR.Application.Services;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
// Get configuration from appsettings.json, environment variables, and Docker secrets in that order
builder.Configuration
.AddJsonFile("appsettings.json", optional: true)
.AddEnvironmentVariables()
.AddKeyPerFile("/run/secrets", optional: true);
// Add services to the container.
builder.Services.AddRazorPages();
// Set up connection to SQLite database
var dbPath = builder.Configuration["Database:Path"];
if (string.IsNullOrEmpty(dbPath))
{
var folder = Environment.SpecialFolder.LocalApplicationData;
var path = Environment.GetFolderPath(folder);
dbPath = Path.Join(path, "csr.db");
}
builder.Services.AddDbContext<CSRDbContext>(options =>
{
options.UseSqlite($"Data Source={dbPath}");
});
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var services = scope.ServiceProvider;
var config = services.GetRequiredService<IConfiguration>();
var context = services.GetRequiredService<CSRDbContext>();
var userService = services.GetRequiredService<UserService>();
var adminUsername = config["Admin:Username"] ?? "admin";
var adminEmail = config["Admin:Email"] ?? "";
var adminPassword = config["Admin:Password"] ?? "password";
userService.CreateUser(adminUsername, adminEmail, adminPassword, true);
}
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
app.UseExceptionHandler("/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseHttpsRedirection();