Migrate from User to Account domain concept

Renames core domain entities and services from "User" to "Account"

Refactors project-wide namespaces, classes, and database tables to use "Account" terminology
Updates related components, services, and database schema to reflect new domain naming
Standardizes naming conventions across authentication and organization setup features
This commit is contained in:
Janus C. H. Knudsen 2026-01-09 22:14:46 +01:00
parent e5e7c1c19f
commit 88812177a9
29 changed files with 288 additions and 298 deletions

View file

@ -0,0 +1,43 @@
using System.Diagnostics;
using Microsoft.ApplicationInsights;
using Microsoft.ApplicationInsights.DataContracts;
using PlanTempus.Core.CommandQueries;
namespace PlanTempus.Components.Accounts.Create;
public class CommandHandlerDecorator<TCommand>(
ICommandHandler<TCommand> decoratedHandler,
TelemetryClient telemetryClient) : ICommandHandler<TCommand>, ICommandHandlerDecorator where TCommand : ICommand
{
public async Task<CommandResponse> Handle(TCommand command)
{
command.TransactionId = Guid.NewGuid();
using var operation =
telemetryClient.StartOperation<RequestTelemetry>($"Handle {decoratedHandler.GetType().FullName}",
command.CorrelationId.ToString());
try
{
operation.Telemetry.Properties["CorrelationId"] = command.CorrelationId.ToString();
operation.Telemetry.Properties["TransactionId"] = command.TransactionId.ToString();
var result = await decoratedHandler.Handle(command);
operation.Telemetry.Properties["RequestId"] = result.RequestId.ToString();
operation.Telemetry.Success = true;
return result;
}
catch (Exception ex)
{
operation.Telemetry.Success = false;
telemetryClient.TrackException(ex, new Dictionary<string, string>
{
["CorrelationId"] = command.CorrelationId.ToString(),
["Command"] = command.GetType().Name,
["CommandHandler"] = decoratedHandler.GetType().FullName
});
throw;
}
}
}

View file

@ -0,0 +1,11 @@
using PlanTempus.Core.CommandQueries;
namespace PlanTempus.Components.Accounts.Create
{
public class CreateAccountCommand : Command
{
public required string Email { get; set; }
public required string Password { get; set; }
public bool IsActive { get; set; } = true;
}
}

View file

@ -0,0 +1,31 @@
using Microsoft.AspNetCore.Mvc;
using PlanTempus.Core.CommandQueries;
namespace PlanTempus.Components.Accounts.Create
{
[ApiController]
[Route("api/accounts")]
public class CreateAccountController(CreateAccountHandler handler, CreateAccountValidator validator) : ControllerBase
{
[HttpPost]
public async Task<ActionResult<CommandResponse>> Create([FromBody] CreateAccountCommand command)
{
try
{
var validationResult = await validator.ValidateAsync(command);
if (!validationResult.IsValid)
{
return BadRequest(validationResult.Errors);
}
var result = await handler.Handle(command);
return Accepted($"/api/requests/{result.RequestId}", result);
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
}
}

View file

@ -0,0 +1,53 @@
using Insight.Database;
using Microsoft.ApplicationInsights;
using Npgsql;
using PlanTempus.Components.Accounts.Exceptions;
using PlanTempus.Core;
using PlanTempus.Core.CommandQueries;
using PlanTempus.Core.Database;
namespace PlanTempus.Components.Accounts.Create
{
public class CreateAccountHandler(
IDatabaseOperations databaseOperations,
ISecureTokenizer secureTokenizer) : ICommandHandler<CreateAccountCommand>
{
public async Task<CommandResponse> Handle(CreateAccountCommand command)
{
using var db = databaseOperations.CreateScope(nameof(CreateAccountHandler));
try
{
var sql = @"
INSERT INTO system.accounts(email , password_hash, security_stamp, email_confirmed,
access_failed_count, lockout_enabled,
is_active)
VALUES(@Email, @PasswordHash, @SecurityStamp, @EmailConfirmed,
@AccessFailedCount, @LockoutEnabled, @IsActive)
RETURNING id, created_at, email, is_active";
await db.Connection.QuerySqlAsync(sql, new
{
command.Email,
PasswordHash = secureTokenizer.TokenizeText(command.Password),
SecurityStamp = Guid.NewGuid().ToString("N"),
EmailConfirmed = false,
AccessFailedCount = 0,
LockoutEnabled = false,
command.IsActive,
});
return new CommandResponse(command.CorrelationId, command.GetType().Name, command.TransactionId);
}
catch (PostgresException ex) when (ex.SqlState == "23505" && ex.ConstraintName.Equals("accounts_email_key", StringComparison.InvariantCultureIgnoreCase))
{
db.Error(ex);
throw new EmailAlreadyRegistreredException();
}
catch (Exception ex)
{
db.Error(ex);
throw;
}
}
}
}

View file

@ -0,0 +1,11 @@
namespace PlanTempus.Components.Accounts.Create
{
public class CreateAccountResult
{
public long Id { get; set; }
public string Email { get; set; }
public bool IsActive { get; set; }
public DateTime CreatedAt { get; set; }
}
}

View file

@ -0,0 +1,23 @@
using FluentValidation;
namespace PlanTempus.Components.Accounts.Create
{
public class CreateAccountValidator : AbstractValidator<CreateAccountCommand>
{
public CreateAccountValidator()
{
RuleFor(x => x.Email)
.NotEmpty().WithMessage("Email skal angives.")
.EmailAddress().WithMessage("Ugyldig emailadresse.")
.MaximumLength(256).WithMessage("Email må højst være 256 tegn.");
RuleFor(x => x.Password)
.NotEmpty().WithMessage("Password skal angives.")
.MinimumLength(8).WithMessage("Password skal være mindst 8 tegn.")
.Matches("[A-Z]").WithMessage("Password skal indeholde mindst ét stort bogstav.")
.Matches("[a-z]").WithMessage("Password skal indeholde mindst ét lille bogstav.")
.Matches("[0-9]").WithMessage("Password skal indeholde mindst ét tal.")
.Matches("[^a-zA-Z0-9]").WithMessage("Password skal indeholde mindst ét specialtegn.");
}
}
}