diff --git a/backend/Cms.sln b/backend/Cms.sln
index 28e2933..6380c2c 100644
--- a/backend/Cms.sln
+++ b/backend/Cms.sln
@@ -20,6 +20,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{49A3AE69
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Logitar.Cms.UnitTests", "tests\Logitar.Cms.UnitTests\Logitar.Cms.UnitTests.csproj", "{F367B008-1CAD-49A5-9EB1-5160661FF9CC}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Logitar.Cms.Infrastructure", "src\Logitar.Cms.Infrastructure\Logitar.Cms.Infrastructure.csproj", "{15549282-0316-4CA0-A3B2-ADE7E89C4A3D}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -38,6 +40,10 @@ Global
{F367B008-1CAD-49A5-9EB1-5160661FF9CC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F367B008-1CAD-49A5-9EB1-5160661FF9CC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F367B008-1CAD-49A5-9EB1-5160661FF9CC}.Release|Any CPU.Build.0 = Release|Any CPU
+ {15549282-0316-4CA0-A3B2-ADE7E89C4A3D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {15549282-0316-4CA0-A3B2-ADE7E89C4A3D}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {15549282-0316-4CA0-A3B2-ADE7E89C4A3D}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {15549282-0316-4CA0-A3B2-ADE7E89C4A3D}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/backend/src/Logitar.Cms.Core/Models/ActorType.cs b/backend/src/Logitar.Cms.Core/Actors/ActorType.cs
similarity index 63%
rename from backend/src/Logitar.Cms.Core/Models/ActorType.cs
rename to backend/src/Logitar.Cms.Core/Actors/ActorType.cs
index 58a1434..a2ad4b5 100644
--- a/backend/src/Logitar.Cms.Core/Models/ActorType.cs
+++ b/backend/src/Logitar.Cms.Core/Actors/ActorType.cs
@@ -1,4 +1,4 @@
-namespace Logitar.Cms.Core.Models;
+namespace Logitar.Cms.Core.Actors;
public enum ActorType
{
diff --git a/backend/src/Logitar.Cms.Core/DependencyInjectionExtensions.cs b/backend/src/Logitar.Cms.Core/DependencyInjectionExtensions.cs
new file mode 100644
index 0000000..d846fb1
--- /dev/null
+++ b/backend/src/Logitar.Cms.Core/DependencyInjectionExtensions.cs
@@ -0,0 +1,14 @@
+using Logitar.EventSourcing;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Logitar.Cms.Core;
+
+public static class DependencyInjectionExtensions
+{
+ public static IServiceCollection AddLogitarCmsCore(this IServiceCollection services)
+ {
+ return services
+ .AddLogitarEventSourcing()
+ .AddMediatR(config => config.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()));
+ }
+}
diff --git a/backend/src/Logitar.Cms.Core/Logitar.Cms.Core.csproj b/backend/src/Logitar.Cms.Core/Logitar.Cms.Core.csproj
index 260f946..a117314 100644
--- a/backend/src/Logitar.Cms.Core/Logitar.Cms.Core.csproj
+++ b/backend/src/Logitar.Cms.Core/Logitar.Cms.Core.csproj
@@ -23,6 +23,7 @@
+
diff --git a/backend/src/Logitar.Cms.Core/Models/ActorModel.cs b/backend/src/Logitar.Cms.Core/Models/ActorModel.cs
index 522b801..31ef574 100644
--- a/backend/src/Logitar.Cms.Core/Models/ActorModel.cs
+++ b/backend/src/Logitar.Cms.Core/Models/ActorModel.cs
@@ -1,4 +1,6 @@
-namespace Logitar.Cms.Core.Models;
+using Logitar.Cms.Core.Actors;
+
+namespace Logitar.Cms.Core.Models;
public class ActorModel
{
diff --git a/backend/src/Logitar.Cms.Infrastructure/Actors/ActorService.cs b/backend/src/Logitar.Cms.Infrastructure/Actors/ActorService.cs
new file mode 100644
index 0000000..ac00d95
--- /dev/null
+++ b/backend/src/Logitar.Cms.Infrastructure/Actors/ActorService.cs
@@ -0,0 +1,56 @@
+using Logitar.Cms.Core.Models;
+using Logitar.Cms.Infrastructure.Caching;
+using Logitar.Cms.Infrastructure.Entities;
+using Logitar.EventSourcing;
+using Microsoft.EntityFrameworkCore;
+
+namespace Logitar.Cms.Infrastructure.Actors;
+
+internal class ActorService : IActorService
+{
+ private readonly DbSet _actors;
+ private readonly ICacheService _cacheService;
+
+ public ActorService(ICacheService cacheService, CmsContext context)
+ {
+ _cacheService = cacheService;
+ _actors = context.Actors;
+ }
+
+ public async Task> FindAsync(IEnumerable ids, CancellationToken cancellationToken)
+ {
+ int capacity = ids.Count();
+ Dictionary actors = new(capacity);
+ HashSet missingIds = new(capacity);
+
+ foreach (ActorId id in ids)
+ {
+ ActorModel? actor = _cacheService.GetActor(id);
+ if (actor == null)
+ {
+ missingIds.Add(id.ToGuid());
+ }
+ else
+ {
+ actors[id] = actor;
+ }
+ }
+
+ ActorEntity[] entities = await _actors.AsNoTracking()
+ .Where(actor => missingIds.Contains(actor.Id))
+ .ToArrayAsync(cancellationToken);
+ foreach (ActorEntity entity in entities)
+ {
+ ActorId id = new(entity.Id);
+ ActorModel actor = Mapper.ToActor(entity);
+ actors[id] = actor;
+ }
+
+ foreach (ActorModel actor in actors.Values)
+ {
+ _cacheService.SetActor(actor);
+ }
+
+ return actors.Values;
+ }
+}
diff --git a/backend/src/Logitar.Cms.Infrastructure/Actors/IActorService.cs b/backend/src/Logitar.Cms.Infrastructure/Actors/IActorService.cs
new file mode 100644
index 0000000..fde2729
--- /dev/null
+++ b/backend/src/Logitar.Cms.Infrastructure/Actors/IActorService.cs
@@ -0,0 +1,9 @@
+using Logitar.Cms.Core.Models;
+using Logitar.EventSourcing;
+
+namespace Logitar.Cms.Infrastructure.Actors;
+
+public interface IActorService
+{
+ Task> FindAsync(IEnumerable ids, CancellationToken cancellationToken = default);
+}
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..060d482
--- /dev/null
+++ b/backend/src/Logitar.Cms.Infrastructure/Caching/CacheService.cs
@@ -0,0 +1,31 @@
+using Logitar.Cms.Core.Models;
+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 _cache;
+ private readonly CachingSettings _settings;
+
+ public CacheService(IMemoryCache cache, CachingSettings settings)
+ {
+ _cache = cache;
+ _settings = settings;
+ }
+
+ public ActorModel? GetActor(ActorId id) => TryGetValue(GetActorKey(id));
+ public void SetActor(ActorModel actor)
+ {
+ SetValue(GetActorKey(new ActorId(actor.Id)), actor, _settings.ActorLifetime);
+ }
+ private static string GetActorKey(ActorId id) => $"Actor.Id:{id}";
+
+ private T? TryGetValue(object key) => _cache.TryGetValue(key, out object? value) ? (T?)value : default;
+ void SetValue(object key, T value, TimeSpan duration)
+ {
+ _cache.Set(key, value, duration);
+ }
+}
diff --git a/backend/src/Logitar.Cms.Infrastructure/Caching/ICacheService.cs b/backend/src/Logitar.Cms.Infrastructure/Caching/ICacheService.cs
new file mode 100644
index 0000000..73068d9
--- /dev/null
+++ b/backend/src/Logitar.Cms.Infrastructure/Caching/ICacheService.cs
@@ -0,0 +1,10 @@
+using Logitar.Cms.Core.Models;
+using Logitar.EventSourcing;
+
+namespace Logitar.Cms.Infrastructure.Caching;
+
+public interface ICacheService
+{
+ ActorModel? GetActor(ActorId id);
+ void SetActor(ActorModel actor);
+}
diff --git a/backend/src/Logitar.Cms.Infrastructure/CmsContext.cs b/backend/src/Logitar.Cms.Infrastructure/CmsContext.cs
new file mode 100644
index 0000000..3395898
--- /dev/null
+++ b/backend/src/Logitar.Cms.Infrastructure/CmsContext.cs
@@ -0,0 +1,19 @@
+using Logitar.Cms.Infrastructure.Entities;
+using Microsoft.EntityFrameworkCore;
+
+namespace Logitar.Cms.Infrastructure;
+
+public class CmsContext : DbContext
+{
+ public CmsContext(DbContextOptions options) : base(options)
+ {
+ }
+
+ internal DbSet Actors => Set();
+ internal DbSet Languages => Set();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());
+ }
+}
diff --git a/backend/src/Logitar.Cms.Infrastructure/CmsDb/Actors.cs b/backend/src/Logitar.Cms.Infrastructure/CmsDb/Actors.cs
new file mode 100644
index 0000000..80fd873
--- /dev/null
+++ b/backend/src/Logitar.Cms.Infrastructure/CmsDb/Actors.cs
@@ -0,0 +1,18 @@
+using Logitar.Cms.Infrastructure.Entities;
+using Logitar.Data;
+
+namespace Logitar.Cms.Infrastructure.CmsDb;
+
+public static class Actors
+{
+ public static readonly TableId Table = new(nameof(CmsContext.Actors));
+
+ public static readonly ColumnId ActorId = new(nameof(ActorEntity.ActorId), Table);
+ public static readonly ColumnId DisplayName = new(nameof(ActorEntity.DisplayName), Table);
+ public static readonly ColumnId EmailAddress = new(nameof(ActorEntity.EmailAddress), Table);
+ public static readonly ColumnId Id = new(nameof(ActorEntity.Id), Table);
+ public static readonly ColumnId IdHash = new(nameof(ActorEntity.IdHash), Table);
+ public static readonly ColumnId IsDeleted = new(nameof(ActorEntity.IsDeleted), Table);
+ public static readonly ColumnId PictureUrl = new(nameof(ActorEntity.PictureUrl), Table);
+ public static readonly ColumnId Type = new(nameof(ActorEntity.Type), Table);
+}
diff --git a/backend/src/Logitar.Cms.Infrastructure/CmsDb/Helper.cs b/backend/src/Logitar.Cms.Infrastructure/CmsDb/Helper.cs
new file mode 100644
index 0000000..8c4165f
--- /dev/null
+++ b/backend/src/Logitar.Cms.Infrastructure/CmsDb/Helper.cs
@@ -0,0 +1,6 @@
+namespace Logitar.Cms.Infrastructure.CmsDb;
+
+public static class Helper // TODO(fpion): refactor
+{
+ public static string Normalize(string value) => value.Trim().ToUpperInvariant();
+}
diff --git a/backend/src/Logitar.Cms.Infrastructure/CmsDb/Languages.cs b/backend/src/Logitar.Cms.Infrastructure/CmsDb/Languages.cs
new file mode 100644
index 0000000..b56e439
--- /dev/null
+++ b/backend/src/Logitar.Cms.Infrastructure/CmsDb/Languages.cs
@@ -0,0 +1,26 @@
+using Logitar.Cms.Infrastructure.Entities;
+using Logitar.Data;
+
+namespace Logitar.Cms.Infrastructure.CmsDb;
+
+public static class Languages
+{
+ public static readonly TableId Table = new(nameof(CmsContext.Languages));
+
+ public static readonly ColumnId CreatedBy = new(nameof(LanguageEntity.CreatedBy), Table);
+ public static readonly ColumnId CreatedOn = new(nameof(LanguageEntity.CreatedOn), Table);
+ public static readonly ColumnId StreamId = new(nameof(LanguageEntity.StreamId), 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 Id = new(nameof(LanguageEntity.Id), 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);
+}
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..595be05
--- /dev/null
+++ b/backend/src/Logitar.Cms.Infrastructure/Commands/InitializeDatabaseCommand.cs
@@ -0,0 +1,25 @@
+using Logitar.EventSourcing.EntityFrameworkCore.Relational;
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+
+namespace Logitar.Cms.Infrastructure.Commands;
+
+public record InitializeDatabaseCommand : INotification;
+
+public class InitializeDatabaseCommandHandler : INotificationHandler
+{
+ private readonly EventContext _eventContext;
+ private readonly CmsContext _cmsContext;
+
+ public InitializeDatabaseCommandHandler(EventContext eventContext, CmsContext cmsContext)
+ {
+ _eventContext = eventContext;
+ _cmsContext = cmsContext;
+ }
+
+ public async Task Handle(InitializeDatabaseCommand command, CancellationToken cancellationToken)
+ {
+ await _eventContext.Database.MigrateAsync(cancellationToken);
+ await _cmsContext.Database.MigrateAsync(cancellationToken);
+ }
+}
diff --git a/backend/src/Logitar.Cms.Infrastructure/Configurations/ActorConfiguration.cs b/backend/src/Logitar.Cms.Infrastructure/Configurations/ActorConfiguration.cs
new file mode 100644
index 0000000..630c9a2
--- /dev/null
+++ b/backend/src/Logitar.Cms.Infrastructure/Configurations/ActorConfiguration.cs
@@ -0,0 +1,30 @@
+using Logitar.Cms.Core.Actors;
+using Logitar.Cms.Infrastructure.Entities;
+using Logitar.EventSourcing;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+namespace Logitar.Cms.Infrastructure.Configurations;
+
+internal class ActorConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable(CmsDb.Actors.Table.Table ?? string.Empty, CmsDb.Actors.Table.Schema);
+ builder.HasKey(x => x.ActorId);
+
+ builder.HasIndex(x => x.Id).IsUnique();
+ builder.HasIndex(x => x.IdHash).IsUnique();
+ builder.HasIndex(x => x.Type);
+ builder.HasIndex(x => x.IsDeleted);
+ builder.HasIndex(x => x.DisplayName);
+ builder.HasIndex(x => x.EmailAddress);
+
+ builder.Property(x => x.IdHash).HasMaxLength(ActorId.MaximumLength);
+ builder.Property(x => x.Type).HasMaxLength(byte.MaxValue).HasConversion(new EnumToStringConverter());
+ builder.Property(x => x.DisplayName).HasMaxLength(byte.MaxValue); // TODO(fpion): use constant
+ builder.Property(x => x.EmailAddress).HasMaxLength(byte.MaxValue); // TODO(fpion): use constant
+ builder.Property(x => x.PictureUrl).HasMaxLength(2048); // TODO(fpion): use constant
+ }
+}
diff --git a/backend/src/Logitar.Cms.Infrastructure/Configurations/AggregateConfiguration.cs b/backend/src/Logitar.Cms.Infrastructure/Configurations/AggregateConfiguration.cs
new file mode 100644
index 0000000..aea9448
--- /dev/null
+++ b/backend/src/Logitar.Cms.Infrastructure/Configurations/AggregateConfiguration.cs
@@ -0,0 +1,22 @@
+using Logitar.Cms.Infrastructure.Entities;
+using Logitar.EventSourcing;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace Logitar.Cms.Infrastructure.Configurations;
+
+internal abstract class AggregateConfiguration where T : AggregateEntity // TODO(fpion): refactor
+{
+ public virtual void Configure(EntityTypeBuilder builder)
+ {
+ builder.HasIndex(x => x.StreamId).IsUnique();
+ builder.HasIndex(x => x.Version);
+ builder.HasIndex(x => x.CreatedBy);
+ builder.HasIndex(x => x.CreatedOn);
+ builder.HasIndex(x => x.UpdatedBy);
+ builder.HasIndex(x => x.UpdatedOn);
+
+ builder.Property(x => x.StreamId).HasMaxLength(StreamId.MaximumLength);
+ builder.Property(x => x.CreatedBy).HasMaxLength(ActorId.MaximumLength);
+ builder.Property(x => x.UpdatedBy).HasMaxLength(ActorId.MaximumLength);
+ }
+}
diff --git a/backend/src/Logitar.Cms.Infrastructure/Configurations/LanguageConfiguration.cs b/backend/src/Logitar.Cms.Infrastructure/Configurations/LanguageConfiguration.cs
new file mode 100644
index 0000000..f59d071
--- /dev/null
+++ b/backend/src/Logitar.Cms.Infrastructure/Configurations/LanguageConfiguration.cs
@@ -0,0 +1,25 @@
+using Logitar.Cms.Infrastructure.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace Logitar.Cms.Infrastructure.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.Id).IsUnique();
+ builder.HasIndex(x => x.IsDefault);
+ builder.HasIndex(x => x.LCID);
+ 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);
+ }
+}
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..f73cd50
--- /dev/null
+++ b/backend/src/Logitar.Cms.Infrastructure/Converters/LanguageIdConverter.cs
@@ -0,0 +1,17 @@
+using Logitar.Cms.Core.Localization;
+
+namespace Logitar.Cms.Infrastructure.Converters;
+
+internal class LanguageIdConverter : JsonConverter
+{
+ public override LanguageId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ string? value = reader.GetString();
+ return string.IsNullOrWhiteSpace(value) ? new LanguageId() : new(value);
+ }
+
+ public override void Write(Utf8JsonWriter writer, LanguageId languageId, JsonSerializerOptions options)
+ {
+ writer.WriteStringValue(languageId.Value);
+ }
+}
diff --git a/backend/src/Logitar.Cms.Infrastructure/Converters/LocaleConverter.cs b/backend/src/Logitar.Cms.Infrastructure/Converters/LocaleConverter.cs
new file mode 100644
index 0000000..6fc48d9
--- /dev/null
+++ b/backend/src/Logitar.Cms.Infrastructure/Converters/LocaleConverter.cs
@@ -0,0 +1,17 @@
+using Logitar.Cms.Core.Localization;
+
+namespace Logitar.Cms.Infrastructure.Converters;
+
+internal class LocaleConverter : JsonConverter
+{
+ public override Locale? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ string? value = reader.GetString();
+ return string.IsNullOrWhiteSpace(value) ? null : new Locale(value);
+ }
+
+ public override void Write(Utf8JsonWriter writer, Locale locale, JsonSerializerOptions options)
+ {
+ writer.WriteStringValue(locale.Value);
+ }
+}
diff --git a/backend/src/Logitar.Cms.Infrastructure/DependencyInjectionExtensions.cs b/backend/src/Logitar.Cms.Infrastructure/DependencyInjectionExtensions.cs
new file mode 100644
index 0000000..05f66f8
--- /dev/null
+++ b/backend/src/Logitar.Cms.Infrastructure/DependencyInjectionExtensions.cs
@@ -0,0 +1,48 @@
+using Logitar.Cms.Core;
+using Logitar.Cms.Core.Localization;
+using Logitar.Cms.Infrastructure.Actors;
+using Logitar.Cms.Infrastructure.Caching;
+using Logitar.Cms.Infrastructure.Queriers;
+using Logitar.Cms.Infrastructure.Repositories;
+using Logitar.Cms.Infrastructure.Settings;
+using Logitar.EventSourcing.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
+ .AddLogitarCmsCore()
+ .AddMediatR(config => config.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()))
+ .AddMemoryCache()
+ .AddSingleton(InitializeCachingSettings)
+ .AddSingleton()
+ .AddSingleton()
+ .AddScoped()
+ .AddScoped()
+ .AddQueriers()
+ .AddRepositories();
+ }
+
+ private static CachingSettings InitializeCachingSettings(IServiceProvider serviceProvider)
+ {
+ IConfiguration configuration = serviceProvider.GetRequiredService();
+ return configuration.GetSection(CachingSettings.SectionKey).Get() ?? new();
+ }
+
+ private static IServiceCollection AddQueriers(this IServiceCollection services)
+ {
+ return services
+ .AddScoped();
+ }
+
+ private static IServiceCollection AddRepositories(this IServiceCollection services)
+ {
+ return services
+ .AddScoped();
+ }
+}
diff --git a/backend/src/Logitar.Cms.Infrastructure/Entities/ActorEntity.cs b/backend/src/Logitar.Cms.Infrastructure/Entities/ActorEntity.cs
new file mode 100644
index 0000000..66e4753
--- /dev/null
+++ b/backend/src/Logitar.Cms.Infrastructure/Entities/ActorEntity.cs
@@ -0,0 +1,25 @@
+using Logitar.Cms.Core.Actors;
+
+namespace Logitar.Cms.Infrastructure.Entities;
+
+internal class ActorEntity
+{
+ public int ActorId { get; private set; }
+ public Guid Id { get; private set; }
+ public string IdHash { get; private set; } = string.Empty;
+
+ public ActorType Type { get; private set; }
+ public bool IsDeleted { get; private set; }
+
+ public string DisplayName { get; private set; } = string.Empty;
+ public string? EmailAddress { get; private set; }
+ public string? PictureUrl { get; private set; }
+
+ private ActorEntity()
+ {
+ }
+
+ public override bool Equals(object? obj) => obj is ActorEntity actor && actor.Id == Id;
+ public override int GetHashCode() => Id.GetHashCode();
+ public override string ToString() => $"{GetType()} (Id={Id})";
+}
diff --git a/backend/src/Logitar.Cms.Infrastructure/Entities/AggregateEntity.cs b/backend/src/Logitar.Cms.Infrastructure/Entities/AggregateEntity.cs
new file mode 100644
index 0000000..a07b8c6
--- /dev/null
+++ b/backend/src/Logitar.Cms.Infrastructure/Entities/AggregateEntity.cs
@@ -0,0 +1,55 @@
+using Logitar.EventSourcing;
+
+namespace Logitar.Cms.Infrastructure.Entities;
+
+internal abstract class AggregateEntity // TODO(fpion): refactor
+{
+ public string StreamId { get; private set; } = string.Empty;
+ public long Version { get; private set; }
+
+ public string? CreatedBy { get; private set; }
+ public DateTime CreatedOn { get; private set; }
+
+ public string? UpdatedBy { get; private set; }
+ public DateTime UpdatedOn { get; private set; }
+
+ protected AggregateEntity()
+ {
+ }
+
+ protected AggregateEntity(DomainEvent @event)
+ {
+ StreamId = @event.StreamId.Value;
+
+ CreatedBy = @event.ActorId?.Value;
+ CreatedOn = @event.OccurredOn.AsUniversalTime();
+
+ Update(@event);
+ }
+
+ public virtual IReadOnlyCollection GetActorIds()
+ {
+ List actorIds = new(capacity: 2);
+ if (CreatedBy != null)
+ {
+ actorIds.Add(new(CreatedBy));
+ }
+ if (UpdatedBy != null)
+ {
+ actorIds.Add(new(UpdatedBy));
+ }
+ return actorIds.AsReadOnly();
+ }
+
+ protected virtual void Update(DomainEvent @event)
+ {
+ Version = @event.Version;
+
+ UpdatedBy = @event.ActorId?.Value;
+ UpdatedOn = @event.OccurredOn.AsUniversalTime();
+ }
+
+ public override bool Equals(object? obj) => obj is AggregateEntity aggregate && aggregate.StreamId == StreamId;
+ public override int GetHashCode() => StreamId.GetHashCode();
+ public override string ToString() => $"{GetType()} (StreamId={StreamId})";
+}
diff --git a/backend/src/Logitar.Cms.Infrastructure/Entities/LanguageEntity.cs b/backend/src/Logitar.Cms.Infrastructure/Entities/LanguageEntity.cs
new file mode 100644
index 0000000..3e9a36a
--- /dev/null
+++ b/backend/src/Logitar.Cms.Infrastructure/Entities/LanguageEntity.cs
@@ -0,0 +1,63 @@
+using Logitar.Cms.Core.Localization;
+using Logitar.Cms.Core.Localization.Events;
+
+namespace Logitar.Cms.Infrastructure.Entities;
+
+internal class LanguageEntity : AggregateEntity
+{
+ public int LanguageId { get; private set; }
+ public Guid Id { 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.Helper.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(LanguageCreated @event) : base(@event)
+ {
+ Id = @event.StreamId.ToGuid();
+
+ IsDefault = @event.IsDefault;
+
+ SetLocale(@event.Locale);
+ }
+
+ private LanguageEntity() : base()
+ {
+ }
+
+ public void SetDefault(LanguageSetDefault @event)
+ {
+ base.Update(@event);
+
+ IsDefault = @event.IsDefault;
+ }
+
+ public void Update(LanguageUpdated @event)
+ {
+ base.Update(@event);
+
+ if (@event.Locale != null)
+ {
+ SetLocale(@event.Locale);
+ }
+ }
+
+ private void SetLocale(Locale locale)
+ {
+ CultureInfo culture = locale.Culture;
+ LCID = culture.LCID;
+ Code = culture.Name;
+ DisplayName = culture.DisplayName;
+ EnglishName = culture.EnglishName;
+ NativeName = culture.NativeName;
+ }
+}
diff --git a/backend/src/Logitar.Cms.Infrastructure/EventBus.cs b/backend/src/Logitar.Cms.Infrastructure/EventBus.cs
new file mode 100644
index 0000000..3f25d8f
--- /dev/null
+++ b/backend/src/Logitar.Cms.Infrastructure/EventBus.cs
@@ -0,0 +1,20 @@
+using Logitar.EventSourcing;
+using Logitar.EventSourcing.Infrastructure;
+using MediatR;
+
+namespace Logitar.Cms.Infrastructure;
+
+internal class EventBus : IEventBus
+{
+ private readonly IMediator _mediator;
+
+ public EventBus(IMediator mediator)
+ {
+ _mediator = mediator;
+ }
+
+ public async Task PublishAsync(IEvent @event, CancellationToken cancellationToken)
+ {
+ await _mediator.Publish(@event, cancellationToken);
+ }
+}
diff --git a/backend/src/Logitar.Cms.Infrastructure/EventSerializer.cs b/backend/src/Logitar.Cms.Infrastructure/EventSerializer.cs
new file mode 100644
index 0000000..6f1aa5c
--- /dev/null
+++ b/backend/src/Logitar.Cms.Infrastructure/EventSerializer.cs
@@ -0,0 +1,14 @@
+using Logitar.Cms.Infrastructure.Converters;
+
+namespace Logitar.Cms.Infrastructure;
+
+internal class EventSerializer : EventSourcing.Infrastructure.EventSerializer
+{
+ protected override void RegisterConverters()
+ {
+ base.RegisterConverters();
+
+ SerializerOptions.Converters.Add(new LanguageIdConverter());
+ SerializerOptions.Converters.Add(new LocaleConverter());
+ }
+}
diff --git a/backend/src/Logitar.Cms.Infrastructure/Handlers/LanguageHandlers.cs b/backend/src/Logitar.Cms.Infrastructure/Handlers/LanguageHandlers.cs
new file mode 100644
index 0000000..bfcf8b5
--- /dev/null
+++ b/backend/src/Logitar.Cms.Infrastructure/Handlers/LanguageHandlers.cs
@@ -0,0 +1,52 @@
+using Logitar.Cms.Core.Localization.Events;
+using Logitar.Cms.Infrastructure.Entities;
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+
+namespace Logitar.Cms.Infrastructure.Handlers;
+
+internal class LanguageHandlers : INotificationHandler, INotificationHandler, INotificationHandler
+{
+ private readonly CmsContext _context;
+
+ public LanguageHandlers(CmsContext context)
+ {
+ _context = context;
+ }
+
+ public async Task Handle(LanguageCreated @event, CancellationToken cancellationToken)
+ {
+ LanguageEntity? language = await _context.Languages.AsNoTracking()
+ .SingleOrDefaultAsync(x => x.StreamId == @event.StreamId.Value, cancellationToken);
+ if (language == null)
+ {
+ language = new(@event);
+
+ _context.Languages.Add(language);
+
+ await _context.SaveChangesAsync(cancellationToken);
+ }
+ }
+
+ public async Task Handle(LanguageSetDefault @event, CancellationToken cancellationToken)
+ {
+ LanguageEntity language = await _context.Languages
+ .SingleOrDefaultAsync(x => x.StreamId == @event.StreamId.Value, cancellationToken)
+ ?? throw new InvalidOperationException($"The language entity 'StreamId={@event.StreamId}' could not be found.");
+
+ language.SetDefault(@event);
+
+ await _context.SaveChangesAsync(cancellationToken);
+ }
+
+ public async Task Handle(LanguageUpdated @event, CancellationToken cancellationToken)
+ {
+ LanguageEntity language = await _context.Languages
+ .SingleOrDefaultAsync(x => x.StreamId == @event.StreamId.Value, cancellationToken)
+ ?? throw new InvalidOperationException($"The language entity 'StreamId={@event.StreamId}' could not be found.");
+
+ language.Update(@event);
+
+ await _context.SaveChangesAsync(cancellationToken);
+ }
+}
diff --git a/backend/src/Logitar.Cms.Infrastructure/Logitar.Cms.Infrastructure.csproj b/backend/src/Logitar.Cms.Infrastructure/Logitar.Cms.Infrastructure.csproj
new file mode 100644
index 0000000..1a56011
--- /dev/null
+++ b/backend/src/Logitar.Cms.Infrastructure/Logitar.Cms.Infrastructure.csproj
@@ -0,0 +1,33 @@
+
+
+
+ net9.0
+ enable
+ enable
+
+
+
+ True
+
+
+
+ True
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/src/Logitar.Cms.Infrastructure/Mapper.cs b/backend/src/Logitar.Cms.Infrastructure/Mapper.cs
new file mode 100644
index 0000000..5a59c97
--- /dev/null
+++ b/backend/src/Logitar.Cms.Infrastructure/Mapper.cs
@@ -0,0 +1,78 @@
+using Logitar.Cms.Core.Localization.Models;
+using Logitar.Cms.Core.Models;
+using Logitar.Cms.Infrastructure.Entities;
+using Logitar.EventSourcing;
+
+namespace Logitar.Cms.Infrastructure;
+
+internal class Mapper
+{
+ private readonly Dictionary _actors = [];
+ private readonly ActorModel _system = ActorModel.System;
+
+ public Mapper()
+ {
+ }
+
+ public Mapper(IEnumerable actors)
+ {
+ foreach (ActorModel actor in actors)
+ {
+ ActorId actorId = new(actor.Id);
+ _actors[actorId] = actor;
+ }
+ }
+
+ public static ActorModel ToActor(ActorEntity actor) => new(actor.DisplayName)
+ {
+ Id = actor.Id,
+ Type = actor.Type,
+ IsDeleted = actor.IsDeleted,
+ EmailAddress = actor.EmailAddress,
+ PictureUrl = actor.PictureUrl
+ };
+
+ public LanguageModel ToLanguage(LanguageEntity source)
+ {
+ LanguageModel destination = new()
+ {
+ IsDefault = source.IsDefault,
+ Locale = new LocaleModel(source.Code)
+ };
+
+ MapAggregate(source, destination);
+
+ return destination;
+ }
+
+ private void MapAggregate(AggregateEntity source, AggregateModel destination)
+ {
+ try
+ {
+ destination.Id = new StreamId(source.StreamId).ToGuid();
+ }
+ catch (Exception)
+ {
+ }
+ 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 ActorModel FindActor(string? id)
+ {
+ if (id != null)
+ {
+ ActorId actorId = new(id);
+ if (_actors.TryGetValue(actorId, out ActorModel? actor))
+ {
+ return actor;
+ }
+ }
+
+ return _system;
+ }
+}
diff --git a/backend/src/Logitar.Cms.Infrastructure/Queriers/LanguageQuerier.cs b/backend/src/Logitar.Cms.Infrastructure/Queriers/LanguageQuerier.cs
new file mode 100644
index 0000000..23abd96
--- /dev/null
+++ b/backend/src/Logitar.Cms.Infrastructure/Queriers/LanguageQuerier.cs
@@ -0,0 +1,91 @@
+using Logitar.Cms.Core.Localization;
+using Logitar.Cms.Core.Localization.Models;
+using Logitar.Cms.Core.Models;
+using Logitar.Cms.Infrastructure.Actors;
+using Logitar.Cms.Infrastructure.Entities;
+using Logitar.EventSourcing;
+using Microsoft.EntityFrameworkCore;
+
+namespace Logitar.Cms.Infrastructure.Queriers;
+
+internal class LanguageQuerier : ILanguageQuerier
+{
+ private readonly IActorService _actorService;
+ private readonly DbSet _languages;
+
+ public LanguageQuerier(IActorService actorService, CmsContext context)
+ {
+ _actorService = actorService;
+ _languages = context.Languages;
+ }
+
+ public async Task FindDefaultIdAsync(CancellationToken cancellationToken)
+ {
+ string streamId = await _languages.AsNoTracking()
+ .Where(x => x.IsDefault)
+ .Select(x => x.StreamId)
+ .SingleOrDefaultAsync(cancellationToken)
+ ?? throw new InvalidOperationException("The default language entity could not be found.");
+
+ return new LanguageId(streamId);
+ }
+ public async Task FindIdAsync(Locale locale, CancellationToken cancellationToken)
+ {
+ string codeNormalized = CmsDb.Helper.Normalize(locale.Value);
+
+ string? streamId = await _languages.AsNoTracking()
+ .Where(x => x.CodeNormalized == codeNormalized)
+ .Select(x => x.StreamId)
+ .SingleOrDefaultAsync(cancellationToken);
+
+ return streamId == null ? null : new LanguageId(streamId);
+ }
+
+ public async Task ReadAsync(Language language, CancellationToken cancellationToken)
+ {
+ return await ReadAsync(language.Id, cancellationToken)
+ ?? throw new InvalidOperationException($"The language 'StreamId={language.Id}' could not be found.");
+ }
+ public async Task ReadAsync(LanguageId id, CancellationToken cancellationToken)
+ {
+ return await ReadAsync(id.ToGuid(), cancellationToken);
+ }
+ public async Task ReadAsync(Guid id, CancellationToken cancellationToken)
+ {
+ LanguageEntity? language = await _languages.AsNoTracking()
+ .SingleOrDefaultAsync(x => x.Id == id, cancellationToken);
+
+ return language == null ? null : await MapAsync(language, cancellationToken);
+ }
+ public async Task ReadAsync(string locale, CancellationToken cancellationToken)
+ {
+ string codeNormalized = CmsDb.Helper.Normalize(locale);
+
+ LanguageEntity? language = await _languages.AsNoTracking()
+ .SingleOrDefaultAsync(x => x.CodeNormalized == codeNormalized, cancellationToken);
+
+ return language == null ? null : await MapAsync(language, cancellationToken);
+ }
+
+ public async Task ReadDefaultAsync(CancellationToken cancellationToken)
+ {
+ LanguageEntity language = await _languages.AsNoTracking()
+ .SingleOrDefaultAsync(x => x.IsDefault, cancellationToken)
+ ?? throw new InvalidOperationException("The default language entity could not be found.");
+
+ return await MapAsync(language, cancellationToken);
+ }
+
+ private async Task MapAsync(LanguageEntity language, CancellationToken cancellationToken)
+ {
+ return (await MapAsync([language], cancellationToken)).Single();
+ }
+ private async Task> MapAsync(IEnumerable languages, CancellationToken cancellationToken)
+ {
+ IEnumerable actorIds = languages.SelectMany(language => language.GetActorIds());
+ IReadOnlyCollection actors = await _actorService.FindAsync(actorIds, cancellationToken);
+ Mapper mapper = new(actors);
+
+ return languages.Select(mapper.ToLanguage).ToArray();
+ }
+}
diff --git a/backend/src/Logitar.Cms.Infrastructure/Repositories/LanguageRepository.cs b/backend/src/Logitar.Cms.Infrastructure/Repositories/LanguageRepository.cs
new file mode 100644
index 0000000..b3c4f9a
--- /dev/null
+++ b/backend/src/Logitar.Cms.Infrastructure/Repositories/LanguageRepository.cs
@@ -0,0 +1,30 @@
+using Logitar.Cms.Core.Localization;
+using Logitar.EventSourcing;
+
+namespace Logitar.Cms.Infrastructure.Repositories;
+
+internal class LanguageRepository : Repository, ILanguageRepository
+{
+ public LanguageRepository(IEventStore eventStore) : base(eventStore)
+ {
+ }
+
+ public async Task LoadAsync(LanguageId id, CancellationToken cancellationToken)
+ {
+ return await LoadAsync(id, version: null, cancellationToken);
+ }
+ public async Task LoadAsync(LanguageId id, long? version, CancellationToken cancellationToken)
+ {
+ return await LoadAsync(id.StreamId, version, cancellationToken);
+ }
+
+ public async Task SaveAsync(Language 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/Settings/CachingSettings.cs b/backend/src/Logitar.Cms.Infrastructure/Settings/CachingSettings.cs
new file mode 100644
index 0000000..3a394a5
--- /dev/null
+++ b/backend/src/Logitar.Cms.Infrastructure/Settings/CachingSettings.cs
@@ -0,0 +1,8 @@
+namespace Logitar.Cms.Infrastructure.Settings;
+
+internal record CachingSettings
+{
+ public const string SectionKey = "Caching";
+
+ public TimeSpan ActorLifetime { get; set; } = TimeSpan.FromMinutes(15);
+}
diff --git a/backend/src/Logitar.Cms.Web/Logitar.Cms.Web.csproj b/backend/src/Logitar.Cms.Web/Logitar.Cms.Web.csproj
index 780e96a..c76e2af 100644
--- a/backend/src/Logitar.Cms.Web/Logitar.Cms.Web.csproj
+++ b/backend/src/Logitar.Cms.Web/Logitar.Cms.Web.csproj
@@ -15,7 +15,7 @@
-
+