diff --git a/backend/Cms.sln b/backend/Cms.sln index 92aa695..2102ff3 100644 --- a/backend/Cms.sln +++ b/backend/Cms.sln @@ -12,23 +12,23 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution README.md = README.md EndProjectSection EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Logitar.Cms.Contracts", "src\Logitar.Cms.Contracts\Logitar.Cms.Contracts.csproj", "{6A08D4DD-8E86-43AE-B391-BB10ABAEC4AD}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Logitar.Cms.Contracts", "src\Logitar.Cms.Contracts\Logitar.Cms.Contracts.csproj", "{6A08D4DD-8E86-43AE-B391-BB10ABAEC4AD}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Logitar.Cms.Core", "src\Logitar.Cms.Core\Logitar.Cms.Core.csproj", "{E98B3291-3F8F-4B80-8463-3873A51FDB22}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Logitar.Cms.Infrastructure", "src\Logitar.Cms.Infrastructure\Logitar.Cms.Infrastructure.csproj", "{4784DFE3-5E0B-4628-A3F6-56A6ED11D185}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Logitar.Cms.Infrastructure", "src\Logitar.Cms.Infrastructure\Logitar.Cms.Infrastructure.csproj", "{4784DFE3-5E0B-4628-A3F6-56A6ED11D185}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Logitar.Cms.MongoDB", "src\Logitar.Cms.MongoDB\Logitar.Cms.MongoDB.csproj", "{FEF28D87-7237-4EC0-A957-DF7BC7FF7572}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Logitar.Cms.MongoDB", "src\Logitar.Cms.MongoDB\Logitar.Cms.MongoDB.csproj", "{FEF28D87-7237-4EC0-A957-DF7BC7FF7572}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Logitar.Cms.EntityFrameworkCore", "src\Logitar.Cms.EntityFrameworkCore\Logitar.Cms.EntityFrameworkCore.csproj", "{F14DA8F7-103B-4E37-8842-292F8B1954C2}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Logitar.Cms.EntityFrameworkCore", "src\Logitar.Cms.EntityFrameworkCore\Logitar.Cms.EntityFrameworkCore.csproj", "{F14DA8F7-103B-4E37-8842-292F8B1954C2}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Logitar.Cms.EntityFrameworkCore.SqlSever", "src\Logitar.Cms.EntityFrameworkCore.SqlSever\Logitar.Cms.EntityFrameworkCore.SqlSever.csproj", "{8F7BE431-41ED-4E6C-A7B5-3B1A7B963F44}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Logitar.Cms.EntityFrameworkCore.PostgreSQL", "src\Logitar.Cms.EntityFrameworkCore.PostgreSQL\Logitar.Cms.EntityFrameworkCore.PostgreSQL.csproj", "{79D2A438-4BD9-417E-89F2-28664B50533D}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Logitar.Cms.EntityFrameworkCore.PostgreSQL", "src\Logitar.Cms.EntityFrameworkCore.PostgreSQL\Logitar.Cms.EntityFrameworkCore.PostgreSQL.csproj", "{79D2A438-4BD9-417E-89F2-28664B50533D}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Logitar.Cms.Web", "src\Logitar.Cms.Web\Logitar.Cms.Web.csproj", "{4E2791D8-6D64-425C-A18B-0E70519D63FD}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Logitar.Cms.Web", "src\Logitar.Cms.Web\Logitar.Cms.Web.csproj", "{4E2791D8-6D64-425C-A18B-0E70519D63FD}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Logitar.Cms", "src\Logitar.Cms\Logitar.Cms.csproj", "{8580B630-EECA-457F-B342-58B8B362CD98}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Logitar.Cms", "src\Logitar.Cms\Logitar.Cms.csproj", "{8580B630-EECA-457F-B342-58B8B362CD98}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Logitar.Cms.EntityFrameworkCore.SqlServer", "src\Logitar.Cms.EntityFrameworkCore.SqlServer\Logitar.Cms.EntityFrameworkCore.SqlServer.csproj", "{01AE8019-93B4-4C51-ACEE-943F114B9ABF}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -56,10 +56,6 @@ Global {F14DA8F7-103B-4E37-8842-292F8B1954C2}.Debug|Any CPU.Build.0 = Debug|Any CPU {F14DA8F7-103B-4E37-8842-292F8B1954C2}.Release|Any CPU.ActiveCfg = Release|Any CPU {F14DA8F7-103B-4E37-8842-292F8B1954C2}.Release|Any CPU.Build.0 = Release|Any CPU - {8F7BE431-41ED-4E6C-A7B5-3B1A7B963F44}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {8F7BE431-41ED-4E6C-A7B5-3B1A7B963F44}.Debug|Any CPU.Build.0 = Debug|Any CPU - {8F7BE431-41ED-4E6C-A7B5-3B1A7B963F44}.Release|Any CPU.ActiveCfg = Release|Any CPU - {8F7BE431-41ED-4E6C-A7B5-3B1A7B963F44}.Release|Any CPU.Build.0 = Release|Any CPU {79D2A438-4BD9-417E-89F2-28664B50533D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {79D2A438-4BD9-417E-89F2-28664B50533D}.Debug|Any CPU.Build.0 = Debug|Any CPU {79D2A438-4BD9-417E-89F2-28664B50533D}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -72,6 +68,10 @@ Global {8580B630-EECA-457F-B342-58B8B362CD98}.Debug|Any CPU.Build.0 = Debug|Any CPU {8580B630-EECA-457F-B342-58B8B362CD98}.Release|Any CPU.ActiveCfg = Release|Any CPU {8580B630-EECA-457F-B342-58B8B362CD98}.Release|Any CPU.Build.0 = Release|Any CPU + {01AE8019-93B4-4C51-ACEE-943F114B9ABF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {01AE8019-93B4-4C51-ACEE-943F114B9ABF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {01AE8019-93B4-4C51-ACEE-943F114B9ABF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {01AE8019-93B4-4C51-ACEE-943F114B9ABF}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/backend/src/Logitar.Cms.Contracts/Actors/Actor.cs b/backend/src/Logitar.Cms.Contracts/Actors/Actor.cs new file mode 100644 index 0000000..fa5a643 --- /dev/null +++ b/backend/src/Logitar.Cms.Contracts/Actors/Actor.cs @@ -0,0 +1,39 @@ +namespace Logitar.Cms.Contracts.Actors; + +public class Actor +{ + public static Actor System => new(ActorType.System.ToString()); + + public Guid Id { get; set; } + public ActorType Type { get; set; } + public bool IsDeleted { get; set; } + + public string DisplayName { get; set; } + public string? EmailAddress { get; set; } + public string? PictureUrl { get; set; } + + public Actor() : this(string.Empty) + { + } + + public Actor(string displayName) + { + DisplayName = displayName; + } + + public override bool Equals(object obj) => obj is Actor actor && actor.Id == Id; + public override int GetHashCode() => Id.GetHashCode(); + public override string ToString() + { + StringBuilder s = new(); + + s.Append(DisplayName); + if (EmailAddress != null) + { + s.Append(" <").Append(EmailAddress).Append('>'); + } + s.Append(" (").Append(Type).Append(".Id=").Append(Id).Append(')'); + + return s.ToString(); + } +} diff --git a/backend/src/Logitar.Cms.Contracts/Actors/ActorType.cs b/backend/src/Logitar.Cms.Contracts/Actors/ActorType.cs new file mode 100644 index 0000000..db8b10c --- /dev/null +++ b/backend/src/Logitar.Cms.Contracts/Actors/ActorType.cs @@ -0,0 +1,8 @@ +namespace Logitar.Cms.Contracts.Actors; + +public enum ActorType +{ + System = 0, + User = 1, + ApiKey = 2 +} diff --git a/backend/src/Logitar.Cms.Contracts/Aggregate.cs b/backend/src/Logitar.Cms.Contracts/Aggregate.cs new file mode 100644 index 0000000..a770872 --- /dev/null +++ b/backend/src/Logitar.Cms.Contracts/Aggregate.cs @@ -0,0 +1,19 @@ +using Logitar.Cms.Contracts.Actors; + +namespace Logitar.Cms.Contracts; + +public abstract class Aggregate +{ + public Guid Id { get; set; } + public long Version { get; set; } + + public Actor CreatedBy { get; set; } = new(); + public DateTime CreatedOn { get; set; } + + public Actor UpdatedBy { get; set; } = new(); + public DateTime UpdatedOn { get; set; } + + public override bool Equals(object obj) => obj is Aggregate aggregate && aggregate.GetType().Equals(GetType()) && aggregate.Id == Id; + public override int GetHashCode() => HashCode.Combine(GetType(), Id); + public override string ToString() => $"{GetType()} (Id={Id})"; +} diff --git a/backend/src/Logitar.Cms.Contracts/Configurations/Configuration.cs b/backend/src/Logitar.Cms.Contracts/Configurations/Configuration.cs new file mode 100644 index 0000000..8f66a63 --- /dev/null +++ b/backend/src/Logitar.Cms.Contracts/Configurations/Configuration.cs @@ -0,0 +1,26 @@ +namespace Logitar.Cms.Contracts.Configurations; + +public class Configuration : Aggregate +{ + public string Secret { get; set; } + + public UniqueNameSettings UniqueNameSettings { get; set; } + public PasswordSettings PasswordSettings { get; set; } + public bool RequireUniqueName { get; set; } + + public LoggingSettings LoggingSettings { get; set; } + + public Configuration() : this(string.Empty) + { + } + + public Configuration(string secret) + { + Secret = secret; + + UniqueNameSettings = new(); + PasswordSettings = new(); + + LoggingSettings = new(); + } +} diff --git a/backend/src/Logitar.Cms.Contracts/Configurations/ILoggingSettings.cs b/backend/src/Logitar.Cms.Contracts/Configurations/ILoggingSettings.cs new file mode 100644 index 0000000..5c2621d --- /dev/null +++ b/backend/src/Logitar.Cms.Contracts/Configurations/ILoggingSettings.cs @@ -0,0 +1,7 @@ +namespace Logitar.Cms.Contracts.Configurations; + +public interface ILoggingSettings +{ + LoggingExtent Extent { get; } + bool OnlyErrors { get; } +} diff --git a/backend/src/Logitar.Cms.Contracts/Configurations/LoggingExtent.cs b/backend/src/Logitar.Cms.Contracts/Configurations/LoggingExtent.cs new file mode 100644 index 0000000..215e811 --- /dev/null +++ b/backend/src/Logitar.Cms.Contracts/Configurations/LoggingExtent.cs @@ -0,0 +1,8 @@ +namespace Logitar.Cms.Contracts.Configurations; + +public enum LoggingExtent +{ + None = 0, + ActivityOnly = 1, + Full = 2 +} diff --git a/backend/src/Logitar.Cms.Contracts/Configurations/LoggingSettings.cs b/backend/src/Logitar.Cms.Contracts/Configurations/LoggingSettings.cs new file mode 100644 index 0000000..c151def --- /dev/null +++ b/backend/src/Logitar.Cms.Contracts/Configurations/LoggingSettings.cs @@ -0,0 +1,21 @@ +namespace Logitar.Cms.Contracts.Configurations; + +public record LoggingSettings : ILoggingSettings +{ + public LoggingExtent Extent { get; set; } = LoggingExtent.ActivityOnly; + public bool OnlyErrors { get; set; } + + public LoggingSettings() : this(LoggingExtent.ActivityOnly, onlyErrors: false) + { + } + + public LoggingSettings(ILoggingSettings logging) : this(logging.Extent, logging.OnlyErrors) + { + } + + public LoggingSettings(LoggingExtent extent, bool onlyErrors) + { + Extent = extent; + OnlyErrors = onlyErrors; + } +} diff --git a/backend/src/Logitar.Cms.Contracts/Configurations/PasswordSettings.cs b/backend/src/Logitar.Cms.Contracts/Configurations/PasswordSettings.cs new file mode 100644 index 0000000..05db7d1 --- /dev/null +++ b/backend/src/Logitar.Cms.Contracts/Configurations/PasswordSettings.cs @@ -0,0 +1,41 @@ +using Logitar.Identity.Contracts.Settings; + +namespace Logitar.Cms.Contracts.Configurations; + +public record PasswordSettings : IPasswordSettings +{ + public int RequiredLength { get; set; } + public int RequiredUniqueChars { get; set; } + public bool RequireNonAlphanumeric { get; set; } + public bool RequireLowercase { get; set; } + public bool RequireUppercase { get; set; } + public bool RequireDigit { get; set; } + public string HashingStrategy { get; set; } + + public PasswordSettings() : this(new PasswordSettings()) + { + } + + public PasswordSettings(IPasswordSettings password) + : this(password.RequiredLength, password.RequiredUniqueChars, password.RequireNonAlphanumeric, password.RequireLowercase, password.RequireUppercase, password.RequireDigit, password.HashingStrategy) + { + } + + public PasswordSettings( + int requiredLength, + int requiredUniqueChars, + bool requireNonAlphanumeric, + bool requireLowercase, + bool requireUppercase, + bool requireDigit, + string hashingStrategy) + { + RequiredLength = requiredLength; + RequiredUniqueChars = requiredUniqueChars; + RequireNonAlphanumeric = requireNonAlphanumeric; + RequireLowercase = requireLowercase; + RequireUppercase = requireUppercase; + RequireDigit = requireDigit; + HashingStrategy = hashingStrategy; + } +} diff --git a/backend/src/Logitar.Cms.Contracts/Configurations/UniqueNameSettings.cs b/backend/src/Logitar.Cms.Contracts/Configurations/UniqueNameSettings.cs new file mode 100644 index 0000000..d5f78ca --- /dev/null +++ b/backend/src/Logitar.Cms.Contracts/Configurations/UniqueNameSettings.cs @@ -0,0 +1,21 @@ +using Logitar.Identity.Contracts.Settings; + +namespace Logitar.Cms.Contracts.Configurations; + +public record UniqueNameSettings : IUniqueNameSettings +{ + public string? AllowedCharacters { get; set; } + + public UniqueNameSettings() : this(allowedCharacters: null) + { + } + + public UniqueNameSettings(IUniqueNameSettings uniqueName) : this(uniqueName.AllowedCharacters) + { + } + + public UniqueNameSettings(string? allowedCharacters) + { + AllowedCharacters = allowedCharacters; + } +} diff --git a/backend/src/Logitar.Cms.Contracts/Errors/Error.cs b/backend/src/Logitar.Cms.Contracts/Errors/Error.cs new file mode 100644 index 0000000..6efa1ff --- /dev/null +++ b/backend/src/Logitar.Cms.Contracts/Errors/Error.cs @@ -0,0 +1,21 @@ +namespace Logitar.Cms.Contracts.Errors; + +public record Error +{ + public string Code { get; set; } + public string Message { get; set; } + public List Data { get; set; } + + public Error() : this(string.Empty, string.Empty) + { + } + + public Error(string code, string message, IEnumerable? data = null) + { + Code = code; + Message = message; + Data = data?.ToList() ?? []; + } + + public void Add(ErrorData data) => Data.Add(data); +} diff --git a/backend/src/Logitar.Cms.Contracts/Errors/ErrorData.cs b/backend/src/Logitar.Cms.Contracts/Errors/ErrorData.cs new file mode 100644 index 0000000..bb61642 --- /dev/null +++ b/backend/src/Logitar.Cms.Contracts/Errors/ErrorData.cs @@ -0,0 +1,21 @@ +namespace Logitar.Cms.Contracts.Errors; + +public record ErrorData +{ + public string Key { get; set; } + public string Value { get; set; } + + public ErrorData() : this(string.Empty, string.Empty) + { + } + + public ErrorData(KeyValuePair data) : this(data.Key, data.Value) + { + } + + public ErrorData(string key, string value) + { + Key = key; + Value = value; + } +} diff --git a/backend/src/Logitar.Cms.Contracts/Errors/PropertyError.cs b/backend/src/Logitar.Cms.Contracts/Errors/PropertyError.cs new file mode 100644 index 0000000..d721a9d --- /dev/null +++ b/backend/src/Logitar.Cms.Contracts/Errors/PropertyError.cs @@ -0,0 +1,18 @@ +namespace Logitar.Cms.Contracts.Errors; + +public record PropertyError : Error +{ + public string? PropertyName { get; set; } + public object? AttemptedValue { get; set; } + + public PropertyError() : this(string.Empty, string.Empty, null, null) + { + } + + public PropertyError(string code, string message, string? propertyName, object? attemptedValue, IEnumerable? data = null) + : base(code, message, data) + { + PropertyName = propertyName; + AttemptedValue = attemptedValue; + } +} diff --git a/backend/src/Logitar.Cms.Contracts/Errors/ValidationError.cs b/backend/src/Logitar.Cms.Contracts/Errors/ValidationError.cs new file mode 100644 index 0000000..2b15215 --- /dev/null +++ b/backend/src/Logitar.Cms.Contracts/Errors/ValidationError.cs @@ -0,0 +1,18 @@ +namespace Logitar.Cms.Contracts.Errors; + +public record ValidationError : Error +{ + public List Errors { get; set; } + + public ValidationError() : this("Validation", "Validation failed.") + { + } + + public ValidationError(string code, string message, IEnumerable? data = null, IEnumerable? errors = null) + : base(code, message, data) + { + Errors = errors?.ToList() ?? []; + } + + public void Add(PropertyError error) => Errors.Add(error); +} diff --git a/backend/src/Logitar.Cms.Contracts/Logitar.Cms.Contracts.csproj b/backend/src/Logitar.Cms.Contracts/Logitar.Cms.Contracts.csproj index 11e1f65..6c356c7 100644 --- a/backend/src/Logitar.Cms.Contracts/Logitar.Cms.Contracts.csproj +++ b/backend/src/Logitar.Cms.Contracts/Logitar.Cms.Contracts.csproj @@ -2,6 +2,7 @@ netstandard2.1 + 12 enable @@ -13,4 +14,15 @@ True + + + + + + + + + + + diff --git a/backend/src/Logitar.Cms.Core/Caching/ICacheService.cs b/backend/src/Logitar.Cms.Core/Caching/ICacheService.cs new file mode 100644 index 0000000..046da9b --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Caching/ICacheService.cs @@ -0,0 +1,14 @@ +using Logitar.Cms.Contracts.Actors; +using Logitar.Cms.Contracts.Configurations; +using Logitar.EventSourcing; + +namespace Logitar.Cms.Core.Caching; + +public interface ICacheService +{ + Configuration? Configuration { get; set; } + + Actor? GetActor(ActorId id); + void RemoveActor(ActorId id); + void SetActor(Actor actor); +} diff --git a/backend/src/Logitar.Cms.Core/Configurations/Commands/InitializeConfigurationCommand.cs b/backend/src/Logitar.Cms.Core/Configurations/Commands/InitializeConfigurationCommand.cs new file mode 100644 index 0000000..7201f19 --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Configurations/Commands/InitializeConfigurationCommand.cs @@ -0,0 +1,5 @@ +using MediatR; + +namespace Logitar.Cms.Core.Configurations.Commands; + +public record InitializeConfigurationCommand(string DefaultLocale, string Username, string Password) : IRequest; diff --git a/backend/src/Logitar.Cms.Core/Configurations/Commands/InitializeConfigurationCommandHandler.cs b/backend/src/Logitar.Cms.Core/Configurations/Commands/InitializeConfigurationCommandHandler.cs new file mode 100644 index 0000000..73b91e2 --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Configurations/Commands/InitializeConfigurationCommandHandler.cs @@ -0,0 +1,66 @@ +using Logitar.Cms.Core.Caching; +using Logitar.Cms.Core.Languages; +using Logitar.Cms.Core.Languages.Commands; +using Logitar.EventSourcing; +using Logitar.Identity.Domain.Passwords; +using Logitar.Identity.Domain.Shared; +using Logitar.Identity.Domain.Users; +using MediatR; + +namespace Logitar.Cms.Core.Configurations.Commands; + +internal class InitializeConfigurationCommandHandler : IRequestHandler +{ + private readonly ICacheService _cacheService; + private readonly IConfigurationQuerier _configurationQuerier; + private readonly IConfigurationRepository _configurationRepository; + private readonly IPasswordManager _passwordManager; + private readonly ISender _sender; + private readonly IUserManager _userManager; + + public InitializeConfigurationCommandHandler( + ICacheService cacheService, + IConfigurationQuerier configurationQuerier, + IConfigurationRepository configurationRepository, + IPasswordManager passwordManager, + ISender sender, + IUserManager userManager) + { + _cacheService = cacheService; + _configurationQuerier = configurationQuerier; + _configurationRepository = configurationRepository; + _passwordManager = passwordManager; + _sender = sender; + _userManager = userManager; + } + + public async Task Handle(InitializeConfigurationCommand command, CancellationToken cancellationToken) + { + ConfigurationAggregate? configuration = await _configurationRepository.LoadAsync(cancellationToken); + if (configuration == null) + { + UserId userId = UserId.NewId(); + ActorId actorId = new(userId.Value); + + configuration = ConfigurationAggregate.Initialize(actorId); + + LanguageAggregate language = new(new LocaleUnit(command.DefaultLocale), isDefault: true, actorId); + + UserAggregate user = new(new UniqueNameUnit(configuration.UniqueNameSettings, command.Username), tenantId: null, actorId, userId); + + Password password = _passwordManager.ValidateAndCreate(command.Password); + user.SetPassword(password, actorId); + + user.Locale = language.Locale; + user.Update(actorId); + + await _userManager.SaveAsync(user, configuration.UserSettings, actorId, cancellationToken); + await _sender.Send(new SaveLanguageCommand(language), cancellationToken); + await _configurationRepository.SaveAsync(configuration, cancellationToken); // NOTE(fpion): this should cache the configuration. + } + else + { + _cacheService.Configuration = await _configurationQuerier.ReadAsync(configuration, cancellationToken); + } + } +} diff --git a/backend/src/Logitar.Cms.Core/Configurations/ConfigurationAggregate.cs b/backend/src/Logitar.Cms.Core/Configurations/ConfigurationAggregate.cs new file mode 100644 index 0000000..00f2e5e --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Configurations/ConfigurationAggregate.cs @@ -0,0 +1,58 @@ +using Logitar.Cms.Core.Configurations.Events; +using Logitar.EventSourcing; +using Logitar.Identity.Contracts.Settings; +using Logitar.Identity.Domain.Settings; + +namespace Logitar.Cms.Core.Configurations; + +public class ConfigurationAggregate : AggregateRoot +{ + public new ConfigurationId Id { get; } = new(); + + private JwtSecretUnit? _secret = null; + public JwtSecretUnit Secret => _secret ?? throw new InvalidOperationException($"The '{nameof(Secret)}' has not been initialized yet."); + + private ReadOnlyUniqueNameSettings? _uniqueNameSettings = null; + public ReadOnlyUniqueNameSettings UniqueNameSettings => _uniqueNameSettings ?? throw new InvalidOperationException($"The '{nameof(UniqueNameSettings)}' has not been initialized yet."); + private ReadOnlyPasswordSettings? _passwordSettings = null; + public ReadOnlyPasswordSettings PasswordSettings => _passwordSettings ?? throw new InvalidOperationException($"The '{nameof(PasswordSettings)}' has not been initialized yet."); + private bool _requireUniqueEmail = false; + public bool RequireUniqueEmail => _requireUniqueEmail; + public IUserSettings UserSettings => new UserSettings + { + UniqueName = UniqueNameSettings, + Password = PasswordSettings, + RequireUniqueEmail = RequireUniqueEmail + }; + + private ReadOnlyLoggingSettings? _loggingSettings = null; + public ReadOnlyLoggingSettings LoggingSettings => _loggingSettings ?? throw new InvalidOperationException($"The {nameof(LoggingSettings)} has not been initialized yet."); + + public ConfigurationAggregate() : base() + { + } + + public static ConfigurationAggregate Initialize(ActorId actorId = default) + { + ConfigurationAggregate configuration = new(); + + JwtSecretUnit secret = JwtSecretUnit.Generate(); + ReadOnlyUniqueNameSettings uniqueNameSettings = new(); + ReadOnlyPasswordSettings passwordSettings = new(); + bool requireUniqueEmail = true; + ReadOnlyLoggingSettings loggingSettings = new(); + configuration.Raise(new ConfigurationInitializedEvent(secret, uniqueNameSettings, passwordSettings, requireUniqueEmail, loggingSettings), actorId); + + return configuration; + } + protected virtual void Apply(ConfigurationInitializedEvent @event) + { + _secret = @event.Secret; + + _uniqueNameSettings = @event.UniqueNameSettings; + _passwordSettings = @event.PasswordSettings; + _requireUniqueEmail = @event.RequireUniqueEmail; + + _loggingSettings = @event.LoggingSettings; + } +} diff --git a/backend/src/Logitar.Cms.Core/Configurations/ConfigurationId.cs b/backend/src/Logitar.Cms.Core/Configurations/ConfigurationId.cs new file mode 100644 index 0000000..8fdd259 --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Configurations/ConfigurationId.cs @@ -0,0 +1,14 @@ +using Logitar.EventSourcing; + +namespace Logitar.Cms.Core.Configurations; + +public record ConfigurationId +{ + public AggregateId AggregateId { get; } + public string Value => AggregateId.Value; + + public ConfigurationId() + { + AggregateId = new(Guid.Empty); + } +} diff --git a/backend/src/Logitar.Cms.Core/Configurations/Events/ConfigurationInitializedEvent.cs b/backend/src/Logitar.Cms.Core/Configurations/Events/ConfigurationInitializedEvent.cs new file mode 100644 index 0000000..84656d9 --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Configurations/Events/ConfigurationInitializedEvent.cs @@ -0,0 +1,31 @@ +using Logitar.EventSourcing; +using MediatR; + +namespace Logitar.Cms.Core.Configurations.Events; + +public class ConfigurationInitializedEvent : DomainEvent, INotification +{ + public JwtSecretUnit Secret { get; } + + public ReadOnlyUniqueNameSettings UniqueNameSettings { get; } + public ReadOnlyPasswordSettings PasswordSettings { get; } + public bool RequireUniqueEmail { get; } + + public ReadOnlyLoggingSettings LoggingSettings { get; } + + public ConfigurationInitializedEvent( + JwtSecretUnit secret, + ReadOnlyUniqueNameSettings uniqueNameSettings, + ReadOnlyPasswordSettings passwordSettings, + bool requireUniqueEmail, + ReadOnlyLoggingSettings loggingSettings) + { + Secret = secret; + + UniqueNameSettings = uniqueNameSettings; + PasswordSettings = passwordSettings; + RequireUniqueEmail = requireUniqueEmail; + + LoggingSettings = loggingSettings; + } +} diff --git a/backend/src/Logitar.Cms.Core/Configurations/IConfigurationQuerier.cs b/backend/src/Logitar.Cms.Core/Configurations/IConfigurationQuerier.cs new file mode 100644 index 0000000..e08bc01 --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Configurations/IConfigurationQuerier.cs @@ -0,0 +1,8 @@ +using Logitar.Cms.Contracts.Configurations; + +namespace Logitar.Cms.Core.Configurations; + +public interface IConfigurationQuerier +{ + Task ReadAsync(ConfigurationAggregate configuration, CancellationToken cancellationToken = default); +} diff --git a/backend/src/Logitar.Cms.Core/Configurations/IConfigurationRepository.cs b/backend/src/Logitar.Cms.Core/Configurations/IConfigurationRepository.cs new file mode 100644 index 0000000..d810f65 --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Configurations/IConfigurationRepository.cs @@ -0,0 +1,8 @@ +namespace Logitar.Cms.Core.Configurations; + +public interface IConfigurationRepository +{ + Task LoadAsync(CancellationToken cancellationToken = default); + + Task SaveAsync(ConfigurationAggregate configuration, CancellationToken cancellationToken = default); +} diff --git a/backend/src/Logitar.Cms.Core/Configurations/JwtSecretUnit.cs b/backend/src/Logitar.Cms.Core/Configurations/JwtSecretUnit.cs new file mode 100644 index 0000000..8714b9f --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Configurations/JwtSecretUnit.cs @@ -0,0 +1,27 @@ +using FluentValidation; +using Logitar.Cms.Core.Configurations.Validators; +using Logitar.Security.Cryptography; + +namespace Logitar.Cms.Core.Configurations; + +public record JwtSecretUnit +{ + public const int MinimumLength = 256 / 8; + public const int MaximumLength = 512 / 8; + + public string Value { get; } + + public JwtSecretUnit(string value) + { + Value = value.Trim(); + new JwtSecretValidator().ValidateAndThrow(Value); + } + + public static JwtSecretUnit CreateOrGenerate(string? value) + { + return string.IsNullOrWhiteSpace(value) ? Generate() : new(value); + } + + public static JwtSecretUnit Generate() => new(RandomStringGenerator.GetString()); + public static JwtSecretUnit Generate(int length) => new(RandomStringGenerator.GetString(length)); +} diff --git a/backend/src/Logitar.Cms.Core/Configurations/ReadOnlyLoggingSettings.cs b/backend/src/Logitar.Cms.Core/Configurations/ReadOnlyLoggingSettings.cs new file mode 100644 index 0000000..a3a3fb7 --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Configurations/ReadOnlyLoggingSettings.cs @@ -0,0 +1,27 @@ +using FluentValidation; +using Logitar.Cms.Contracts.Configurations; +using Logitar.Cms.Core.Configurations.Validators; + +namespace Logitar.Cms.Core.Configurations; + +public record ReadOnlyLoggingSettings : ILoggingSettings +{ + public LoggingExtent Extent { get; } + public bool OnlyErrors { get; } + + public ReadOnlyLoggingSettings() : this(new LoggingSettings()) + { + } + + public ReadOnlyLoggingSettings(ILoggingSettings logging) : this(logging.Extent, logging.OnlyErrors) + { + } + + [JsonConstructor] + public ReadOnlyLoggingSettings(LoggingExtent extent, bool onlyErrors) + { + Extent = extent; + OnlyErrors = onlyErrors; + new LoggingSettingsValidator().ValidateAndThrow(this); + } +} diff --git a/backend/src/Logitar.Cms.Core/Configurations/ReadOnlyPasswordSettings.cs b/backend/src/Logitar.Cms.Core/Configurations/ReadOnlyPasswordSettings.cs new file mode 100644 index 0000000..5b3dffd --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Configurations/ReadOnlyPasswordSettings.cs @@ -0,0 +1,46 @@ +using FluentValidation; +using Logitar.Cms.Core.Configurations.Validators; +using Logitar.Identity.Contracts.Settings; +using Logitar.Identity.Domain.Settings; + +namespace Logitar.Cms.Core.Configurations; + +public record ReadOnlyPasswordSettings : IPasswordSettings +{ + public int RequiredLength { get; } + public int RequiredUniqueChars { get; } + public bool RequireNonAlphanumeric { get; } + public bool RequireLowercase { get; } + public bool RequireUppercase { get; } + public bool RequireDigit { get; } + public string HashingStrategy { get; } + + public ReadOnlyPasswordSettings() : this(new PasswordSettings()) + { + } + + public ReadOnlyPasswordSettings(IPasswordSettings password) + : this(password.RequiredLength, password.RequiredUniqueChars, password.RequireNonAlphanumeric, password.RequireLowercase, password.RequireUppercase, password.RequireDigit, password.HashingStrategy) + { + } + + [JsonConstructor] + public ReadOnlyPasswordSettings( + int requiredLength, + int requiredUniqueChars, + bool requireNonAlphanumeric, + bool requireLowercase, + bool requireUppercase, + bool requireDigit, + string hashingStrategy) + { + RequiredLength = requiredLength; + RequiredUniqueChars = requiredUniqueChars; + RequireNonAlphanumeric = requireNonAlphanumeric; + RequireLowercase = requireLowercase; + RequireUppercase = requireUppercase; + RequireDigit = requireDigit; + HashingStrategy = hashingStrategy; + new PasswordSettingsValidator().ValidateAndThrow(this); + } +} diff --git a/backend/src/Logitar.Cms.Core/Configurations/ReadOnlyUniqueNameSettings.cs b/backend/src/Logitar.Cms.Core/Configurations/ReadOnlyUniqueNameSettings.cs new file mode 100644 index 0000000..5c01b83 --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Configurations/ReadOnlyUniqueNameSettings.cs @@ -0,0 +1,26 @@ +using FluentValidation; +using Logitar.Cms.Core.Configurations.Validators; +using Logitar.Identity.Contracts.Settings; +using Logitar.Identity.Domain.Settings; + +namespace Logitar.Cms.Core.Configurations; + +public record ReadOnlyUniqueNameSettings : IUniqueNameSettings +{ + public string? AllowedCharacters { get; } + + public ReadOnlyUniqueNameSettings() : this(new UniqueNameSettings()) + { + } + + public ReadOnlyUniqueNameSettings(IUniqueNameSettings uniqueName) : this(uniqueName.AllowedCharacters) + { + } + + [JsonConstructor] + public ReadOnlyUniqueNameSettings(string? allowedCharacters) + { + AllowedCharacters = allowedCharacters; + new UniqueNameSettingsValidator().ValidateAndThrow(this); + } +} diff --git a/backend/src/Logitar.Cms.Core/Configurations/Validators/JwtSecretValidator.cs b/backend/src/Logitar.Cms.Core/Configurations/Validators/JwtSecretValidator.cs new file mode 100644 index 0000000..ae73edd --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Configurations/Validators/JwtSecretValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace Logitar.Cms.Core.Configurations.Validators; + +public class JwtSecretValidator : AbstractValidator +{ + public JwtSecretValidator() + { + RuleFor(x => x).NotEmpty() + .MaximumLength(JwtSecretUnit.MaximumLength) + .MinimumLength(JwtSecretUnit.MinimumLength); + } +} diff --git a/backend/src/Logitar.Cms.Core/Configurations/Validators/LoggingSettingsValidator.cs b/backend/src/Logitar.Cms.Core/Configurations/Validators/LoggingSettingsValidator.cs new file mode 100644 index 0000000..6461444 --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Configurations/Validators/LoggingSettingsValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; +using Logitar.Cms.Contracts.Configurations; + +namespace Logitar.Cms.Core.Configurations.Validators; + +public class LoggingSettingsValidator : AbstractValidator +{ + public LoggingSettingsValidator() + { + RuleFor(x => x.Extent).IsInEnum(); + When(x => x.Extent == LoggingExtent.None, () => RuleFor(x => x.OnlyErrors).Equal(false)); + } +} diff --git a/backend/src/Logitar.Cms.Core/Configurations/Validators/PasswordSettingsValidator.cs b/backend/src/Logitar.Cms.Core/Configurations/Validators/PasswordSettingsValidator.cs new file mode 100644 index 0000000..34cb06b --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Configurations/Validators/PasswordSettingsValidator.cs @@ -0,0 +1,36 @@ +using FluentValidation; +using Logitar.Identity.Contracts.Settings; + +namespace Logitar.Cms.Core.Configurations.Validators; + +public class PasswordSettingsValidator : AbstractValidator +{ + public PasswordSettingsValidator() + { + RuleFor(x => x.RequiredLength).GreaterThanOrEqualTo(x => GetRequiredLength(x)); + RuleFor(x => x.RequiredUniqueChars).LessThanOrEqualTo(x => x.RequiredLength); + RuleFor(x => x.HashingStrategy).NotEmpty().MaximumLength(byte.MaxValue); + } + + private static int GetRequiredLength(IPasswordSettings settings) + { + int requiredLength = 0; + if (settings.RequireNonAlphanumeric) + { + requiredLength++; + } + if (settings.RequireLowercase) + { + requiredLength++; + } + if (settings.RequireUppercase) + { + requiredLength++; + } + if (settings.RequireDigit) + { + requiredLength++; + } + return requiredLength < 1 ? 1 : requiredLength; + } +} diff --git a/backend/src/Logitar.Cms.Core/Configurations/Validators/UniqueNameSettingsValidator.cs b/backend/src/Logitar.Cms.Core/Configurations/Validators/UniqueNameSettingsValidator.cs new file mode 100644 index 0000000..88298cd --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Configurations/Validators/UniqueNameSettingsValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; +using Logitar.Identity.Contracts.Settings; + +namespace Logitar.Cms.Core.Configurations.Validators; + +public class UniqueNameSettingsValidator : AbstractValidator +{ + public UniqueNameSettingsValidator() + { + When(x => x.AllowedCharacters != null, () => RuleFor(x => x.AllowedCharacters).NotEmpty().MaximumLength(byte.MaxValue)); + } +} diff --git a/backend/src/Logitar.Cms.Core/ConflictException.cs b/backend/src/Logitar.Cms.Core/ConflictException.cs new file mode 100644 index 0000000..014e005 --- /dev/null +++ b/backend/src/Logitar.Cms.Core/ConflictException.cs @@ -0,0 +1,8 @@ +namespace Logitar.Cms.Core; + +public abstract class ConflictException : ErrorException +{ + public ConflictException(string? message = null, Exception? innerException = null) : base(message, innerException) + { + } +} diff --git a/backend/src/Logitar.Cms.Core/DateTimeExtensions.cs b/backend/src/Logitar.Cms.Core/DateTimeExtensions.cs new file mode 100644 index 0000000..1effa61 --- /dev/null +++ b/backend/src/Logitar.Cms.Core/DateTimeExtensions.cs @@ -0,0 +1,12 @@ +namespace Logitar.Cms.Core; + +public static class DateTimeExtensions // ISSUE: https://github.com/Logitar/CMS/issues/4 +{ + public static DateTime AsUniversalTime(this DateTime value) => value.Kind switch + { + DateTimeKind.Local => value.ToUniversalTime(), + DateTimeKind.Unspecified => DateTime.SpecifyKind(value, DateTimeKind.Utc), + DateTimeKind.Utc => value, + _ => throw new ArgumentException($"The date time kind '{value.Kind}' is not supported.", nameof(value)), + }; +} diff --git a/backend/src/Logitar.Cms.Core/DependencyInjectionExtensions.cs b/backend/src/Logitar.Cms.Core/DependencyInjectionExtensions.cs new file mode 100644 index 0000000..2b2f650 --- /dev/null +++ b/backend/src/Logitar.Cms.Core/DependencyInjectionExtensions.cs @@ -0,0 +1,14 @@ +using Logitar.Identity.Domain; +using Microsoft.Extensions.DependencyInjection; + +namespace Logitar.Cms.Core; + +public static class DependencyInjectionExtensions +{ + public static IServiceCollection AddLogitarCmsCore(this IServiceCollection services) + { + return services + .AddLogitarIdentityDomain() + .AddMediatR(config => config.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly())); + } +} diff --git a/backend/src/Logitar.Cms.Core/ErrorException.cs b/backend/src/Logitar.Cms.Core/ErrorException.cs new file mode 100644 index 0000000..9ac208d --- /dev/null +++ b/backend/src/Logitar.Cms.Core/ErrorException.cs @@ -0,0 +1,12 @@ +using Logitar.Cms.Contracts.Errors; + +namespace Logitar.Cms.Core; + +public abstract class ErrorException : Exception +{ + public abstract Error Error { get; } + + public ErrorException(string? message = null, Exception? innerException = null) : base(message, innerException) + { + } +} diff --git a/backend/src/Logitar.Cms.Core/FluentValidationExtensions.cs b/backend/src/Logitar.Cms.Core/FluentValidationExtensions.cs new file mode 100644 index 0000000..5554a01 --- /dev/null +++ b/backend/src/Logitar.Cms.Core/FluentValidationExtensions.cs @@ -0,0 +1,12 @@ +using FluentValidation.Results; +using Logitar.Cms.Contracts.Errors; + +namespace Logitar.Cms.Core; + +public static class FluentValidationExtensions +{ + public static PropertyError ToPropertyError(this ValidationFailure failure) + { + return new(failure.ErrorCode, failure.ErrorMessage, failure.PropertyName, failure.AttemptedValue); + } +} diff --git a/backend/src/Logitar.Cms.Core/Languages/Commands/SaveLanguageCommand.cs b/backend/src/Logitar.Cms.Core/Languages/Commands/SaveLanguageCommand.cs new file mode 100644 index 0000000..c381196 --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Languages/Commands/SaveLanguageCommand.cs @@ -0,0 +1,5 @@ +using MediatR; + +namespace Logitar.Cms.Core.Languages.Commands; + +public record SaveLanguageCommand(LanguageAggregate Language) : IRequest; diff --git a/backend/src/Logitar.Cms.Core/Languages/Commands/SaveLanguageCommandHandler.cs b/backend/src/Logitar.Cms.Core/Languages/Commands/SaveLanguageCommandHandler.cs new file mode 100644 index 0000000..fd2efd7 --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Languages/Commands/SaveLanguageCommandHandler.cs @@ -0,0 +1,40 @@ +using Logitar.Cms.Core.Languages.Events; +using Logitar.EventSourcing; +using MediatR; + +namespace Logitar.Cms.Core.Languages.Commands; + +internal class SaveLanguageCommandHandler : IRequestHandler +{ + private readonly ILanguageRepository _languageRepository; + + public SaveLanguageCommandHandler(ILanguageRepository languageRepository) + { + _languageRepository = languageRepository; + } + + public async Task Handle(SaveLanguageCommand command, CancellationToken cancellationToken) + { + LanguageAggregate language = command.Language; + + bool hasLocaleChanged = false; + foreach (DomainEvent change in language.Changes) + { + if (change is LanguageCreatedEvent) + { + hasLocaleChanged = true; + } + } + + if (hasLocaleChanged) + { + LanguageAggregate? other = await _languageRepository.LoadAsync(language.Locale, cancellationToken); + if (other != null && !other.Equals(language)) + { + throw new LocaleAlreadyUsedException(language.Locale, nameof(language.Locale)); + } + } + + await _languageRepository.SaveAsync(language, cancellationToken); + } +} diff --git a/backend/src/Logitar.Cms.Core/Languages/Events/LanguageCreatedEvent.cs b/backend/src/Logitar.Cms.Core/Languages/Events/LanguageCreatedEvent.cs new file mode 100644 index 0000000..d9f686b --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Languages/Events/LanguageCreatedEvent.cs @@ -0,0 +1,17 @@ +using Logitar.EventSourcing; +using Logitar.Identity.Domain.Shared; +using MediatR; + +namespace Logitar.Cms.Core.Languages.Events; + +public class LanguageCreatedEvent : DomainEvent, INotification +{ + public bool IsDefault { get; private set; } + public LocaleUnit Locale { get; private set; } + + public LanguageCreatedEvent(bool isDefault, LocaleUnit locale) + { + IsDefault = isDefault; + Locale = locale; + } +} diff --git a/backend/src/Logitar.Cms.Core/Languages/Events/LanguageDeletedEvent.cs b/backend/src/Logitar.Cms.Core/Languages/Events/LanguageDeletedEvent.cs new file mode 100644 index 0000000..4102508 --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Languages/Events/LanguageDeletedEvent.cs @@ -0,0 +1,6 @@ +using Logitar.EventSourcing; +using MediatR; + +namespace Logitar.Cms.Core.Languages.Events; + +public class LanguageDeletedEvent : DomainEvent, INotification; diff --git a/backend/src/Logitar.Cms.Core/Languages/ILanguageRepository.cs b/backend/src/Logitar.Cms.Core/Languages/ILanguageRepository.cs new file mode 100644 index 0000000..accb5c5 --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Languages/ILanguageRepository.cs @@ -0,0 +1,11 @@ +using Logitar.Identity.Domain.Shared; + +namespace Logitar.Cms.Core.Languages; + +public interface ILanguageRepository +{ + Task LoadAsync(LocaleUnit locale, CancellationToken cancellationToken = default); + + Task SaveAsync(LanguageAggregate language, CancellationToken cancellationToken = default); + Task SaveAsync(IEnumerable languages, CancellationToken cancellationToken = default); +} diff --git a/backend/src/Logitar.Cms.Core/Languages/LanguageAggregate.cs b/backend/src/Logitar.Cms.Core/Languages/LanguageAggregate.cs new file mode 100644 index 0000000..b1faadb --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Languages/LanguageAggregate.cs @@ -0,0 +1,39 @@ +using Logitar.Cms.Core.Languages.Events; +using Logitar.EventSourcing; +using Logitar.Identity.Domain.Shared; + +namespace Logitar.Cms.Core.Languages; + +public class LanguageAggregate : AggregateRoot +{ + public bool IsDefault { get; private set; } + + private LocaleUnit? _locale = null; + public LocaleUnit Locale => _locale ?? throw new InvalidOperationException($"The {nameof(Locale)} has not been initialized yet."); + + public LanguageAggregate() : base() + { + } + + public LanguageAggregate(LocaleUnit locale, bool isDefault = false, ActorId actorId = default, LanguageId? id = null) + : base((id ?? LanguageId.NewId()).AggregateId) + { + Raise(new LanguageCreatedEvent(isDefault, locale), actorId); + } + protected virtual void Apply(LanguageCreatedEvent @event) + { + IsDefault = @event.IsDefault; + + _locale = @event.Locale; + } + + public void Delete(ActorId actorId = default) + { + if (!IsDeleted) + { + Raise(new LanguageDeletedEvent(), actorId); + } + } + + public override string ToString() => $"{Locale.Culture.DisplayName} ({Locale.Code}) | {base.ToString()}"; +} diff --git a/backend/src/Logitar.Cms.Core/Languages/LanguageId.cs b/backend/src/Logitar.Cms.Core/Languages/LanguageId.cs new file mode 100644 index 0000000..b5261bf --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Languages/LanguageId.cs @@ -0,0 +1,24 @@ +using Logitar.EventSourcing; + +namespace Logitar.Cms.Core.Languages; + +public record LanguageId +{ + public AggregateId AggregateId { get; } + public string Value => AggregateId.Value; + + public LanguageId(string value) : this(new AggregateId(value)) + { + } + public LanguageId(AggregateId aggregateId) + { + AggregateId = aggregateId; + } + + public static LanguageId NewId() => new(AggregateId.NewId()); + + public static LanguageId? TryCreate(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : new(value.Trim()); + } +} diff --git a/backend/src/Logitar.Cms.Core/Languages/LocaleAlreadyUsedException.cs b/backend/src/Logitar.Cms.Core/Languages/LocaleAlreadyUsedException.cs new file mode 100644 index 0000000..db9d238 --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Languages/LocaleAlreadyUsedException.cs @@ -0,0 +1,33 @@ +using Logitar.Cms.Contracts.Errors; +using Logitar.Identity.Domain.Shared; + +namespace Logitar.Cms.Core.Languages; + +public class LocaleAlreadyUsedException : ConflictException +{ + public const string ErrorMessage = "The specified locale is already used."; + + public string LocaleCode + { + get => (string)Data[nameof(LocaleCode)]!; + private set => Data[nameof(LocaleCode)] = value; + } + public string? PropertyName + { + get => (string?)Data[nameof(PropertyName)]; + private set => Data[nameof(PropertyName)] = value; + } + + public override Error Error => new PropertyError(this.GetErrorCode(), ErrorMessage, PropertyName, LocaleCode); + + public LocaleAlreadyUsedException(LocaleUnit locale, string? propertyName = null) : base(BuildMessage(locale, propertyName)) + { + LocaleCode = locale.Code; + PropertyName = propertyName; + } + + private static string BuildMessage(LocaleUnit locale, string? propertyName) => new ErrorMessageBuilder(ErrorMessage) + .AddData(nameof(LocaleCode), locale.Code) + .AddData(nameof(PropertyName), propertyName, "") + .Build(); +} diff --git a/backend/src/Logitar.Cms.Core/Logitar.Cms.Core.csproj b/backend/src/Logitar.Cms.Core/Logitar.Cms.Core.csproj index 7d40443..e118c2f 100644 --- a/backend/src/Logitar.Cms.Core/Logitar.Cms.Core.csproj +++ b/backend/src/Logitar.Cms.Core/Logitar.Cms.Core.csproj @@ -2,6 +2,7 @@ net8.0 + enable enable @@ -13,8 +14,18 @@ True + + + + + + + + + + diff --git a/backend/src/Logitar.Cms.EntityFrameworkCore.PostgreSQL/DependencyInjectionExtensions.cs b/backend/src/Logitar.Cms.EntityFrameworkCore.PostgreSQL/DependencyInjectionExtensions.cs new file mode 100644 index 0000000..be0819f --- /dev/null +++ b/backend/src/Logitar.Cms.EntityFrameworkCore.PostgreSQL/DependencyInjectionExtensions.cs @@ -0,0 +1,32 @@ +using Logitar.Identity.EntityFrameworkCore.PostgreSQL; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Logitar.Cms.EntityFrameworkCore.PostgreSQL; + +public static class DependencyInjectionExtensions +{ + private const string ConfigurationKey = "POSTGRESQLCONNSTR_Cms"; + + public static IServiceCollection AddLogitarCmsWithEntityFrameworkCorePostgreSQL(this IServiceCollection services, IConfiguration configuration) + { + string? connectionString = Environment.GetEnvironmentVariable(ConfigurationKey); + if (string.IsNullOrWhiteSpace(connectionString)) + { + connectionString = configuration.GetValue(ConfigurationKey); + } + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new ArgumentException($"The configuration '{ConfigurationKey}' could not be found.", nameof(configuration)); + } + return services.AddLogitarCmsWithEntityFrameworkCorePostgreSQL(connectionString.Trim()); + } + public static IServiceCollection AddLogitarCmsWithEntityFrameworkCorePostgreSQL(this IServiceCollection services, string connectionString) + { + return services + .AddLogitarIdentityWithEntityFrameworkCorePostgreSQL(connectionString) + .AddLogitarCmsWithEntityFrameworkCore() + .AddDbContext(options => options.UseNpgsql(connectionString, b => b.MigrationsAssembly("Logitar.Cms.EntityFrameworkCore.PostgreSQL"))); + } +} diff --git a/backend/src/Logitar.Cms.EntityFrameworkCore.PostgreSQL/Logitar.Cms.EntityFrameworkCore.PostgreSQL.csproj b/backend/src/Logitar.Cms.EntityFrameworkCore.PostgreSQL/Logitar.Cms.EntityFrameworkCore.PostgreSQL.csproj index 6e8684a..8f7b119 100644 --- a/backend/src/Logitar.Cms.EntityFrameworkCore.PostgreSQL/Logitar.Cms.EntityFrameworkCore.PostgreSQL.csproj +++ b/backend/src/Logitar.Cms.EntityFrameworkCore.PostgreSQL/Logitar.Cms.EntityFrameworkCore.PostgreSQL.csproj @@ -14,6 +14,10 @@ True + + + + diff --git a/backend/src/Logitar.Cms.EntityFrameworkCore.PostgreSQL/Migrations/20240710170805_CreateLanguageTable.Designer.cs b/backend/src/Logitar.Cms.EntityFrameworkCore.PostgreSQL/Migrations/20240710170805_CreateLanguageTable.Designer.cs new file mode 100644 index 0000000..7bb15d4 --- /dev/null +++ b/backend/src/Logitar.Cms.EntityFrameworkCore.PostgreSQL/Migrations/20240710170805_CreateLanguageTable.Designer.cs @@ -0,0 +1,132 @@ +// +using Logitar.Cms.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Logitar.Cms.EntityFrameworkCore.PostgreSQL.Migrations +{ + [DbContext(typeof(CmsContext))] + [Migration("20240710170805_CreateLanguageTable")] + partial class CreateLanguageTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Logitar.Cms.EntityFrameworkCore.Entities.LanguageEntity", b => + { + b.Property("LanguageId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LanguageId")); + + b.Property("AggregateId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("CodeNormalized") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("EnglishName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IsDefault") + .HasColumnType("boolean"); + + b.Property("LCID") + .HasColumnType("integer"); + + b.Property("NativeName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("UniqueId") + .HasColumnType("uuid"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("UpdatedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("LanguageId"); + + b.HasIndex("AggregateId") + .IsUnique(); + + b.HasIndex("Code"); + + b.HasIndex("CodeNormalized") + .IsUnique(); + + b.HasIndex("CreatedBy"); + + b.HasIndex("CreatedOn"); + + b.HasIndex("DisplayName"); + + b.HasIndex("EnglishName"); + + b.HasIndex("IsDefault"); + + b.HasIndex("LCID") + .IsUnique(); + + b.HasIndex("NativeName"); + + b.HasIndex("UniqueId") + .IsUnique(); + + b.HasIndex("UpdatedBy"); + + b.HasIndex("UpdatedOn"); + + b.HasIndex("Version"); + + b.ToTable("Languages", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Logitar.Cms.EntityFrameworkCore.PostgreSQL/Migrations/20240710170805_CreateLanguageTable.cs b/backend/src/Logitar.Cms.EntityFrameworkCore.PostgreSQL/Migrations/20240710170805_CreateLanguageTable.cs new file mode 100644 index 0000000..f3b08f5 --- /dev/null +++ b/backend/src/Logitar.Cms.EntityFrameworkCore.PostgreSQL/Migrations/20240710170805_CreateLanguageTable.cs @@ -0,0 +1,122 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Logitar.Cms.EntityFrameworkCore.PostgreSQL.Migrations +{ + /// + public partial class CreateLanguageTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Languages", + columns: table => new + { + LanguageId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UniqueId = table.Column(type: "uuid", nullable: false), + IsDefault = table.Column(type: "boolean", nullable: false), + LCID = table.Column(type: "integer", nullable: false), + Code = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + CodeNormalized = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + DisplayName = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), + EnglishName = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), + NativeName = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), + AggregateId = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), + Version = table.Column(type: "bigint", nullable: false), + CreatedBy = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), + CreatedOn = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedBy = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), + UpdatedOn = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Languages", x => x.LanguageId); + }); + + migrationBuilder.CreateIndex( + name: "IX_Languages_AggregateId", + table: "Languages", + column: "AggregateId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Languages_Code", + table: "Languages", + column: "Code"); + + migrationBuilder.CreateIndex( + name: "IX_Languages_CodeNormalized", + table: "Languages", + column: "CodeNormalized", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Languages_CreatedBy", + table: "Languages", + column: "CreatedBy"); + + migrationBuilder.CreateIndex( + name: "IX_Languages_CreatedOn", + table: "Languages", + column: "CreatedOn"); + + migrationBuilder.CreateIndex( + name: "IX_Languages_DisplayName", + table: "Languages", + column: "DisplayName"); + + migrationBuilder.CreateIndex( + name: "IX_Languages_EnglishName", + table: "Languages", + column: "EnglishName"); + + migrationBuilder.CreateIndex( + name: "IX_Languages_IsDefault", + table: "Languages", + column: "IsDefault"); + + migrationBuilder.CreateIndex( + name: "IX_Languages_LCID", + table: "Languages", + column: "LCID", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Languages_NativeName", + table: "Languages", + column: "NativeName"); + + migrationBuilder.CreateIndex( + name: "IX_Languages_UniqueId", + table: "Languages", + column: "UniqueId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Languages_UpdatedBy", + table: "Languages", + column: "UpdatedBy"); + + migrationBuilder.CreateIndex( + name: "IX_Languages_UpdatedOn", + table: "Languages", + column: "UpdatedOn"); + + migrationBuilder.CreateIndex( + name: "IX_Languages_Version", + table: "Languages", + column: "Version"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Languages"); + } + } +} diff --git a/backend/src/Logitar.Cms.EntityFrameworkCore.PostgreSQL/Migrations/CmsContextModelSnapshot.cs b/backend/src/Logitar.Cms.EntityFrameworkCore.PostgreSQL/Migrations/CmsContextModelSnapshot.cs new file mode 100644 index 0000000..d2dadcd --- /dev/null +++ b/backend/src/Logitar.Cms.EntityFrameworkCore.PostgreSQL/Migrations/CmsContextModelSnapshot.cs @@ -0,0 +1,129 @@ +// +using Logitar.Cms.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Logitar.Cms.EntityFrameworkCore.PostgreSQL.Migrations +{ + [DbContext(typeof(CmsContext))] + partial class CmsContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Logitar.Cms.EntityFrameworkCore.Entities.LanguageEntity", b => + { + b.Property("LanguageId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LanguageId")); + + b.Property("AggregateId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("CodeNormalized") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("EnglishName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IsDefault") + .HasColumnType("boolean"); + + b.Property("LCID") + .HasColumnType("integer"); + + b.Property("NativeName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("UniqueId") + .HasColumnType("uuid"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("UpdatedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("LanguageId"); + + b.HasIndex("AggregateId") + .IsUnique(); + + b.HasIndex("Code"); + + b.HasIndex("CodeNormalized") + .IsUnique(); + + b.HasIndex("CreatedBy"); + + b.HasIndex("CreatedOn"); + + b.HasIndex("DisplayName"); + + b.HasIndex("EnglishName"); + + b.HasIndex("IsDefault"); + + b.HasIndex("LCID") + .IsUnique(); + + b.HasIndex("NativeName"); + + b.HasIndex("UniqueId") + .IsUnique(); + + b.HasIndex("UpdatedBy"); + + b.HasIndex("UpdatedOn"); + + b.HasIndex("Version"); + + b.ToTable("Languages", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Logitar.Cms.EntityFrameworkCore.PostgreSQL/README.md b/backend/src/Logitar.Cms.EntityFrameworkCore.PostgreSQL/README.md new file mode 100644 index 0000000..4a6a314 --- /dev/null +++ b/backend/src/Logitar.Cms.EntityFrameworkCore.PostgreSQL/README.md @@ -0,0 +1,31 @@ +# Logitar.Cms.EntityFrameworkCore.PostgreSQL + +Provides an implementation of a relational event store to be used with Logitar content management system, Entity Framework Core and PostgreSQL. + +## Migrations + +This project is setup to use migrations. All the commands below must be executed in the solution directory. + +### Create a migration + +To create a new migration, execute the following command. Do not forget to provide a migration name! + +```sh +dotnet ef migrations add --context CmsContext --project src/Logitar.Cms.EntityFrameworkCore.PostgreSQL --startup-project src/Logitar.Cms +``` + +### Remove a migration + +To remove the latest unapplied migration, execute the following command. + +```sh +dotnet ef migrations remove --context CmsContext --project src/Logitar.Cms.EntityFrameworkCore.PostgreSQL --startup-project src/Logitar.Cms +``` + +### Generate a script + +To generate a script, execute the following command. Do not forget to provide a source migration name! + +```sh +dotnet ef migrations script --context CmsContext --project src/Logitar.Cms.EntityFrameworkCore.PostgreSQL --startup-project src/Logitar.Cms +``` diff --git a/backend/src/Logitar.Cms.EntityFrameworkCore.SqlServer/DependencyInjectionExtensions.cs b/backend/src/Logitar.Cms.EntityFrameworkCore.SqlServer/DependencyInjectionExtensions.cs new file mode 100644 index 0000000..276c84f --- /dev/null +++ b/backend/src/Logitar.Cms.EntityFrameworkCore.SqlServer/DependencyInjectionExtensions.cs @@ -0,0 +1,32 @@ +using Logitar.Identity.EntityFrameworkCore.SqlServer; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Logitar.Cms.EntityFrameworkCore.SqlServer; + +public static class DependencyInjectionExtensions +{ + private const string ConfigurationKey = "SQLCONNSTR_Cms"; + + public static IServiceCollection AddLogitarCmsWithEntityFrameworkCoreSqlServer(this IServiceCollection services, IConfiguration configuration) + { + string? connectionString = Environment.GetEnvironmentVariable(ConfigurationKey); + if (string.IsNullOrWhiteSpace(connectionString)) + { + connectionString = configuration.GetValue(ConfigurationKey); + } + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new ArgumentException($"The configuration '{ConfigurationKey}' could not be found.", nameof(configuration)); + } + return services.AddLogitarCmsWithEntityFrameworkCoreSqlServer(connectionString.Trim()); + } + public static IServiceCollection AddLogitarCmsWithEntityFrameworkCoreSqlServer(this IServiceCollection services, string connectionString) + { + return services + .AddLogitarIdentityWithEntityFrameworkCoreSqlServer(connectionString) + .AddLogitarCmsWithEntityFrameworkCore() + .AddDbContext(options => options.UseSqlServer(connectionString, b => b.MigrationsAssembly("Logitar.Cms.EntityFrameworkCore.SqlServer"))); + } +} diff --git a/backend/src/Logitar.Cms.EntityFrameworkCore.SqlSever/Logitar.Cms.EntityFrameworkCore.SqlSever.csproj b/backend/src/Logitar.Cms.EntityFrameworkCore.SqlServer/Logitar.Cms.EntityFrameworkCore.SqlServer.csproj similarity index 83% rename from backend/src/Logitar.Cms.EntityFrameworkCore.SqlSever/Logitar.Cms.EntityFrameworkCore.SqlSever.csproj rename to backend/src/Logitar.Cms.EntityFrameworkCore.SqlServer/Logitar.Cms.EntityFrameworkCore.SqlServer.csproj index 6e8684a..f8527f0 100644 --- a/backend/src/Logitar.Cms.EntityFrameworkCore.SqlSever/Logitar.Cms.EntityFrameworkCore.SqlSever.csproj +++ b/backend/src/Logitar.Cms.EntityFrameworkCore.SqlServer/Logitar.Cms.EntityFrameworkCore.SqlServer.csproj @@ -14,6 +14,10 @@ True + + + + diff --git a/backend/src/Logitar.Cms.EntityFrameworkCore.SqlServer/Migrations/20240710170713_CreateLanguageTable.Designer.cs b/backend/src/Logitar.Cms.EntityFrameworkCore.SqlServer/Migrations/20240710170713_CreateLanguageTable.Designer.cs new file mode 100644 index 0000000..6fee311 --- /dev/null +++ b/backend/src/Logitar.Cms.EntityFrameworkCore.SqlServer/Migrations/20240710170713_CreateLanguageTable.Designer.cs @@ -0,0 +1,132 @@ +// +using Logitar.Cms.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Logitar.Cms.EntityFrameworkCore.SqlServer.Migrations +{ + [DbContext(typeof(CmsContext))] + [Migration("20240710170713_CreateLanguageTable")] + partial class CreateLanguageTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Logitar.Cms.EntityFrameworkCore.Entities.LanguageEntity", b => + { + b.Property("LanguageId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("LanguageId")); + + b.Property("AggregateId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("CodeNormalized") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("EnglishName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("IsDefault") + .HasColumnType("bit"); + + b.Property("LCID") + .HasColumnType("int"); + + b.Property("NativeName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("UniqueId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("UpdatedOn") + .HasColumnType("datetime2"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("LanguageId"); + + b.HasIndex("AggregateId") + .IsUnique(); + + b.HasIndex("Code"); + + b.HasIndex("CodeNormalized") + .IsUnique(); + + b.HasIndex("CreatedBy"); + + b.HasIndex("CreatedOn"); + + b.HasIndex("DisplayName"); + + b.HasIndex("EnglishName"); + + b.HasIndex("IsDefault"); + + b.HasIndex("LCID") + .IsUnique(); + + b.HasIndex("NativeName"); + + b.HasIndex("UniqueId") + .IsUnique(); + + b.HasIndex("UpdatedBy"); + + b.HasIndex("UpdatedOn"); + + b.HasIndex("Version"); + + b.ToTable("Languages", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Logitar.Cms.EntityFrameworkCore.SqlServer/Migrations/20240710170713_CreateLanguageTable.cs b/backend/src/Logitar.Cms.EntityFrameworkCore.SqlServer/Migrations/20240710170713_CreateLanguageTable.cs new file mode 100644 index 0000000..17834ef --- /dev/null +++ b/backend/src/Logitar.Cms.EntityFrameworkCore.SqlServer/Migrations/20240710170713_CreateLanguageTable.cs @@ -0,0 +1,121 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Logitar.Cms.EntityFrameworkCore.SqlServer.Migrations +{ + /// + public partial class CreateLanguageTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Languages", + columns: table => new + { + LanguageId = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + UniqueId = table.Column(type: "uniqueidentifier", nullable: false), + IsDefault = table.Column(type: "bit", nullable: false), + LCID = table.Column(type: "int", nullable: false), + Code = table.Column(type: "nvarchar(16)", maxLength: 16, nullable: false), + CodeNormalized = table.Column(type: "nvarchar(16)", maxLength: 16, nullable: false), + DisplayName = table.Column(type: "nvarchar(255)", maxLength: 255, nullable: false), + EnglishName = table.Column(type: "nvarchar(255)", maxLength: 255, nullable: false), + NativeName = table.Column(type: "nvarchar(255)", maxLength: 255, nullable: false), + AggregateId = table.Column(type: "nvarchar(255)", maxLength: 255, nullable: false), + Version = table.Column(type: "bigint", nullable: false), + CreatedBy = table.Column(type: "nvarchar(255)", maxLength: 255, nullable: false), + CreatedOn = table.Column(type: "datetime2", nullable: false), + UpdatedBy = table.Column(type: "nvarchar(255)", maxLength: 255, nullable: false), + UpdatedOn = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Languages", x => x.LanguageId); + }); + + migrationBuilder.CreateIndex( + name: "IX_Languages_AggregateId", + table: "Languages", + column: "AggregateId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Languages_Code", + table: "Languages", + column: "Code"); + + migrationBuilder.CreateIndex( + name: "IX_Languages_CodeNormalized", + table: "Languages", + column: "CodeNormalized", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Languages_CreatedBy", + table: "Languages", + column: "CreatedBy"); + + migrationBuilder.CreateIndex( + name: "IX_Languages_CreatedOn", + table: "Languages", + column: "CreatedOn"); + + migrationBuilder.CreateIndex( + name: "IX_Languages_DisplayName", + table: "Languages", + column: "DisplayName"); + + migrationBuilder.CreateIndex( + name: "IX_Languages_EnglishName", + table: "Languages", + column: "EnglishName"); + + migrationBuilder.CreateIndex( + name: "IX_Languages_IsDefault", + table: "Languages", + column: "IsDefault"); + + migrationBuilder.CreateIndex( + name: "IX_Languages_LCID", + table: "Languages", + column: "LCID", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Languages_NativeName", + table: "Languages", + column: "NativeName"); + + migrationBuilder.CreateIndex( + name: "IX_Languages_UniqueId", + table: "Languages", + column: "UniqueId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Languages_UpdatedBy", + table: "Languages", + column: "UpdatedBy"); + + migrationBuilder.CreateIndex( + name: "IX_Languages_UpdatedOn", + table: "Languages", + column: "UpdatedOn"); + + migrationBuilder.CreateIndex( + name: "IX_Languages_Version", + table: "Languages", + column: "Version"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Languages"); + } + } +} diff --git a/backend/src/Logitar.Cms.EntityFrameworkCore.SqlServer/Migrations/CmsContextModelSnapshot.cs b/backend/src/Logitar.Cms.EntityFrameworkCore.SqlServer/Migrations/CmsContextModelSnapshot.cs new file mode 100644 index 0000000..04eee70 --- /dev/null +++ b/backend/src/Logitar.Cms.EntityFrameworkCore.SqlServer/Migrations/CmsContextModelSnapshot.cs @@ -0,0 +1,129 @@ +// +using Logitar.Cms.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Logitar.Cms.EntityFrameworkCore.SqlServer.Migrations +{ + [DbContext(typeof(CmsContext))] + partial class CmsContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Logitar.Cms.EntityFrameworkCore.Entities.LanguageEntity", b => + { + b.Property("LanguageId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("LanguageId")); + + b.Property("AggregateId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("CodeNormalized") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("EnglishName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("IsDefault") + .HasColumnType("bit"); + + b.Property("LCID") + .HasColumnType("int"); + + b.Property("NativeName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("UniqueId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("UpdatedOn") + .HasColumnType("datetime2"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("LanguageId"); + + b.HasIndex("AggregateId") + .IsUnique(); + + b.HasIndex("Code"); + + b.HasIndex("CodeNormalized") + .IsUnique(); + + b.HasIndex("CreatedBy"); + + b.HasIndex("CreatedOn"); + + b.HasIndex("DisplayName"); + + b.HasIndex("EnglishName"); + + b.HasIndex("IsDefault"); + + b.HasIndex("LCID") + .IsUnique(); + + b.HasIndex("NativeName"); + + b.HasIndex("UniqueId") + .IsUnique(); + + b.HasIndex("UpdatedBy"); + + b.HasIndex("UpdatedOn"); + + b.HasIndex("Version"); + + b.ToTable("Languages", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Logitar.Cms.EntityFrameworkCore.SqlServer/README.md b/backend/src/Logitar.Cms.EntityFrameworkCore.SqlServer/README.md new file mode 100644 index 0000000..ae60b84 --- /dev/null +++ b/backend/src/Logitar.Cms.EntityFrameworkCore.SqlServer/README.md @@ -0,0 +1,31 @@ +# Logitar.Cms.EntityFrameworkCore.SqlServer + +Provides an implementation of a relational event store to be used with Logitar content management system, Entity Framework Core and Microsoft SQL Server. + +## Migrations + +This project is setup to use migrations. All the commands below must be executed in the solution directory. + +### Create a migration + +To create a new migration, execute the following command. Do not forget to provide a migration name! + +```sh +dotnet ef migrations add --context CmsContext --project src/Logitar.Cms.EntityFrameworkCore.SqlServer --startup-project src/Logitar.Cms +``` + +### Remove a migration + +To remove the latest unapplied migration, execute the following command. + +```sh +dotnet ef migrations remove --context CmsContext --project src/Logitar.Cms.EntityFrameworkCore.SqlServer --startup-project src/Logitar.Cms +``` + +### Generate a script + +To generate a script, execute the following command. Do not forget to provide a source migration name! + +```sh +dotnet ef migrations script --context CmsContext --project src/Logitar.Cms.EntityFrameworkCore.SqlServer --startup-project src/Logitar.Cms +``` diff --git a/backend/src/Logitar.Cms.EntityFrameworkCore/Actors/ActorService.cs b/backend/src/Logitar.Cms.EntityFrameworkCore/Actors/ActorService.cs new file mode 100644 index 0000000..77c78d0 --- /dev/null +++ b/backend/src/Logitar.Cms.EntityFrameworkCore/Actors/ActorService.cs @@ -0,0 +1,60 @@ +using Logitar.Cms.Contracts.Actors; +using Logitar.Cms.Core.Caching; +using Logitar.EventSourcing; +using Logitar.Identity.EntityFrameworkCore.Relational; +using Logitar.Identity.EntityFrameworkCore.Relational.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Logitar.Cms.EntityFrameworkCore.Actors; + +public class ActorService : IActorService +{ + private readonly DbSet _actors; + private readonly ICacheService _cacheService; + + public ActorService(IdentityContext context, ICacheService cacheService) + { + _actors = context.Actors; + _cacheService = cacheService; + } + + public virtual async Task> FindAsync(IEnumerable ids, CancellationToken cancellationToken) + { + int capacity = ids.Count(); + + Dictionary actors = new(capacity); + + HashSet missingIds = new(capacity); + foreach (ActorId id in ids) + { + Actor? actor = _cacheService.GetActor(id); + if (actor == null) + { + missingIds.Add(id.Value); + } + else + { + actors[id] = actor; + _cacheService.SetActor(actor); + } + } + + if (missingIds.Count > 0) + { + ActorEntity[] entities = await _actors.AsNoTracking() + .Where(a => missingIds.Contains(a.Id)) + .ToArrayAsync(cancellationToken); + + foreach (ActorEntity entity in entities) + { + ActorId id = new(entity.Id); + Actor actor = Mapper.ToActor(entity); + + actors[id] = actor; + _cacheService.SetActor(actor); + } + } + + return actors.Values; + } +} diff --git a/backend/src/Logitar.Cms.EntityFrameworkCore/Actors/IActorService.cs b/backend/src/Logitar.Cms.EntityFrameworkCore/Actors/IActorService.cs new file mode 100644 index 0000000..ce8a3fe --- /dev/null +++ b/backend/src/Logitar.Cms.EntityFrameworkCore/Actors/IActorService.cs @@ -0,0 +1,9 @@ +using Logitar.Cms.Contracts.Actors; +using Logitar.EventSourcing; + +namespace Logitar.Cms.EntityFrameworkCore.Actors; + +public interface IActorService +{ + Task> FindAsync(IEnumerable ids, CancellationToken cancellationToken = default); +} diff --git a/backend/src/Logitar.Cms.EntityFrameworkCore/CmsContext.cs b/backend/src/Logitar.Cms.EntityFrameworkCore/CmsContext.cs new file mode 100644 index 0000000..8213bdf --- /dev/null +++ b/backend/src/Logitar.Cms.EntityFrameworkCore/CmsContext.cs @@ -0,0 +1,18 @@ +using Logitar.Cms.EntityFrameworkCore.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Logitar.Cms.EntityFrameworkCore; + +public class CmsContext : DbContext +{ + public CmsContext(DbContextOptions options) : base(options) + { + } + + internal DbSet Languages { get; private set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly()); + } +} diff --git a/backend/src/Logitar.Cms.EntityFrameworkCore/CmsDb.cs b/backend/src/Logitar.Cms.EntityFrameworkCore/CmsDb.cs new file mode 100644 index 0000000..88ba152 --- /dev/null +++ b/backend/src/Logitar.Cms.EntityFrameworkCore/CmsDb.cs @@ -0,0 +1,31 @@ +using Logitar.Cms.EntityFrameworkCore.Entities; +using Logitar.Data; + +namespace Logitar.Cms.EntityFrameworkCore; + +public static class CmsDb +{ + public static string Normalize(string value) => value.Trim().ToUpperInvariant(); + + public static class Languages + { + public static readonly TableId Table = new(nameof(CmsContext.Languages)); + + public static readonly ColumnId AggregateId = new(nameof(LanguageEntity.AggregateId), Table); + public static readonly ColumnId CreatedBy = new(nameof(LanguageEntity.CreatedBy), Table); + public static readonly ColumnId CreatedOn = new(nameof(LanguageEntity.CreatedOn), Table); + public static readonly ColumnId UpdatedBy = new(nameof(LanguageEntity.UpdatedBy), Table); + public static readonly ColumnId UpdatedOn = new(nameof(LanguageEntity.UpdatedOn), Table); + public static readonly ColumnId Version = new(nameof(LanguageEntity.Version), Table); + + public static readonly ColumnId Code = new(nameof(LanguageEntity.Code), Table); + public static readonly ColumnId CodeNormalized = new(nameof(LanguageEntity.CodeNormalized), Table); + public static readonly ColumnId DisplayName = new(nameof(LanguageEntity.DisplayName), Table); + public static readonly ColumnId EnglishName = new(nameof(LanguageEntity.EnglishName), Table); + public static readonly ColumnId IsDefault = new(nameof(LanguageEntity.IsDefault), Table); + public static readonly ColumnId LanguageId = new(nameof(LanguageEntity.LanguageId), Table); + public static readonly ColumnId LCID = new(nameof(LanguageEntity.LCID), Table); + public static readonly ColumnId NativeName = new(nameof(LanguageEntity.NativeName), Table); + public static readonly ColumnId UniqueId = new(nameof(LanguageEntity.UniqueId), Table); + } +} diff --git a/backend/src/Logitar.Cms.EntityFrameworkCore/Configurations/LanguageConfiguration.cs b/backend/src/Logitar.Cms.EntityFrameworkCore/Configurations/LanguageConfiguration.cs new file mode 100644 index 0000000..8dd9ca5 --- /dev/null +++ b/backend/src/Logitar.Cms.EntityFrameworkCore/Configurations/LanguageConfiguration.cs @@ -0,0 +1,33 @@ +using Logitar.Cms.EntityFrameworkCore.Entities; +using Logitar.Identity.Domain.Shared; +using Logitar.Identity.EntityFrameworkCore.Relational.Configurations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Logitar.Cms.EntityFrameworkCore.Configurations; + +internal class LanguageConfiguration : AggregateConfiguration, IEntityTypeConfiguration +{ + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + builder.ToTable(CmsDb.Languages.Table.Table ?? string.Empty, CmsDb.Languages.Table.Schema); + builder.HasKey(x => x.LanguageId); + + builder.HasIndex(x => x.UniqueId).IsUnique(); + builder.HasIndex(x => x.IsDefault); // ISSUE: https://github.com/Logitar/CMS/issues/2 + builder.HasIndex(x => x.LCID).IsUnique(); + builder.HasIndex(x => x.Code); + builder.HasIndex(x => x.CodeNormalized).IsUnique(); + builder.HasIndex(x => x.DisplayName); + builder.HasIndex(x => x.EnglishName); + builder.HasIndex(x => x.NativeName); + + builder.Property(x => x.Code).HasMaxLength(LocaleUnit.MaximumLength); + builder.Property(x => x.CodeNormalized).HasMaxLength(LocaleUnit.MaximumLength); + builder.Property(x => x.DisplayName).HasMaxLength(byte.MaxValue); + builder.Property(x => x.EnglishName).HasMaxLength(byte.MaxValue); + builder.Property(x => x.NativeName).HasMaxLength(byte.MaxValue); + } +} diff --git a/backend/src/Logitar.Cms.EntityFrameworkCore/DependencyInjectionExtensions.cs b/backend/src/Logitar.Cms.EntityFrameworkCore/DependencyInjectionExtensions.cs new file mode 100644 index 0000000..d4364b9 --- /dev/null +++ b/backend/src/Logitar.Cms.EntityFrameworkCore/DependencyInjectionExtensions.cs @@ -0,0 +1,37 @@ +using Logitar.Cms.Core.Configurations; +using Logitar.Cms.Core.Languages; +using Logitar.Cms.EntityFrameworkCore.Actors; +using Logitar.Cms.EntityFrameworkCore.Queriers; +using Logitar.Cms.EntityFrameworkCore.Repositories; +using Logitar.Cms.Infrastructure; +using Logitar.Identity.EntityFrameworkCore.Relational; +using Microsoft.Extensions.DependencyInjection; + +namespace Logitar.Cms.EntityFrameworkCore; + +public static class DependencyInjectionExtensions +{ + public static IServiceCollection AddLogitarCmsWithEntityFrameworkCore(this IServiceCollection services) + { + return services + .AddLogitarIdentityWithEntityFrameworkCoreRelational() + .AddLogitarCmsInfrastructure() + .AddMediatR(config => config.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly())) + .AddQueriers() + .AddRepositories() + .AddTransient(); + } + + public static IServiceCollection AddQueriers(this IServiceCollection services) + { + return services + .AddTransient(); + } + + public static IServiceCollection AddRepositories(this IServiceCollection services) + { + return services + .AddTransient() + .AddTransient(); + } +} diff --git a/backend/src/Logitar.Cms.EntityFrameworkCore/Entities/LanguageEntity.cs b/backend/src/Logitar.Cms.EntityFrameworkCore/Entities/LanguageEntity.cs new file mode 100644 index 0000000..69e49cb --- /dev/null +++ b/backend/src/Logitar.Cms.EntityFrameworkCore/Entities/LanguageEntity.cs @@ -0,0 +1,52 @@ +using Logitar.Cms.Core.Languages.Events; +using Logitar.Identity.Domain.Shared; +using Logitar.Identity.EntityFrameworkCore.Relational.Entities; + +namespace Logitar.Cms.EntityFrameworkCore.Entities; + +internal class LanguageEntity : AggregateEntity +{ + public int LanguageId { get; private set; } + public Guid UniqueId { get; private set; } + + public bool IsDefault { get; private set; } + + public int LCID { get; private set; } + public string Code { get; private set; } = string.Empty; + public string CodeNormalized + { + get => CmsDb.Normalize(Code); + private set { } + } + + public string DisplayName { get; private set; } = string.Empty; + public string EnglishName { get; private set; } = string.Empty; + public string NativeName { get; private set; } = string.Empty; + + public LanguageEntity(LanguageCreatedEvent @event) : base(@event) + { + UniqueId = @event.AggregateId.ToGuid(); + + IsDefault = @event.IsDefault; + + SetLocale(@event.Locale); + } + + private LanguageEntity() : base() + { + } + + private void SetLocale(LocaleUnit locale) + { + Code = locale.Code; + + SetCulture(locale.Culture); + } + private void SetCulture(CultureInfo culture) + { + LCID = culture.LCID; + DisplayName = culture.DisplayName; + EnglishName = culture.EnglishName; + NativeName = culture.NativeName; + } +} diff --git a/backend/src/Logitar.Cms.EntityFrameworkCore/Handlers/InitializeDatabaseCommandHandler.cs b/backend/src/Logitar.Cms.EntityFrameworkCore/Handlers/InitializeDatabaseCommandHandler.cs new file mode 100644 index 0000000..8e9c629 --- /dev/null +++ b/backend/src/Logitar.Cms.EntityFrameworkCore/Handlers/InitializeDatabaseCommandHandler.cs @@ -0,0 +1,34 @@ +using Logitar.Cms.Infrastructure.Commands; +using Logitar.EventSourcing.EntityFrameworkCore.Relational; +using Logitar.Identity.EntityFrameworkCore.Relational; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; + +namespace Logitar.Cms.EntityFrameworkCore.Handlers; + +internal class InitializeDatabaseCommandHandler : IRequestHandler +{ + private readonly bool _enableMigrations; + private readonly CmsContext _cmsContext; + private readonly EventContext _eventContext; + private readonly IdentityContext _identityContext; + + public InitializeDatabaseCommandHandler(IConfiguration configuration, CmsContext cmsContext, EventContext eventContext, IdentityContext identityContext) + { + _enableMigrations = configuration.GetValue("EnableMigrations"); + _cmsContext = cmsContext; + _eventContext = eventContext; + _identityContext = identityContext; + } + + public async Task Handle(InitializeDatabaseCommand command, CancellationToken cancellationToken) + { + if (_enableMigrations) + { + await _eventContext.Database.MigrateAsync(cancellationToken); + await _identityContext.Database.MigrateAsync(cancellationToken); + await _cmsContext.Database.MigrateAsync(cancellationToken); + } + } +} diff --git a/backend/src/Logitar.Cms.EntityFrameworkCore/Handlers/Languages/LanguageCreatedEventHandler.cs b/backend/src/Logitar.Cms.EntityFrameworkCore/Handlers/Languages/LanguageCreatedEventHandler.cs new file mode 100644 index 0000000..91e5dc0 --- /dev/null +++ b/backend/src/Logitar.Cms.EntityFrameworkCore/Handlers/Languages/LanguageCreatedEventHandler.cs @@ -0,0 +1,30 @@ +using Logitar.Cms.Core.Languages.Events; +using Logitar.Cms.EntityFrameworkCore.Entities; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Logitar.Cms.EntityFrameworkCore.Handlers.Languages; + +internal class LanguageCreatedEventHandler : INotificationHandler +{ + private readonly CmsContext _context; + + public LanguageCreatedEventHandler(CmsContext context) + { + _context = context; + } + + public async Task Handle(LanguageCreatedEvent @event, CancellationToken cancellationToken) + { + LanguageEntity? language = await _context.Languages.AsNoTracking() + .SingleOrDefaultAsync(x => x.AggregateId == @event.AggregateId.Value, cancellationToken); + if (language == null) + { + language = new(@event); + + _context.Languages.Add(language); + + await _context.SaveChangesAsync(cancellationToken); + } + } +} diff --git a/backend/src/Logitar.Cms.EntityFrameworkCore/Handlers/Languages/LanguageDeletedEventHandler.cs b/backend/src/Logitar.Cms.EntityFrameworkCore/Handlers/Languages/LanguageDeletedEventHandler.cs new file mode 100644 index 0000000..ba73336 --- /dev/null +++ b/backend/src/Logitar.Cms.EntityFrameworkCore/Handlers/Languages/LanguageDeletedEventHandler.cs @@ -0,0 +1,28 @@ +using Logitar.Cms.Core.Languages.Events; +using Logitar.Cms.EntityFrameworkCore.Entities; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Logitar.Cms.EntityFrameworkCore.Handlers.Languages; + +internal class LanguageDeletedEventHandler : INotificationHandler +{ + private readonly CmsContext _context; + + public LanguageDeletedEventHandler(CmsContext context) + { + _context = context; + } + + public async Task Handle(LanguageDeletedEvent @event, CancellationToken cancellationToken) + { + LanguageEntity? language = await _context.Languages + .SingleOrDefaultAsync(x => x.AggregateId == @event.AggregateId.Value, cancellationToken); + if (language != null) + { + _context.Languages.Remove(language); + + await _context.SaveChangesAsync(cancellationToken); + } + } +} diff --git a/backend/src/Logitar.Cms.EntityFrameworkCore/Logitar.Cms.EntityFrameworkCore.csproj b/backend/src/Logitar.Cms.EntityFrameworkCore/Logitar.Cms.EntityFrameworkCore.csproj index 4a67985..5b2e0a3 100644 --- a/backend/src/Logitar.Cms.EntityFrameworkCore/Logitar.Cms.EntityFrameworkCore.csproj +++ b/backend/src/Logitar.Cms.EntityFrameworkCore/Logitar.Cms.EntityFrameworkCore.csproj @@ -14,8 +14,17 @@ True + + + + + + + + + diff --git a/backend/src/Logitar.Cms.EntityFrameworkCore/Mapper.cs b/backend/src/Logitar.Cms.EntityFrameworkCore/Mapper.cs new file mode 100644 index 0000000..7133e08 --- /dev/null +++ b/backend/src/Logitar.Cms.EntityFrameworkCore/Mapper.cs @@ -0,0 +1,63 @@ +using Logitar.Cms.Contracts; +using Logitar.Cms.Contracts.Actors; +using Logitar.Cms.Contracts.Configurations; +using Logitar.Cms.Core; +using Logitar.Cms.Core.Configurations; +using Logitar.EventSourcing; +using Logitar.Identity.EntityFrameworkCore.Relational.Entities; + +namespace Logitar.Cms.EntityFrameworkCore; + +internal class Mapper +{ + private readonly Dictionary _actors; + + public Mapper() + { + _actors = []; + } + public Mapper(IEnumerable actors) : this() + { + foreach (Actor actor in actors) + { + ActorId id = new(actor.Id); + _actors[id] = actor; + } + } + + public static Actor ToActor(ActorEntity source) => new(source.DisplayName) + { + Id = new ActorId(source.Id).ToGuid(), + Type = Enum.Parse(source.Type), + IsDeleted = source.IsDeleted, + EmailAddress = source.EmailAddress, + PictureUrl = source.PictureUrl + }; + + public Configuration ToConfiguration(ConfigurationAggregate source) + { + Configuration destination = new(source.Secret.Value) + { + UniqueNameSettings = new(source.UniqueNameSettings), + PasswordSettings = new(source.PasswordSettings), + RequireUniqueName = source.RequireUniqueEmail, + LoggingSettings = new(source.LoggingSettings) + }; + + MapAggregate(source, destination); + + return destination; + } + + private void MapAggregate(AggregateRoot source, Aggregate destination) + { + destination.Id = source.Id.ToGuid(); + destination.Version = source.Version; + destination.CreatedBy = FindActor(source.CreatedBy); + destination.CreatedOn = source.CreatedOn.AsUniversalTime(); + destination.UpdatedBy = FindActor(source.UpdatedBy); + destination.UpdatedOn = source.UpdatedOn.AsUniversalTime(); + } + + private Actor FindActor(ActorId id) => _actors.TryGetValue(id, out Actor? actor) ? actor : Actor.System; +} diff --git a/backend/src/Logitar.Cms.EntityFrameworkCore/Queriers/ConfigurationQuerier.cs b/backend/src/Logitar.Cms.EntityFrameworkCore/Queriers/ConfigurationQuerier.cs new file mode 100644 index 0000000..312cf2c --- /dev/null +++ b/backend/src/Logitar.Cms.EntityFrameworkCore/Queriers/ConfigurationQuerier.cs @@ -0,0 +1,26 @@ +using Logitar.Cms.Contracts.Actors; +using Logitar.Cms.Contracts.Configurations; +using Logitar.Cms.Core.Configurations; +using Logitar.Cms.EntityFrameworkCore.Actors; +using Logitar.EventSourcing; + +namespace Logitar.Cms.EntityFrameworkCore.Queriers; + +internal class ConfigurationQuerier : IConfigurationQuerier +{ + private readonly IActorService _actorService; + + public ConfigurationQuerier(IActorService actorService) + { + _actorService = actorService; + } + + public async Task ReadAsync(ConfigurationAggregate configuration, CancellationToken cancellationToken) + { + IEnumerable actorIds = [configuration.CreatedBy, configuration.UpdatedBy]; + IReadOnlyCollection actors = await _actorService.FindAsync(actorIds, cancellationToken); + Mapper mapper = new(actors); + + return mapper.ToConfiguration(configuration); + } +} diff --git a/backend/src/Logitar.Cms.EntityFrameworkCore/Repositories/ConfigurationRepository.cs b/backend/src/Logitar.Cms.EntityFrameworkCore/Repositories/ConfigurationRepository.cs new file mode 100644 index 0000000..9b3d3c9 --- /dev/null +++ b/backend/src/Logitar.Cms.EntityFrameworkCore/Repositories/ConfigurationRepository.cs @@ -0,0 +1,25 @@ +using Logitar.Cms.Core.Configurations; +using Logitar.EventSourcing; +using Logitar.EventSourcing.EntityFrameworkCore.Relational; +using Logitar.EventSourcing.Infrastructure; + +namespace Logitar.Cms.EntityFrameworkCore.Repositories; + +internal class ConfigurationRepository : EventSourcing.EntityFrameworkCore.Relational.AggregateRepository, IConfigurationRepository +{ + public ConfigurationRepository(IEventBus eventBus, EventContext eventContext, IEventSerializer eventSerializer) + : base(eventBus, eventContext, eventSerializer) + { + } + + public async Task LoadAsync(CancellationToken cancellationToken) + { + AggregateId id = new ConfigurationId().AggregateId; + return await base.LoadAsync(id, cancellationToken); + } + + public async Task SaveAsync(ConfigurationAggregate configuration, CancellationToken cancellationToken) + { + await base.SaveAsync(configuration, cancellationToken); + } +} diff --git a/backend/src/Logitar.Cms.EntityFrameworkCore/Repositories/LanguageRepository.cs b/backend/src/Logitar.Cms.EntityFrameworkCore/Repositories/LanguageRepository.cs new file mode 100644 index 0000000..05c578f --- /dev/null +++ b/backend/src/Logitar.Cms.EntityFrameworkCore/Repositories/LanguageRepository.cs @@ -0,0 +1,48 @@ +using Logitar.Cms.Core.Languages; +using Logitar.Data; +using Logitar.EventSourcing.EntityFrameworkCore.Relational; +using Logitar.EventSourcing.Infrastructure; +using Logitar.Identity.Domain.Shared; +using Logitar.Identity.EntityFrameworkCore.Relational; +using Microsoft.EntityFrameworkCore; + +namespace Logitar.Cms.EntityFrameworkCore.Repositories; + +internal class LanguageRepository : EventSourcing.EntityFrameworkCore.Relational.AggregateRepository, ILanguageRepository +{ + private static readonly string AggregateType = typeof(LanguageAggregate).GetNamespaceQualifiedName(); + + private readonly ISqlHelper _sqlHelper; + + public LanguageRepository(IEventBus eventBus, EventContext eventContext, IEventSerializer eventSerializer, ISqlHelper sqlHelper) + : base(eventBus, eventContext, eventSerializer) + { + _sqlHelper = sqlHelper; + } + + public async Task LoadAsync(LocaleUnit locale, CancellationToken cancellationToken) + { + IQuery query = _sqlHelper.QueryFrom(EventDb.Events.Table).SelectAll(EventDb.Events.Table) + .Join(CmsDb.Languages.AggregateId, EventDb.Events.AggregateId, + new OperatorCondition(EventDb.Events.AggregateType, Operators.IsEqualTo(AggregateType)) + ) + .Where(CmsDb.Languages.CodeNormalized, Operators.IsEqualTo(CmsDb.Normalize(locale.Code))) + .Build(); + + EventEntity[] events = await EventContext.Events.FromQuery(query) + .AsNoTracking() + .OrderBy(e => e.Version) + .ToArrayAsync(cancellationToken); + + return Load(events.Select(EventSerializer.Deserialize)).SingleOrDefault(); + } + + public async Task SaveAsync(LanguageAggregate language, CancellationToken cancellationToken) + { + await base.SaveAsync(language, cancellationToken); + } + public async Task SaveAsync(IEnumerable languages, CancellationToken cancellationToken) + { + await base.SaveAsync(languages, cancellationToken); + } +} diff --git a/backend/src/Logitar.Cms.Infrastructure/Caching/CacheService.cs b/backend/src/Logitar.Cms.Infrastructure/Caching/CacheService.cs new file mode 100644 index 0000000..2e76692 --- /dev/null +++ b/backend/src/Logitar.Cms.Infrastructure/Caching/CacheService.cs @@ -0,0 +1,50 @@ +using Logitar.Cms.Contracts.Actors; +using Logitar.Cms.Contracts.Configurations; +using Logitar.Cms.Core.Caching; +using Logitar.Cms.Infrastructure.Settings; +using Logitar.EventSourcing; +using Microsoft.Extensions.Caching.Memory; + +namespace Logitar.Cms.Infrastructure.Caching; + +internal class CacheService : ICacheService +{ + private readonly IMemoryCache _memoryCache; + private readonly CachingSettings _settings; + + public CacheService(IMemoryCache memoryCache, CachingSettings settings) + { + _memoryCache = memoryCache; + _settings = settings; + } + + public Configuration? Configuration + { + get => GetItem(ConfigurationKey); + set => SetItem(ConfigurationKey, value); + } + private const string ConfigurationKey = nameof(Configuration); + + public Actor? GetActor(ActorId id) => GetItem(GetActorKey(id)); + public void RemoveActor(ActorId id) => SetItem(GetActorKey(id), value: null); + public void SetActor(Actor actor) => SetItem(GetActorKey(actor.Id), actor, _settings.ActorLifetime); + private static string GetActorKey(Guid id) => GetActorKey(new ActorId(id)); + private static string GetActorKey(ActorId id) => $"Actor.Id:{id}"; + + private T? GetItem(object key) => _memoryCache.TryGetValue(key, out object? value) ? (T?)value : default; + private void SetItem(object key, T? value, TimeSpan? lifetime = null) + { + if (value == null) + { + _memoryCache.Remove(key); + } + else if (lifetime.HasValue) + { + _memoryCache.Set(key, value, lifetime.Value); + } + else + { + _memoryCache.Set(key, value); + } + } +} diff --git a/backend/src/Logitar.Cms.Infrastructure/CmsEventBus.cs b/backend/src/Logitar.Cms.Infrastructure/CmsEventBus.cs new file mode 100644 index 0000000..00c3310 --- /dev/null +++ b/backend/src/Logitar.Cms.Infrastructure/CmsEventBus.cs @@ -0,0 +1,33 @@ +using Logitar.Cms.Core.Caching; +using Logitar.EventSourcing; +using Logitar.Identity.Infrastructure; +using Logitar.Identity.Infrastructure.Handlers; +using MediatR; + +namespace Logitar.Cms.Infrastructure; + +internal class CmsEventBus : EventBus +{ + private readonly ICacheService _cacheService; + + public CmsEventBus(ICacheService cacheService, IPublisher publisher, IApiKeyEventHandler apiKeyEventHandler, IOneTimePasswordEventHandler oneTimePasswordEventHandler, IRoleEventHandler roleEventHandler, ISessionEventHandler sessionEventHandler, IUserEventHandler userEventHandler) + : base(publisher, apiKeyEventHandler, oneTimePasswordEventHandler, roleEventHandler, sessionEventHandler, userEventHandler) + { + _cacheService = cacheService; + } + + public override async Task PublishAsync(DomainEvent @event, CancellationToken cancellationToken) + { + await base.PublishAsync(@event, cancellationToken); + + string? @namespace = @event.GetType().Namespace; + switch (@namespace) + { + case "Logitar.Identity.Domain.ApiKeys.Events": + case "Logitar.Identity.Domain.Users.Events": + ActorId actorId = new(@event.AggregateId.Value); + _cacheService.RemoveActor(actorId); + break; + } + } +} diff --git a/backend/src/Logitar.Cms.Infrastructure/Commands/InitializeDatabaseCommand.cs b/backend/src/Logitar.Cms.Infrastructure/Commands/InitializeDatabaseCommand.cs new file mode 100644 index 0000000..7bdf400 --- /dev/null +++ b/backend/src/Logitar.Cms.Infrastructure/Commands/InitializeDatabaseCommand.cs @@ -0,0 +1,5 @@ +using MediatR; + +namespace Logitar.Cms.Infrastructure.Commands; + +public record InitializeDatabaseCommand : IRequest; diff --git a/backend/src/Logitar.Cms.Infrastructure/Converters/ConfigurationIdConverter.cs b/backend/src/Logitar.Cms.Infrastructure/Converters/ConfigurationIdConverter.cs new file mode 100644 index 0000000..20ba027 --- /dev/null +++ b/backend/src/Logitar.Cms.Infrastructure/Converters/ConfigurationIdConverter.cs @@ -0,0 +1,16 @@ +using Logitar.Cms.Core.Configurations; + +namespace Logitar.Cms.Infrastructure.Converters; + +public class ConfigurationIdConverter : JsonConverter +{ + public override ConfigurationId? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new ConfigurationId(); + } + + public override void Write(Utf8JsonWriter writer, ConfigurationId configurationId, JsonSerializerOptions options) + { + writer.WriteStringValue(configurationId.Value); + } +} diff --git a/backend/src/Logitar.Cms.Infrastructure/Converters/JwtSecretConverter.cs b/backend/src/Logitar.Cms.Infrastructure/Converters/JwtSecretConverter.cs new file mode 100644 index 0000000..fbc2af0 --- /dev/null +++ b/backend/src/Logitar.Cms.Infrastructure/Converters/JwtSecretConverter.cs @@ -0,0 +1,16 @@ +using Logitar.Cms.Core.Configurations; + +namespace Logitar.Cms.Infrastructure.Converters; + +public class JwtSecretConverter : JsonConverter +{ + public override JwtSecretUnit? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return JwtSecretUnit.CreateOrGenerate(reader.GetString()); + } + + public override void Write(Utf8JsonWriter writer, JwtSecretUnit jwtSecret, JsonSerializerOptions options) + { + writer.WriteStringValue(jwtSecret.Value); + } +} diff --git a/backend/src/Logitar.Cms.Infrastructure/Converters/LanguageIdConverter.cs b/backend/src/Logitar.Cms.Infrastructure/Converters/LanguageIdConverter.cs new file mode 100644 index 0000000..fd42181 --- /dev/null +++ b/backend/src/Logitar.Cms.Infrastructure/Converters/LanguageIdConverter.cs @@ -0,0 +1,16 @@ +using Logitar.Cms.Core.Languages; + +namespace Logitar.Cms.Infrastructure.Converters; + +public class LanguageIdConverter : JsonConverter +{ + public override LanguageId? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return LanguageId.TryCreate(reader.GetString()); + } + + public override void Write(Utf8JsonWriter writer, LanguageId languageId, JsonSerializerOptions options) + { + writer.WriteStringValue(languageId.Value); + } +} diff --git a/backend/src/Logitar.Cms.Infrastructure/DatabaseProvider.cs b/backend/src/Logitar.Cms.Infrastructure/DatabaseProvider.cs new file mode 100644 index 0000000..ae18408 --- /dev/null +++ b/backend/src/Logitar.Cms.Infrastructure/DatabaseProvider.cs @@ -0,0 +1,7 @@ +namespace Logitar.Cms.Infrastructure; + +public enum DatabaseProvider +{ + EntityFrameworkCorePostgreSQL, + EntityFrameworkCoreSqlServer +} diff --git a/backend/src/Logitar.Cms.Infrastructure/DatabaseProviderNotSupportedException.cs b/backend/src/Logitar.Cms.Infrastructure/DatabaseProviderNotSupportedException.cs new file mode 100644 index 0000000..dc344b9 --- /dev/null +++ b/backend/src/Logitar.Cms.Infrastructure/DatabaseProviderNotSupportedException.cs @@ -0,0 +1,22 @@ +namespace Logitar.Cms.Infrastructure; + +public class DatabaseProviderNotSupportedException : NotSupportedException +{ + public const string ErrorMessage = "The specified database provider is not supported."; + + public DatabaseProvider DatabaseProvider + { + get => (DatabaseProvider)Data[nameof(DatabaseProvider)]!; + private set => Data[nameof(DatabaseProvider)] = value; + } + + public DatabaseProviderNotSupportedException(DatabaseProvider databaseProvider) + : base(BuildMessage(databaseProvider)) + { + DatabaseProvider = databaseProvider; + } + + private static string BuildMessage(DatabaseProvider databaseProvider) => new ErrorMessageBuilder(ErrorMessage) + .AddData(nameof(DatabaseProvider), databaseProvider) + .Build(); +} diff --git a/backend/src/Logitar.Cms.Infrastructure/DependencyInjectionExtensions.cs b/backend/src/Logitar.Cms.Infrastructure/DependencyInjectionExtensions.cs new file mode 100644 index 0000000..d1b197e --- /dev/null +++ b/backend/src/Logitar.Cms.Infrastructure/DependencyInjectionExtensions.cs @@ -0,0 +1,44 @@ +using Logitar.Cms.Core; +using Logitar.Cms.Core.Caching; +using Logitar.Cms.Infrastructure.Caching; +using Logitar.Cms.Infrastructure.Converters; +using Logitar.Cms.Infrastructure.Settings; +using Logitar.EventSourcing.Infrastructure; +using Logitar.Identity.Infrastructure; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Logitar.Cms.Infrastructure; + +public static class DependencyInjectionExtensions +{ + public static IServiceCollection AddLogitarCmsInfrastructure(this IServiceCollection services) + { + return services + .AddLogitarIdentityInfrastructure() + .AddLogitarCmsCore() + .AddMemoryCache() + .AddSingleton(GetCachingSettings) + .AddSingleton() + .AddSingleton(services => new EventSerializer(services.GetLogitarCmsJsonConverters())) + .AddTransient(); + } + + public static CachingSettings GetCachingSettings(this IServiceProvider serviceProvider) + { + IConfiguration configuration = serviceProvider.GetRequiredService(); + return configuration.GetSection(CachingSettings.SectionKey).Get() ?? new(); + } + + public static IReadOnlyCollection GetLogitarCmsJsonConverters(this IServiceProvider serviceProvider) + { + List converters = []; + converters.AddRange(serviceProvider.GetLogitarIdentityJsonConverters()); + + converters.Add(new ConfigurationIdConverter()); + converters.Add(new JwtSecretConverter()); + converters.Add(new LanguageIdConverter()); + + return converters.AsReadOnly(); + } +} diff --git a/backend/src/Logitar.Cms.Infrastructure/Logitar.Cms.Infrastructure.csproj b/backend/src/Logitar.Cms.Infrastructure/Logitar.Cms.Infrastructure.csproj index 31fa64a..1192e49 100644 --- a/backend/src/Logitar.Cms.Infrastructure/Logitar.Cms.Infrastructure.csproj +++ b/backend/src/Logitar.Cms.Infrastructure/Logitar.Cms.Infrastructure.csproj @@ -14,8 +14,18 @@ True + + + + + + + + + + diff --git a/backend/src/Logitar.Cms.Infrastructure/Settings/CachingSettings.cs b/backend/src/Logitar.Cms.Infrastructure/Settings/CachingSettings.cs new file mode 100644 index 0000000..265182e --- /dev/null +++ b/backend/src/Logitar.Cms.Infrastructure/Settings/CachingSettings.cs @@ -0,0 +1,8 @@ +namespace Logitar.Cms.Infrastructure.Settings; + +public record CachingSettings +{ + public const string SectionKey = "Caching"; + + public TimeSpan? ActorLifetime { get; set; } +} diff --git a/backend/src/Logitar.Cms.Web/Controllers/CmsController.cs b/backend/src/Logitar.Cms.Web/Controllers/CmsController.cs new file mode 100644 index 0000000..977c36f --- /dev/null +++ b/backend/src/Logitar.Cms.Web/Controllers/CmsController.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Logitar.Cms.Web.Controllers; + +[ApiExplorerSettings(IgnoreApi = true)] +[Route("cms")] +public class CmsController : Controller +{ + [HttpGet("{**anything}")] + public ActionResult Index() => View(); +} diff --git a/backend/src/Logitar.Cms.Web/DependencyInjectionExtensions.cs b/backend/src/Logitar.Cms.Web/DependencyInjectionExtensions.cs new file mode 100644 index 0000000..830c4f4 --- /dev/null +++ b/backend/src/Logitar.Cms.Web/DependencyInjectionExtensions.cs @@ -0,0 +1,24 @@ +using Logitar.Cms.Core; +using Logitar.Cms.Web.Filters; + +namespace Logitar.Cms.Web; + +public static class DependencyInjectionExtensions +{ + public static IServiceCollection AddLogitarCmsWeb(this IServiceCollection services) + { + services + .AddControllersWithViews(options => + { + options.Filters.Add(); + // TODO(fpion): LoggingFilter + }) + .AddJsonOptions(options => + { + options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); + options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles; + }); + + return services.AddLogitarCmsCore(); + } +} diff --git a/backend/src/Logitar.Cms.Web/Filters/ExceptionHandling.cs b/backend/src/Logitar.Cms.Web/Filters/ExceptionHandling.cs new file mode 100644 index 0000000..5e9c048 --- /dev/null +++ b/backend/src/Logitar.Cms.Web/Filters/ExceptionHandling.cs @@ -0,0 +1,35 @@ +using FluentValidation; +using FluentValidation.Results; +using Logitar.Cms.Contracts.Errors; +using Logitar.Cms.Core; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Logitar.Cms.Web.Filters; + +public class ExceptionHandling : ExceptionFilterAttribute +{ + public override void OnException(ExceptionContext context) + { + if (context.Exception is ValidationException validation) + { + ValidationError error = new(); + foreach (ValidationFailure failure in validation.Errors) + { + error.Add(failure.ToPropertyError()); + } + + context.Result = new BadRequestObjectResult(error); + context.ExceptionHandled = true; + } + else if (context.Exception is ConflictException conflict) + { + context.Result = new ConflictObjectResult(conflict.Error); + context.ExceptionHandled = true; + } + else + { + base.OnException(context); + } + } +} diff --git a/backend/src/Logitar.Cms.Web/Logitar.Cms.Web.csproj b/backend/src/Logitar.Cms.Web/Logitar.Cms.Web.csproj index 2b40ea7..d35dc8f 100644 --- a/backend/src/Logitar.Cms.Web/Logitar.Cms.Web.csproj +++ b/backend/src/Logitar.Cms.Web/Logitar.Cms.Web.csproj @@ -22,4 +22,8 @@ + + + + diff --git a/backend/src/Logitar.Cms.Web/Middlewares/RedirectNotFound.cs b/backend/src/Logitar.Cms.Web/Middlewares/RedirectNotFound.cs new file mode 100644 index 0000000..6e79493 --- /dev/null +++ b/backend/src/Logitar.Cms.Web/Middlewares/RedirectNotFound.cs @@ -0,0 +1,23 @@ +using Microsoft.AspNetCore.Http.Extensions; + +namespace Logitar.Cms.Web.Middlewares; + +public class RedirectNotFound +{ + private readonly RequestDelegate _next; + + public RedirectNotFound(RequestDelegate next) + { + _next = next; + } + + public async Task InvokeAsync(HttpContext context) + { + await _next(context); + + if (context.Response.StatusCode == StatusCodes.Status404NotFound && !context.Request.Path.StartsWithSegments("/api")) + { + context.Response.Redirect($"/cms/{UriHelper.GetEncodedPathAndQuery(context.Request).Trim('/')}"); + } + } +} diff --git a/backend/src/Logitar.Cms.Web/Properties/launchSettings.json b/backend/src/Logitar.Cms.Web/Properties/launchSettings.json new file mode 100644 index 0000000..714638a --- /dev/null +++ b/backend/src/Logitar.Cms.Web/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "Logitar.Cms.Web": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:55601;http://localhost:55602" + } + } +} \ No newline at end of file diff --git a/backend/src/Logitar.Cms.Web/Views/Cms/Index.cshtml b/backend/src/Logitar.Cms.Web/Views/Cms/Index.cshtml new file mode 100644 index 0000000..e0d95ee --- /dev/null +++ b/backend/src/Logitar.Cms.Web/Views/Cms/Index.cshtml @@ -0,0 +1,14 @@ + + + + + + + LogitarCMS + + + + +
+ + diff --git a/backend/src/Logitar.Cms/Extensions/CorsExtensions.cs b/backend/src/Logitar.Cms/Extensions/CorsExtensions.cs new file mode 100644 index 0000000..95a75fe --- /dev/null +++ b/backend/src/Logitar.Cms/Extensions/CorsExtensions.cs @@ -0,0 +1,50 @@ +using Logitar.Cms.Settings; + +namespace Logitar.Cms.Extensions; + +internal static class CorsExtensions +{ + public static IServiceCollection AddCors(this IServiceCollection services, CorsSettings settings) + { + services.AddCors(options => options.AddDefaultPolicy(cors => + { + if (settings.AllowAnyOrigin) + { + cors.AllowAnyOrigin(); + } + else + { + cors.WithOrigins(settings.AllowedOrigins); + } + + if (settings.AllowAnyMethod) + { + cors.AllowAnyMethod(); + } + else + { + cors.WithMethods(settings.AllowedMethods); + } + + if (settings.AllowAnyHeader) + { + cors.AllowAnyHeader(); + } + else + { + cors.WithHeaders(settings.AllowedHeaders); + } + + if (settings.AllowCredentials) + { + cors.AllowCredentials(); + } + else + { + cors.DisallowCredentials(); + } + })); + + return services; + } +} diff --git a/backend/src/Logitar.Cms/Extensions/OpenApiExtensions.cs b/backend/src/Logitar.Cms/Extensions/OpenApiExtensions.cs new file mode 100644 index 0000000..6bfa4d1 --- /dev/null +++ b/backend/src/Logitar.Cms/Extensions/OpenApiExtensions.cs @@ -0,0 +1,129 @@ +using Logitar.Cms.Constants; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Logitar.Cms.Extensions; + +internal static class OpenApiExtensions +{ + public static IServiceCollection AddOpenApi(this IServiceCollection services) + { + services.AddEndpointsApiExplorer(); + services.AddSwaggerGen(config => + { + config.AddSecurity(); + config.SwaggerDoc(name: $"v{Api.Version.Major}", new OpenApiInfo + { + Contact = new OpenApiContact + { + Email = "francispion@hotmail.com", + Name = "Logitar Team", + Url = new Uri("https://github.com/Logitar/CMS", UriKind.Absolute) + }, + Description = "Content management system.", + License = new OpenApiLicense + { + Name = "Use under MIT", + Url = new Uri("https://github.com/Logitar/CMS/blob/main/LICENSE", UriKind.Absolute) + }, + Title = Api.Title, + Version = $"v{Api.Version}" + }); + }); + + return services; + } + + public static void UseOpenApi(this IApplicationBuilder builder) + { + builder.UseSwagger(); + builder.UseSwaggerUI(config => config.SwaggerEndpoint( + url: $"/swagger/v{Api.Version.Major}/swagger.json", + name: $"{Api.Title} v{Api.Version}" + )); + } + + private static void AddSecurity(this SwaggerGenOptions options) + { + //options.AddSecurityDefinition(Schemes.ApiKey, new OpenApiSecurityScheme + //{ + // Description = "Enter your API key in the input below:", + // In = ParameterLocation.Header, + // Name = Headers.ApiKey, + // Scheme = Schemes.ApiKey, + // Type = SecuritySchemeType.ApiKey + //}); + //options.AddSecurityRequirement(new OpenApiSecurityRequirement + //{ + // { + // new OpenApiSecurityScheme + // { + // In = ParameterLocation.Header, + // Name = Headers.ApiKey, + // Reference = new OpenApiReference + // { + // Id = Schemes.ApiKey, + // Type = ReferenceType.SecurityScheme + // }, + // Scheme = Schemes.ApiKey, + // Type = SecuritySchemeType.ApiKey + // }, + // new List() + // } + //}); + + //options.AddSecurityDefinition(Schemes.Basic, new OpenApiSecurityScheme + //{ + // Description = "Enter your credentials in the inputs below:", + // In = ParameterLocation.Header, + // Name = Headers.Authorization, + // Scheme = Schemes.Basic, + // Type = SecuritySchemeType.Http + //}); + //options.AddSecurityRequirement(new OpenApiSecurityRequirement + //{ + // { + // new OpenApiSecurityScheme + // { + // In = ParameterLocation.Header, + // Name = Headers.Authorization, + // Reference = new OpenApiReference + // { + // Id = Schemes.Basic, + // Type = ReferenceType.SecurityScheme + // }, + // Scheme = Schemes.Basic, + // Type = SecuritySchemeType.Http + // }, + // new List() + // } + //}); + + //options.AddSecurityDefinition(Schemes.Bearer, new OpenApiSecurityScheme + //{ + // Description = "Enter your access token in the input below:", + // In = ParameterLocation.Header, + // Name = Headers.Authorization, + // Scheme = Schemes.Bearer, + // Type = SecuritySchemeType.Http + //}); + //options.AddSecurityRequirement(new OpenApiSecurityRequirement + //{ + // { + // new OpenApiSecurityScheme + // { + // In = ParameterLocation.Header, + // Name = Headers.Authorization, + // Reference = new OpenApiReference + // { + // Id = Schemes.Bearer, + // Type = ReferenceType.SecurityScheme + // }, + // Scheme = Schemes.Bearer, + // Type = SecuritySchemeType.Http + // }, + // new List() + // } + //}); + } +} diff --git a/backend/src/Logitar.Cms/Logitar.Cms.csproj b/backend/src/Logitar.Cms/Logitar.Cms.csproj index 0fef9ac..51193bc 100644 --- a/backend/src/Logitar.Cms/Logitar.Cms.csproj +++ b/backend/src/Logitar.Cms/Logitar.Cms.csproj @@ -18,8 +18,28 @@ - - + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/backend/src/Logitar.Cms/Program.cs b/backend/src/Logitar.Cms/Program.cs index d382c86..97912d5 100644 --- a/backend/src/Logitar.Cms/Program.cs +++ b/backend/src/Logitar.Cms/Program.cs @@ -1,18 +1,36 @@ -namespace Logitar.Cms; +using Logitar.Cms.Core.Configurations.Commands; +using Logitar.Cms.Infrastructure.Commands; +using MediatR; + +namespace Logitar.Cms; internal static class Program { - public static void Main(string[] args) + private const string DefaultLocale = "en"; + private const string DefaultUsername = "admin"; + private const string DefaultPassword = "P@s$W0rD"; + + public static async Task Main(string[] args) { WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + IConfiguration configuration = builder.Configuration; - Startup startup = new(builder.Configuration); + Startup startup = new(configuration); startup.ConfigureServices(builder.Services); WebApplication application = builder.Build(); startup.Configure(application); + using IServiceScope scope = application.Services.CreateScope(); + ISender sender = scope.ServiceProvider.GetRequiredService(); + await sender.Send(new InitializeDatabaseCommand()); + + string defaultLocale = configuration.GetValue("CMS_LOCALE") ?? DefaultLocale; + string username = configuration.GetValue("CMS_USERNAME") ?? DefaultUsername; + string password = configuration.GetValue("CMS_PASSWORD") ?? DefaultPassword; + await sender.Send(new InitializeConfigurationCommand(defaultLocale, username, password)); + application.Run(); } } diff --git a/backend/src/Logitar.Cms/Settings/CorsSettings.cs b/backend/src/Logitar.Cms/Settings/CorsSettings.cs new file mode 100644 index 0000000..a61263e --- /dev/null +++ b/backend/src/Logitar.Cms/Settings/CorsSettings.cs @@ -0,0 +1,17 @@ +namespace Logitar.Cms.Settings; + +internal record CorsSettings +{ + public const string SectionKey = "Cors"; + + public bool AllowAnyOrigin { get; set; } + public string[] AllowedOrigins { get; set; } = []; + + public bool AllowAnyMethod { get; set; } + public string[] AllowedMethods { get; set; } = []; + + public bool AllowAnyHeader { get; set; } + public string[] AllowedHeaders { get; set; } = []; + + public bool AllowCredentials { get; set; } +} diff --git a/backend/src/Logitar.Cms/Startup.cs b/backend/src/Logitar.Cms/Startup.cs index 15fe342..c350e5d 100644 --- a/backend/src/Logitar.Cms/Startup.cs +++ b/backend/src/Logitar.Cms/Startup.cs @@ -1,4 +1,13 @@ -using System.Text.Json.Serialization; +using Logitar.Cms.EntityFrameworkCore; +using Logitar.Cms.EntityFrameworkCore.PostgreSQL; +using Logitar.Cms.EntityFrameworkCore.SqlServer; +using Logitar.Cms.Extensions; +using Logitar.Cms.Infrastructure; +using Logitar.Cms.Settings; +using Logitar.Cms.Web; +using Logitar.Cms.Web.Middlewares; +using Logitar.EventSourcing.EntityFrameworkCore.Relational; +using Logitar.Identity.EntityFrameworkCore.Relational; namespace Logitar.Cms; @@ -17,13 +26,38 @@ public override void ConfigureServices(IServiceCollection services) { base.ConfigureServices(services); - services.AddControllersWithViews() - .AddJsonOptions(options => options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter())); + services.AddLogitarCmsWeb(); + + CorsSettings corsSettings = _configuration.GetSection(CorsSettings.SectionKey).Get() ?? new(); + services.AddSingleton(corsSettings); + services.AddCors(corsSettings); if (_enableOpenApi) { - services.AddEndpointsApiExplorer(); - services.AddSwaggerGen(); + services.AddOpenApi(); + } + + services.AddApplicationInsightsTelemetry(); + IHealthChecksBuilder healthChecks = services.AddHealthChecks(); + + DatabaseProvider databaseProvider = _configuration.GetValue("DatabaseProvider") + ?? DatabaseProvider.EntityFrameworkCoreSqlServer; + switch (databaseProvider) + { + case DatabaseProvider.EntityFrameworkCorePostgreSQL: + services.AddLogitarCmsWithEntityFrameworkCorePostgreSQL(_configuration); + healthChecks.AddDbContextCheck(); + healthChecks.AddDbContextCheck(); + healthChecks.AddDbContextCheck(); + break; + case DatabaseProvider.EntityFrameworkCoreSqlServer: + services.AddLogitarCmsWithEntityFrameworkCoreSqlServer(_configuration); + healthChecks.AddDbContextCheck(); + healthChecks.AddDbContextCheck(); + healthChecks.AddDbContextCheck(); + break; + default: + throw new DatabaseProviderNotSupportedException(databaseProvider); } } @@ -31,16 +65,19 @@ public override void Configure(IApplicationBuilder builder) { if (_enableOpenApi) { - builder.UseSwagger(); - builder.UseSwaggerUI(); + builder.UseOpenApi(); } builder.UseHttpsRedirection(); - builder.UseAuthorization(); + builder.UseCors(); + builder.UseStaticFiles(); + //builder.UseMiddleware(); // TODO(fpion): Logging + builder.UseMiddleware(); if (builder is WebApplication application) { application.MapControllers(); + application.MapHealthChecks("/health"); } } } diff --git a/backend/src/Logitar.Cms/appsettings.Development.json b/backend/src/Logitar.Cms/appsettings.Development.json index bc36f69..11275c3 100644 --- a/backend/src/Logitar.Cms/appsettings.Development.json +++ b/backend/src/Logitar.Cms/appsettings.Development.json @@ -1,4 +1,10 @@ { + "Cors": { + "AllowedOrigins": [ "http://localhost:7790" ], + "AllowedMethods": [ "DELETE", "GET", "PATCH", "POST", "PUT" ], + "AllowedHeaders": [ "Content-Type" ], + "AllowCredentials": true + }, "EnableBasicAuthentication": true, "EnableMigrations": true, "EnableOpenApi": true, @@ -7,5 +13,7 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } - } + }, + "POSTGRESQLCONNSTR_Cms": "User ID=postgres;Password=kzXnZxLf9s8WQ3vh;Host=host.docker.internal;Port=5440;Database=Cms;", + "SQLCONNSTR_Cms": "Server=host.docker.internal,1440;Database=Cms;User Id=SA;Password=hMe5CJmF2SVLDtvN;Persist Security Info=False;Encrypt=False;" } diff --git a/backend/src/Logitar.Cms/appsettings.json b/backend/src/Logitar.Cms/appsettings.json index 5d8ba2e..e84bf77 100644 --- a/backend/src/Logitar.Cms/appsettings.json +++ b/backend/src/Logitar.Cms/appsettings.json @@ -1,5 +1,17 @@ { "AllowedHosts": "*", + "Caching": { + "ActorLifetime": "00:15:00" + }, + "Cors": { + "AllowAnyOrigin": false, + "AllowedOrigins": [], + "AllowAnyMethod": false, + "AllowedMethods": [], + "AllowAnyHeader": false, + "AllowedHeaders": [], + "AllowCredentials": false + }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/backend/src/Logitar.Cms/secrets.example.json b/backend/src/Logitar.Cms/secrets.example.json new file mode 100644 index 0000000..b3a31eb --- /dev/null +++ b/backend/src/Logitar.Cms/secrets.example.json @@ -0,0 +1,3 @@ +{ + "DatabaseProvider": "EntityFrameworkCorePostgreSQL" +} \ No newline at end of file