diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fe1152b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,30 @@ +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md +!**/.gitignore +!.git/HEAD +!.git/config +!.git/packed-refs +!.git/refs/heads/** \ No newline at end of file diff --git a/EventJournal.CLI/CLIUtilities.cs b/EventJournal.CLI/CLIUtilities.cs new file mode 100644 index 0000000..d4270aa --- /dev/null +++ b/EventJournal.CLI/CLIUtilities.cs @@ -0,0 +1,50 @@ +namespace EventJournal.CLI { + public static class CLIUtilities { + public static string GetStringFromUser(string prompt) { + string? userInput = null; + + while (string.IsNullOrWhiteSpace(userInput)) { + Console.Write(prompt); + userInput = Console.ReadLine(); + } + return userInput; + } + + public static int GetIntFromUser(string prompt) { + var intFromUser = 0; + + var isInvalidInput = true; + while (isInvalidInput) { + Console.Write(prompt); + isInvalidInput = !int.TryParse(Console.ReadLine(), + System.Globalization.NumberStyles.AllowLeadingWhite + | System.Globalization.NumberStyles.AllowTrailingWhite, + System.Globalization.CultureInfo.CurrentCulture, + out intFromUser); + if (isInvalidInput) { + Console.WriteLine("Must be an integer."); + } + } + + return intFromUser; + } + + public static decimal GetDecimalFromUser(string prompt) { + var decimalFromUser = 0.0M; + var isInvalidInput = true; + while (isInvalidInput) { + Console.Write(prompt); + isInvalidInput = !decimal.TryParse(Console.ReadLine(), + System.Globalization.NumberStyles.AllowLeadingWhite + | System.Globalization.NumberStyles.AllowTrailingWhite + | System.Globalization.NumberStyles.Currency, + System.Globalization.CultureInfo.CurrentCulture, + out decimalFromUser); + if (isInvalidInput) { + Console.WriteLine("Must be a number."); + } + } + return decimalFromUser; + } + } +} diff --git a/EventJournal.CLI/DesignTimeDbContextFactory.cs b/EventJournal.CLI/DesignTimeDbContextFactory.cs new file mode 100644 index 0000000..c8e585f --- /dev/null +++ b/EventJournal.CLI/DesignTimeDbContextFactory.cs @@ -0,0 +1,24 @@ +using EventJournal.Common.Configuration; +using EventJournal.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.Extensions.Configuration; + +namespace EventJournal.CLI { + public class DesignTimeDbContextFactory : IDesignTimeDbContextFactory { + public DatabaseContext CreateDbContext(string[] args) { + IConfiguration configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json") + .Build(); + + var dbSettings = configuration.GetSection(DatabaseSettings.ConfigurationSectionName).Get() + ?? throw new InvalidOperationException("Configuration settings does not contain a valid DatabaseSettings section."); + + var builder = new DbContextOptionsBuilder(); + DatabaseContext.ConfigureFromSettings(builder, dbSettings); + + return new DatabaseContext(builder.Options); + } + } +} diff --git a/EventJournal.CLI/EventJournal.CLI.csproj b/EventJournal.CLI/EventJournal.CLI.csproj new file mode 100644 index 0000000..1f42fea --- /dev/null +++ b/EventJournal.CLI/EventJournal.CLI.csproj @@ -0,0 +1,39 @@ + + + + Exe + net9.0 + EventJournal.CLI + enable + enable + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + PreserveNewest + + + diff --git a/EventJournal.CLI/Program.cs b/EventJournal.CLI/Program.cs new file mode 100644 index 0000000..d5f4cce --- /dev/null +++ b/EventJournal.CLI/Program.cs @@ -0,0 +1,119 @@ +// See https://aka.ms/new-console-template for more information +using EventJournal.BootStrap; +using EventJournal.CLI; +using EventJournal.Common.Bootstrap; +using EventJournal.DomainService; +using EventJournal.PublicModels; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using System.Text.Json; +internal class Program { + private static async Task Main(string[] args) { + + var services = CreateServiceCollection(); + var eventService = services.GetService() ?? throw new Exception("Unable to locate a valid Event Service"); + var userTypeService = services.GetService() ?? throw new Exception("Unable to locate a valid User Types Service"); + var defaultDataProvider = services.GetService() ?? throw new Exception("Unable to locate a valid Default Data Provider"); + var jsonSerializerOptions = services.GetService() ?? throw new Exception("Unable to locate a valid Json Serializer Options"); + bool userIsDone = false; + while (!userIsDone) { + //Console.WriteLine("Type '1' to "); + //Console.WriteLine("Type '2' to "); + //Console.WriteLine("Type '3' to "); + //Console.WriteLine("Type '4' to "); + //Console.WriteLine("Type '6' to "); + //Console.WriteLine("Type '7' to "); + //Console.WriteLine("Type '8' to "); + + Console.WriteLine("Type 'v' to view all data"); + Console.WriteLine("Type 't' to add default test data."); + Console.WriteLine("Type 'x' to delete all data."); + Console.WriteLine("Type 'q' to quit."); + + // application will block here waiting for user to press + var userInput = CLIUtilities.GetStringFromUser("===> ").ToLower() ?? ""; + + switch (userInput[0]) { + case 'q': + userIsDone = true; + break; + // case '1': + // await AddUpdateEntity(GetEntityFromUser()).ConfigureAwait(false); + // break; + // case '2': + // await ViewProduct().ConfigureAwait(false); + // break; + // case '3': + // await ViewInStockProducts().ConfigureAwait(false); + // break; + // case '4': + // await ViewAllProduct().ConfigureAwait(false); + // break; + // case '5': + + // break; + // case '6': + // await AddUpdateEntity(GetEntityFromUser()).ConfigureAwait(false); + // break; + // case '7': + // await ViewOrder().ConfigureAwait(false); + // break; + //case '8': + // break; + //case '9': + // break; + case 'v': + await ViewallDataAsync(jsonSerializerOptions, eventService, userTypeService).ConfigureAwait(false); + break; + case 't': + await AddTestDataAsync(defaultDataProvider).ConfigureAwait(false); + break; + case 'x': + await DeleteAllDataAsync(eventService, userTypeService).ConfigureAwait(false); + break; + } + Console.WriteLine("\n=================================================\n"); + } + } + + private static ServiceProvider CreateServiceCollection() { + IConfiguration Configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) + .Build(); + + return new ServiceCollection() + // setup and register bootstrapper and it's installers -- needs to be last + .AddBootStrapper(Configuration, o => { + // Add any application specific installers here. + }).BuildServiceProvider(); + } + + static Task AddTestDataAsync(IDefaultDataProvider defaultDataProvider) { + Console.WriteLine("Adding/Resetting test data."); + return defaultDataProvider.AddResetDefaultDataAsync(); + } + static async Task DeleteAllDataAsync(IEventService eventService, IUserTypesService userTypeService) { + + Console.WriteLine("Deleting all data."); + foreach (var e in await eventService.GetAllEventsAsync().ConfigureAwait(false)) { + await eventService.DeleteEventAsync(e.ResourceId).ConfigureAwait(false); + } + foreach (var e in await userTypeService.GetAllEventTypesAsync().ConfigureAwait(false)) { + await userTypeService.DeleteEventTypeAsync(e.ResourceId).ConfigureAwait(false); + } + foreach (var e in await userTypeService.GetAllDetailTypesAsync().ConfigureAwait(false)) { + await userTypeService.DeleteDetailTypeAsync(e.ResourceId).ConfigureAwait(false); + } + } + + static async Task ViewallDataAsync(JsonSerializerOptions options, IEventService eventService, IUserTypesService userTypeService) { + EventDataResponseModel eventData = new() { + DetailTypes = await userTypeService.GetAllDetailTypesAsync().ConfigureAwait(false), + EventTypes = await userTypeService.GetAllEventTypesAsync().ConfigureAwait(false), + Events = await eventService.GetAllEventsAsync().ConfigureAwait(false) + }; + + Console.WriteLine(JsonSerializer.Serialize(eventData, options)); + } +} \ No newline at end of file diff --git a/EventJournal.CLI/appsettings.json b/EventJournal.CLI/appsettings.json new file mode 100644 index 0000000..40e4608 --- /dev/null +++ b/EventJournal.CLI/appsettings.json @@ -0,0 +1,14 @@ +{ + // Configuration settings for the EventJournal.CLI application + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "Database": { + //"ConnectionString": "Server=myServer;Database=myDatabase;User ID=myUser;Password=myPassword;", + "ConnectionString": "Default", + "Provider": "Sqlite" + } +} \ No newline at end of file diff --git a/EventJournal.Common/Bootstrap/BootStrapper.cs b/EventJournal.Common/Bootstrap/BootStrapper.cs new file mode 100644 index 0000000..0cdcace --- /dev/null +++ b/EventJournal.Common/Bootstrap/BootStrapper.cs @@ -0,0 +1,104 @@ +using EventJournal.Common.IOC; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace EventJournal.Common.Bootstrap { + public class BootStrapper { + protected IList installers; + + public BootStrapper() { + installers = []; + } + + public virtual void AddInstaller(IInstaller installer) { + ArgumentNullException.ThrowIfNull(installer, nameof(installer)); + installers.Add(installer); + } + /// + /// Installs all of the specified installers, overriding the internal list of installers. + /// + /// A list of installers to register with the IoC. + public virtual IServiceProvider InitIoCContainer(params IInstaller[] installers) { + if (installers == null) { + throw new ArgumentNullException(nameof(installers), "Installers cannot be null"); + } + IServiceProvider container = InternalInitialize(installers); + return container; + } + + /// + /// Installs all the internally specified installers, while adding the [applicationInstaller] + /// + /// The additional installer for the root level application. + public virtual IServiceProvider InitIoCContainer(IInstaller applicationInstaller) { + if (applicationInstaller == null) { + throw new ArgumentNullException(nameof(applicationInstaller), "Application installer cannot be null"); + } + installers.Add(applicationInstaller); + return InternalInitialize([.. installers]); + } + + public virtual IServiceProvider InitIoCContainer() { + return InternalInitialize([.. installers]); + } + + public virtual IServiceProvider InitIoCContainer(IServiceCollection services) { + if (services == null) { + throw new ArgumentNullException(nameof(services), "Service collection cannot be null"); + } + return InternalInitialize(services, [.. installers]); + } + + public virtual IServiceProvider InitIoCContainer(IConfigurationBuilder config, IServiceCollection services) { + if (config == null) { + throw new ArgumentNullException(nameof(config), "Configuration builder cannot be null"); + } + return InternalInitialize(config, services, [.. installers]); + } + public virtual IServiceProvider InitIoCContainer(IConfiguration configuration, IServiceCollection services) { + if (configuration == null) { + throw new ArgumentNullException(nameof(configuration), "Configuration cannot be null"); + } + if (services == null) { + throw new ArgumentNullException(nameof(services), "Service collection cannot be null"); + } + return InternalInitialize(configuration, services, [.. installers]); + } + + protected internal virtual IServiceProvider InternalInitialize(IInstaller[] installers) { + var services = new ServiceCollection().AddOptions(); + return InternalInitialize(services, installers); + } + + protected internal virtual IServiceProvider InternalInitialize(IConfigurationBuilder config, IInstaller[] installers) { + var services = new ServiceCollection().AddOptions(); + return InternalInitialize(config, services, installers); + } + + protected internal virtual IServiceProvider InternalInitialize(IServiceCollection services, IInstaller[] installers) { + var configuration = new ConfigurationBuilder() + .AddJsonFile("appsettings.json"); + return InternalInitialize(configuration, services, installers); + } + + protected internal virtual IServiceProvider InternalInitialize(IConfigurationBuilder config, IServiceCollection services, IInstaller[] installers) { + var configuration = config.Build(); + return InternalInitialize(configuration, services, installers); + } + + protected internal virtual IServiceProvider InternalInitialize(IConfiguration configuration, IServiceCollection services, IInstaller[] installers) { + DI.SetConfiguration(configuration); + + foreach (var i in installers) { + i.Install(services, configuration); + } + + services.AddSingleton(configuration); + var serviceProvider = services.BuildServiceProvider(); + + DI.SetContainer(serviceProvider); + return serviceProvider; + } + } + +} diff --git a/EventJournal.Common/Bootstrap/BootStrapperOptions.cs b/EventJournal.Common/Bootstrap/BootStrapperOptions.cs new file mode 100644 index 0000000..3dc7fc1 --- /dev/null +++ b/EventJournal.Common/Bootstrap/BootStrapperOptions.cs @@ -0,0 +1,19 @@ +using System.Collections.ObjectModel; + +namespace EventJournal.Common.Bootstrap { + public class BootStrapperOptions { + protected List installers; + + public BootStrapperOptions() { + installers = []; + } + + public void AddInstaller(IInstaller installer) { + installers.Add(installer); + } + + public ReadOnlyCollection Installers { + get { return installers.AsReadOnly(); } + } + } +} diff --git a/EventJournal.Common/Bootstrap/IInstaller.cs b/EventJournal.Common/Bootstrap/IInstaller.cs new file mode 100644 index 0000000..d291045 --- /dev/null +++ b/EventJournal.Common/Bootstrap/IInstaller.cs @@ -0,0 +1,8 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace EventJournal.Common.Bootstrap { + public interface IInstaller { + void Install(IServiceCollection services, IConfiguration configuration); + } +} diff --git a/EventJournal.Common/Bootstrap/ServiceCollectionExtensions.cs b/EventJournal.Common/Bootstrap/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..ab459ce --- /dev/null +++ b/EventJournal.Common/Bootstrap/ServiceCollectionExtensions.cs @@ -0,0 +1,67 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using System.Reflection; + +namespace EventJournal.Common.Bootstrap { + public static class ServiceCollectionExtensions { + /// + /// Add scoped classes with specified suffix from assembly that contains T + /// + /// + /// + /// + /// + public static IServiceCollection AddScopedInterfacesBySuffix(this IServiceCollection services, string suffix) where T : class { + typeof(T).GetTypeInfo().Assembly.GetTypes() + .Where(x => x.Name.EndsWith(suffix, StringComparison.InvariantCulture) + && x.GetTypeInfo().IsClass + && !x.GetTypeInfo().IsAbstract + && x.GetInterfaces().Length > 0) + .ToList().ForEach(x => { + x.GetInterfaces().ToList() + .ForEach(i => services.AddScoped(i, x)); + }); + + return services; + } + + public static IServiceCollection AddSingletonClassesBySuffix(this IServiceCollection services, string suffix) where T : class { + typeof(T).GetTypeInfo().Assembly.GetTypes() + .Where(x => x.Name.EndsWith(suffix, StringComparison.InvariantCulture) + && x.GetTypeInfo().IsClass + && !x.GetTypeInfo().IsAbstract) + .ToList() + .ForEach(x => services.AddSingleton(x)); + + return services; + } + + // TODO: this is untested and may not work as expected + public static IServiceCollection AddSingletonClassesByInterface(this IServiceCollection services) where T : class { + typeof(T).GetTypeInfo().Assembly.GetTypes() + .Where(x => x.GetInterfaces().Contains(typeof(T)) + && x.GetTypeInfo().IsClass + && !x.GetTypeInfo().IsAbstract) + .ToList() + .ForEach(x => services.AddSingleton(x)); + + return services; + } + public static IServiceCollection AddBootStrapper(this IServiceCollection services, + IConfiguration configuration, Action options) where T : BootStrapper, new() { + services.AddSingleton(configuration); + services.AddOptions(); + var bootstrapper = new T(); + + var o = new BootStrapperOptions(); + options?.Invoke(o); + + foreach (var installer in o.Installers) { + bootstrapper.AddInstaller(installer); + } + + bootstrapper.InitIoCContainer(configuration, services); + return services; + } + } +} diff --git a/EventJournal.Common/Configuration/DatabaseSettings.cs b/EventJournal.Common/Configuration/DatabaseSettings.cs new file mode 100644 index 0000000..c6f544d --- /dev/null +++ b/EventJournal.Common/Configuration/DatabaseSettings.cs @@ -0,0 +1,15 @@ +namespace EventJournal.Common.Configuration { + public enum DatabaseProvider { + Sqlite, + MySql, + SqlServer, + PostgreSQL + } + public class DatabaseSettings : IServiceSettings { + public static string ConfigurationSectionName => "Database"; + public string ConnectionString { get; set; } = null!; + public DatabaseProvider Provider { get; set; } = DatabaseProvider.Sqlite; + public bool UseDefaultConnectionString => string.IsNullOrWhiteSpace(ConnectionString) || ConnectionString.Equals("default", StringComparison.CurrentCultureIgnoreCase); + + } +} diff --git a/EventJournal.Common/Configuration/IServiceSettings.cs b/EventJournal.Common/Configuration/IServiceSettings.cs new file mode 100644 index 0000000..e301669 --- /dev/null +++ b/EventJournal.Common/Configuration/IServiceSettings.cs @@ -0,0 +1,5 @@ +namespace EventJournal.Common.Configuration { + public interface IServiceSettings { + public static abstract string ConfigurationSectionName { get; } + } +} diff --git a/EventJournal.Common/Configuration/JsonSerializerSettings.cs b/EventJournal.Common/Configuration/JsonSerializerSettings.cs new file mode 100644 index 0000000..74056f8 --- /dev/null +++ b/EventJournal.Common/Configuration/JsonSerializerSettings.cs @@ -0,0 +1,18 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace EventJournal.Common.Configuration { + // this class is static so helper methods can consume it without dependency injection + // if you need a instantiated version, use JsonSerailizerOptions installed via bootstrap + public static class JsonSerializerSettings { + public static JsonSerializerOptions JsonSerializerOptions { get; set; } = new() { + WriteIndented = true, + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { + new JsonStringEnumConverter() + // TODO: add additional converters as needed + } + }; + } +} diff --git a/EventJournal.Common/Enumerations/SortType.cs b/EventJournal.Common/Enumerations/SortType.cs new file mode 100644 index 0000000..1c124a7 --- /dev/null +++ b/EventJournal.Common/Enumerations/SortType.cs @@ -0,0 +1,8 @@ +namespace EventJournal.Common.Enumerations { + public enum SortType { + None, + Ascending, + Descending, + Custom + } +} diff --git a/EventJournal.Common/EventJournal.Common.csproj b/EventJournal.Common/EventJournal.Common.csproj new file mode 100644 index 0000000..325572b --- /dev/null +++ b/EventJournal.Common/EventJournal.Common.csproj @@ -0,0 +1,25 @@ + + + + net9.0 + enable + enable + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + diff --git a/EventJournal.Common/IOC/DI.cs b/EventJournal.Common/IOC/DI.cs new file mode 100644 index 0000000..7e60a3b --- /dev/null +++ b/EventJournal.Common/IOC/DI.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.Configuration; + +namespace EventJournal.Common.IOC { + public static class DI { + private static readonly Lock lockObject = new(); + + public static void SetContainer(IServiceProvider instance) { + lock (lockObject) { + Container = instance; + } + } + + public static IServiceProvider? Container { get; private set; } + + public static void SetConfiguration(IConfiguration instance) { + lock (lockObject) { + Configuration = instance; + } + } + + public static IConfiguration? Configuration { get; private set; } + } +} diff --git a/EventJournal.Configuration/EventJournal.Configuration.csproj b/EventJournal.Configuration/EventJournal.Configuration.csproj new file mode 100644 index 0000000..125f4c9 --- /dev/null +++ b/EventJournal.Configuration/EventJournal.Configuration.csproj @@ -0,0 +1,9 @@ + + + + net9.0 + enable + enable + + + diff --git a/EventJournal.Data/AssemblyInfo.cs b/EventJournal.Data/AssemblyInfo.cs new file mode 100644 index 0000000..bc9f759 --- /dev/null +++ b/EventJournal.Data/AssemblyInfo.cs @@ -0,0 +1,2 @@ +using System.Runtime.CompilerServices; +[assembly: InternalsVisibleTo("EventJournal.DomainService")] diff --git a/EventJournal.Data/BaseRepository.cs b/EventJournal.Data/BaseRepository.cs new file mode 100644 index 0000000..40bd390 --- /dev/null +++ b/EventJournal.Data/BaseRepository.cs @@ -0,0 +1,54 @@ +using EventJournal.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace EventJournal.Data { + public abstract class BaseRepository(IDatabaseContext db, DbSet table) : IBaseRepository where T : BaseEntity { + private readonly IDatabaseContext db = db; + internal readonly DbSet table = table; + internal virtual async Task AddAsync(T entity) { + var row = await table.AddAsync(entity).ConfigureAwait(false); + return row.Entity; + } + + public virtual async Task> GetAllAsync() { + return await table.ToListAsync().ConfigureAwait(false); + } + + internal virtual async Task GetByIdAsync(int id) { + return await table.FindAsync(id).ConfigureAwait(false); + } + + public virtual Task GetByResourceIdAsync(Guid resourceId) { + return table.FirstOrDefaultAsync(t => t.ResourceId == resourceId); + } + + public virtual void Delete(T entity) { + table.Remove(entity); + } + + public virtual async Task AddUpdateAsync(T source) { + ArgumentNullException.ThrowIfNull(source, nameof(source)); + var entity = await GetByIdAsync(source.Id) ?? await GetByResourceIdAsync(source.ResourceId).ConfigureAwait(false); + if (entity == null) { + entity = await AddAsync(source).ConfigureAwait(false); + } else { + entity.UpdateEntity(source); + } + return entity; + } + + public virtual async Task> AddUpdateAsync(IEnumerable sources) { + ArgumentNullException.ThrowIfNull(sources, nameof(sources)); + var result = new List(); + foreach (var source in sources) { + var updatedEntity = await AddUpdateAsync(source).ConfigureAwait(false); + result.Add(updatedEntity); + } + return result; + } + + public Task SaveChangesAsync() { + return db.SaveChangesAsync(); + } + } +} diff --git a/EventJournal.Data/DatabaseContext.cs b/EventJournal.Data/DatabaseContext.cs new file mode 100644 index 0000000..9362bed --- /dev/null +++ b/EventJournal.Data/DatabaseContext.cs @@ -0,0 +1,97 @@ +using EventJournal.Common.Configuration; +using EventJournal.Data.Entities; +using EventJournal.Data.Entities.UserTypes; +using Microsoft.EntityFrameworkCore; + +namespace EventJournal.Data { + + public class DatabaseContext : DbContext, IDatabaseContext { + public DbSet Events { get; set; } = null!; + public DbSet EventTypes { get; set; } = null!; + public DbSet Details { get; set; } = null!; + public DbSet DetailTypes { get; set; } = null!; + public DbSet Intensities { get; set; } = null!; + + public DatabaseContext() { + // needed by EF cli tools + // TODO: is it needed if we have the DesignTimeDbContextFactory? + } + + public DatabaseContext(DbContextOptions options) : base(options) { + // needed to properly inject DBContext at runtime + } + // TODO: do we need enumeration conversion? + //protected override void ConfigureConventions(ModelConfigurationBuilder builder) { + // // Applies conversion to all enumerations + // _ = builder.Properties() + // .HaveConversion>() + // .HaveColumnType("nvarchar(50)"); + //} + protected override void OnModelCreating(ModelBuilder modelBuilder) { + modelBuilder + .Entity() + .Property(e => e.IntensitySortType) + .HasConversion(); + } + + protected override void OnConfiguring(DbContextOptionsBuilder options) { + //TODO: this isn't working as expected, options isn't the one created in the installer + if (!options.IsConfigured) { + base.OnConfiguring(options); + // options.UseSqlite(GetConnectionString()); + } + } + + public static DbContextOptionsBuilder ConfigureFromSettings(DbContextOptionsBuilder options, DatabaseSettings? settings = null) { + settings ??= DefaultSettings; + switch (settings.Provider) { + default: + case DatabaseProvider.Sqlite: + options.UseSqlite(GetConnectionString(settings)); + break; + //case DatabaseProvider.MySql: + // optionsBuilder.UseMySql(GetConnectionString(settings), ServerVersion.AutoDetect(GetConnectionString(settings))); + // break; + //case DatabaseProvider.SqlServer: + // optionsBuilder.UseSqlServer(GetConnectionString(settings)); + // break; + //case DatabaseProvider.PostgreSQL: + // optionsBuilder.UseNpgsql(GetConnectionString(settings)); + // break; + } + return options; + } + private static DatabaseSettings DefaultSettings { get; set; } = new DatabaseSettings() { + ConnectionString = string.Empty, + Provider = DatabaseProvider.Sqlite + }; + + // TODO: make private or internal after we get DI for distributed lock supporting multiple DB providers + public static string GetConnectionString(DatabaseSettings? settings = null) { + + settings ??= DefaultSettings; + + switch (settings.Provider) { + default: + case DatabaseProvider.Sqlite: + if (settings.UseDefaultConnectionString) { + // The following configures EF to create a Sqlite database file in the + // special "local" folder for your platform. + var path = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var program = System.Reflection.Assembly.GetEntryAssembly()?.GetName().Name!.Split(".")[0]; + return $"Data Source={Path.Join(path, $"{program}.db")}"; + } + break; + //case DatabaseProvider.SqlServer: + //break; + //case DatabaseProvider.PostgreSQL: + //break; + } + return settings.ConnectionString; + } + + public Task SaveChangesAsync() { + return base.SaveChangesAsync(); + } + } +} diff --git a/EventJournal.Data/Entities/BaseEntity.cs b/EventJournal.Data/Entities/BaseEntity.cs new file mode 100644 index 0000000..b1b144c --- /dev/null +++ b/EventJournal.Data/Entities/BaseEntity.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; + +namespace EventJournal.Data.Entities { + public abstract class BaseEntity { + [Required] + public DateTime CreatedDate { get; set; } = DateTime.UtcNow; + + [Required] + public DateTime UpdatedDate { get; set; } = DateTime.UtcNow; + + [Required] + public abstract int Id { get; set; } + + [Required] + public abstract Guid ResourceId { get; set; } + + /// + /// This method is predominantly for updating an entity based on the values in another entity. + /// Ids and audit info (created, last modified, etc) should NOT be copied! + /// + /// + /// + internal abstract void CopyUserValues(T source); + } +} diff --git a/EventJournal.Data/Entities/Detail.cs b/EventJournal.Data/Entities/Detail.cs new file mode 100644 index 0000000..04c1bfe --- /dev/null +++ b/EventJournal.Data/Entities/Detail.cs @@ -0,0 +1,32 @@ +using EventJournal.Data.Entities.UserTypes; +using System.ComponentModel.DataAnnotations; + +namespace EventJournal.Data.Entities { + public class Detail : BaseEntity { + [Key] + public override int Id { get; set; } + + [Required] + public override Guid ResourceId { get; set; } + + [Required] + public required Event Event { get; set; } + + [Required] + public required DetailType DetailType { get; set; } + + [Required] + public required Intensity Intensity { get; set; } + + [MaxLength(512)] + public string? Notes { get; set; } + + internal override void CopyUserValues(T source) { + var sourceDetail = source as Detail ?? throw new InvalidCastException($"{nameof(source)} is not of type {typeof(Detail)}"); + Event = sourceDetail.Event; + DetailType = sourceDetail.DetailType; + Intensity = sourceDetail.Intensity; + Notes = sourceDetail.Notes; + } + } +} diff --git a/EventJournal.Data/Entities/EntityHelper.cs b/EventJournal.Data/Entities/EntityHelper.cs new file mode 100644 index 0000000..0d4a1e4 --- /dev/null +++ b/EventJournal.Data/Entities/EntityHelper.cs @@ -0,0 +1,14 @@ +namespace EventJournal.Data.Entities { + public static class EntityHelper { + public static T UpdateEntity(this T destination, T source) where T : BaseEntity { + ArgumentNullException.ThrowIfNull(destination, nameof(destination)); + ArgumentNullException.ThrowIfNull(source, nameof(source)); + if (destination.ResourceId != source.ResourceId) + //TODO: custom exception that supports a ThrowIf parameter? + throw new InvalidOperationException($"{typeof(T).Name} ResourceIds do not match"); + destination.CopyUserValues(source); + destination.UpdatedDate = DateTime.UtcNow; + return destination; + } + } +} diff --git a/EventJournal.Data/Entities/Event.cs b/EventJournal.Data/Entities/Event.cs new file mode 100644 index 0000000..034f362 --- /dev/null +++ b/EventJournal.Data/Entities/Event.cs @@ -0,0 +1,58 @@ +using EventJournal.Data.Entities.UserTypes; +using System.ComponentModel.DataAnnotations; + +namespace EventJournal.Data.Entities { + public class Event : BaseEntity { + [Key] + public override int Id { get; set; } + + [Required] + public override Guid ResourceId { get; set; } + + [Required] + public required EventType EventType { get; set; } + + [Required] + public DateTime StartTime { get; set; } = DateTime.Now; + public DateTime? EndTime { get; set; } = null; + + [MaxLength(500)] + public string? Description { get; set; } + + public IReadOnlyCollection Details => (IReadOnlyCollection)_details; + private IList _details = []; + + public Detail AddUpdateDetail(Detail detail) { + ArgumentNullException.ThrowIfNull(detail, nameof(detail)); + var existingDetail = _details.FirstOrDefault(d => d.ResourceId == detail.ResourceId || (d.Id != 0 && d.Id == detail.Id)); + if (existingDetail != null) { + //TODO: shouldn't this already be the case? + existingDetail.Event = this; + return existingDetail.UpdateEntity(detail); + } + detail.Event = this; + _details.Add(detail); + return detail; + } + + public void RemoveDetail(Guid detailResourceId) { + var existingDetail = _details.FirstOrDefault(d => d.ResourceId == detailResourceId); + if (existingDetail != null) { + _details.Remove(existingDetail); + } + } + + public void RemoveAllDetails() { + _details.Clear(); + } + + internal override void CopyUserValues(T source) { + var soruceEvent = source as Event ?? throw new InvalidCastException($"{nameof(source)} is not of type {typeof(Event)}"); + EventType = soruceEvent.EventType; + StartTime = soruceEvent.StartTime; + EndTime = soruceEvent.EndTime; + Description = soruceEvent.Description; + _details = soruceEvent._details; + } + } +} \ No newline at end of file diff --git a/EventJournal.Data/Entities/UserTypes/DetailType.cs b/EventJournal.Data/Entities/UserTypes/DetailType.cs new file mode 100644 index 0000000..aa4a6ce --- /dev/null +++ b/EventJournal.Data/Entities/UserTypes/DetailType.cs @@ -0,0 +1,55 @@ +using EventJournal.Common.Enumerations; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace EventJournal.Data.Entities.UserTypes { + public class DetailType : BaseEntity { + [Key] + public override int Id { get; set; } + + [Required] + public override Guid ResourceId { get; set; } + + [Required] + public required string Name { get; set; } + + [MaxLength(500)] + public string? Description { get; set; } + + [Required] + [Column(TypeName = "nvarchar(50)")] + public SortType IntensitySortType { get; set; } = SortType.None; + + public IReadOnlyCollection AllowedIntensities => (IReadOnlyCollection)_intensities; + private readonly IList _intensities = []; + + public Intensity AddUpdateAllowedIntensity(Intensity intensity) { + ArgumentNullException.ThrowIfNull(intensity, nameof(intensity)); + var existingIntensity = _intensities.FirstOrDefault(i => i.ResourceId == intensity.ResourceId || (i.Id != 0 && i.Id == intensity.Id)); + if (existingIntensity != null) { + //TODO: shouldn't this already be the case? + existingIntensity.DetailType = this; + return existingIntensity.UpdateEntity(intensity); + } + intensity.DetailType = this; + _intensities.Add(intensity); + return intensity; + } + + public void RemoveIntensity(Guid intensityResourceId) { + var existignIntensity = _intensities.FirstOrDefault(i => i.ResourceId == intensityResourceId); + if (existignIntensity != null) { + _intensities.Remove(existignIntensity); + } + } + public void RemoveAllIntensities() { + _intensities.Clear(); + } + + internal override void CopyUserValues(T source) { + var sourceDetailType = source as DetailType ?? throw new InvalidCastException($"{nameof(source)} is not of type {typeof(DetailType)}"); + Name = sourceDetailType.Name; + Description = sourceDetailType.Description; + } + } +} diff --git a/EventJournal.Data/Entities/UserTypes/EventType.cs b/EventJournal.Data/Entities/UserTypes/EventType.cs new file mode 100644 index 0000000..1b86018 --- /dev/null +++ b/EventJournal.Data/Entities/UserTypes/EventType.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; + +namespace EventJournal.Data.Entities.UserTypes { + public class EventType : BaseEntity { + [Key] + public override int Id { get; set; } + + [Required] + public override Guid ResourceId { get; set; } + + [Required] + public required string Name { get; set; } + + [MaxLength(500)] + public string? Description { get; set; } + + internal override void CopyUserValues(T source) { + var sourceEventType = source as EventType ?? throw new InvalidCastException($"{nameof(source)} is not of type {typeof(EventType)}"); + Name = sourceEventType.Name; + Description = sourceEventType.Description; + } + } +} diff --git a/EventJournal.Data/Entities/UserTypes/Intensity.cs b/EventJournal.Data/Entities/UserTypes/Intensity.cs new file mode 100644 index 0000000..29a9c63 --- /dev/null +++ b/EventJournal.Data/Entities/UserTypes/Intensity.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations; + +namespace EventJournal.Data.Entities.UserTypes { + public class Intensity : BaseEntity { + [Key] + public override int Id { get; set; } + [Required] + public override Guid ResourceId { get; set; } + + [Required, MaxLength(50)] + public required string Name { get; set; } + + [Required] + public required int Level { get; set; } + + [MaxLength(500)] + public string? Description { get; set; } + + [Required] + public required DetailType DetailType { get; set; } + + internal override void CopyUserValues(T source) { + var sourceIntensity = source as Intensity ?? throw new InvalidCastException($"{nameof(source)} is not of type {typeof(Intensity)}"); + Name = sourceIntensity.Name; + Level = sourceIntensity.Level; + Description = sourceIntensity.Description; + DetailType = sourceIntensity.DetailType; + } + } + +} diff --git a/EventJournal.Data/EventJournal.Data.csproj b/EventJournal.Data/EventJournal.Data.csproj new file mode 100644 index 0000000..08bb9d3 --- /dev/null +++ b/EventJournal.Data/EventJournal.Data.csproj @@ -0,0 +1,29 @@ + + + + net9.0 + enable + enable + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/EventJournal.Data/EventRepository.cs b/EventJournal.Data/EventRepository.cs new file mode 100644 index 0000000..9d55801 --- /dev/null +++ b/EventJournal.Data/EventRepository.cs @@ -0,0 +1,28 @@ +using EventJournal.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace EventJournal.Data { + public sealed class EventRepository(IDatabaseContext db) : BaseRepository(db, db.Events), IEventRepository { + public override async Task> GetAllAsync() { + return await table + .Include(e => e.EventType) + .Include(e => e.Details) + .ThenInclude(d => d.DetailType) + .ThenInclude(dt => dt.AllowedIntensities) + .Include(e => e.Details) + .ThenInclude(d => d.Intensity) + .ToListAsync() + .ConfigureAwait(false); + } + + public override Task GetByResourceIdAsync(Guid resourceId) { + return table + .Include(e => e.EventType) + .Include(e => e.Details) + .ThenInclude(d => d.DetailType) + .Include(e => e.Details) + .ThenInclude(d => d.Intensity) + .FirstOrDefaultAsync(e => e.ResourceId == resourceId); + } + } +} diff --git a/EventJournal.Data/IBaseRepository.cs b/EventJournal.Data/IBaseRepository.cs new file mode 100644 index 0000000..4de4d14 --- /dev/null +++ b/EventJournal.Data/IBaseRepository.cs @@ -0,0 +1,13 @@ +using EventJournal.Data.Entities; + +namespace EventJournal.Data { + public interface IBaseRepository where T : BaseEntity { + Task> GetAllAsync(); + Task GetByResourceIdAsync(Guid resourceId); + Task AddUpdateAsync(T source); + Task> AddUpdateAsync(IEnumerable sources); + void Delete(T entity); + + Task SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/EventJournal.Data/IDatabaseContext.cs b/EventJournal.Data/IDatabaseContext.cs new file mode 100644 index 0000000..8f6ff86 --- /dev/null +++ b/EventJournal.Data/IDatabaseContext.cs @@ -0,0 +1,15 @@ +using EventJournal.Data.Entities; +using EventJournal.Data.Entities.UserTypes; +using Microsoft.EntityFrameworkCore; + +namespace EventJournal.Data { + public interface IDatabaseContext { + DbSet Events { get; set; } + DbSet EventTypes { get; set; } + DbSet Details { get; set; } + DbSet DetailTypes { get; set; } + DbSet Intensities { get; set; } + + Task SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/EventJournal.Data/IEventRepository.cs b/EventJournal.Data/IEventRepository.cs new file mode 100644 index 0000000..715b829 --- /dev/null +++ b/EventJournal.Data/IEventRepository.cs @@ -0,0 +1,6 @@ +using EventJournal.Data.Entities; + +namespace EventJournal.Data { + public interface IEventRepository : IBaseRepository { + } +} \ No newline at end of file diff --git a/EventJournal.Data/Migrations/20250824150901_InitialMigration.Designer.cs b/EventJournal.Data/Migrations/20250824150901_InitialMigration.Designer.cs new file mode 100644 index 0000000..5b7d2b8 --- /dev/null +++ b/EventJournal.Data/Migrations/20250824150901_InitialMigration.Designer.cs @@ -0,0 +1,255 @@ +// +using System; +using EventJournal.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EventJournal.Data.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20250824150901_InitialMigration")] + partial class InitialMigration + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.8"); + + modelBuilder.Entity("EventJournal.Data.Entities.Detail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedDate") + .HasColumnType("TEXT"); + + b.Property("DetailTypeId") + .HasColumnType("INTEGER"); + + b.Property("EventId") + .HasColumnType("INTEGER"); + + b.Property("IntensityId") + .HasColumnType("INTEGER"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ResourceId") + .HasColumnType("TEXT"); + + b.Property("UpdatedDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DetailTypeId"); + + b.HasIndex("EventId"); + + b.HasIndex("IntensityId"); + + b.ToTable("Details"); + }); + + modelBuilder.Entity("EventJournal.Data.Entities.Event", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedDate") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("EndTime") + .HasColumnType("TEXT"); + + b.Property("EventTypeId") + .HasColumnType("INTEGER"); + + b.Property("ResourceId") + .HasColumnType("TEXT"); + + b.Property("StartTime") + .HasColumnType("TEXT"); + + b.Property("UpdatedDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EventTypeId"); + + b.ToTable("Events"); + }); + + modelBuilder.Entity("EventJournal.Data.Entities.UserTypes.DetailType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedDate") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("IntensitySortType") + .IsRequired() + .HasColumnType("nvarchar(32)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ResourceId") + .HasColumnType("TEXT"); + + b.Property("UpdatedDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("DetailTypes"); + }); + + modelBuilder.Entity("EventJournal.Data.Entities.UserTypes.EventType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedDate") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ResourceId") + .HasColumnType("TEXT"); + + b.Property("UpdatedDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("EventTypes"); + }); + + modelBuilder.Entity("EventJournal.Data.Entities.UserTypes.Intensity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedDate") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DetailTypeId") + .HasColumnType("INTEGER"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ResourceId") + .HasColumnType("TEXT"); + + b.Property("UpdatedDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DetailTypeId"); + + b.ToTable("Intensities"); + }); + + modelBuilder.Entity("EventJournal.Data.Entities.Detail", b => + { + b.HasOne("EventJournal.Data.Entities.UserTypes.DetailType", "DetailType") + .WithMany() + .HasForeignKey("DetailTypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EventJournal.Data.Entities.Event", "Event") + .WithMany("Details") + .HasForeignKey("EventId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EventJournal.Data.Entities.UserTypes.Intensity", "Intensity") + .WithMany() + .HasForeignKey("IntensityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DetailType"); + + b.Navigation("Event"); + + b.Navigation("Intensity"); + }); + + modelBuilder.Entity("EventJournal.Data.Entities.Event", b => + { + b.HasOne("EventJournal.Data.Entities.UserTypes.EventType", "EventType") + .WithMany() + .HasForeignKey("EventTypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EventType"); + }); + + modelBuilder.Entity("EventJournal.Data.Entities.UserTypes.Intensity", b => + { + b.HasOne("EventJournal.Data.Entities.UserTypes.DetailType", "DetailType") + .WithMany("AllowedIntensities") + .HasForeignKey("DetailTypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DetailType"); + }); + + modelBuilder.Entity("EventJournal.Data.Entities.Event", b => + { + b.Navigation("Details"); + }); + + modelBuilder.Entity("EventJournal.Data.Entities.UserTypes.DetailType", b => + { + b.Navigation("AllowedIntensities"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/EventJournal.Data/Migrations/20250824150901_InitialMigration.cs b/EventJournal.Data/Migrations/20250824150901_InitialMigration.cs new file mode 100644 index 0000000..e7e1488 --- /dev/null +++ b/EventJournal.Data/Migrations/20250824150901_InitialMigration.cs @@ -0,0 +1,181 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EventJournal.Data.Migrations +{ + /// + public partial class InitialMigration : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "DetailTypes", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + ResourceId = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", nullable: false), + Description = table.Column(type: "TEXT", maxLength: 500, nullable: true), + IntensitySortType = table.Column(type: "nvarchar(32)", nullable: false), + CreatedDate = table.Column(type: "TEXT", nullable: false), + UpdatedDate = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_DetailTypes", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "EventTypes", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + ResourceId = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", nullable: false), + Description = table.Column(type: "TEXT", maxLength: 500, nullable: true), + CreatedDate = table.Column(type: "TEXT", nullable: false), + UpdatedDate = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_EventTypes", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Intensities", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + ResourceId = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 50, nullable: false), + Level = table.Column(type: "INTEGER", nullable: false), + Description = table.Column(type: "TEXT", maxLength: 500, nullable: true), + DetailTypeId = table.Column(type: "INTEGER", nullable: false), + CreatedDate = table.Column(type: "TEXT", nullable: false), + UpdatedDate = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Intensities", x => x.Id); + table.ForeignKey( + name: "FK_Intensities_DetailTypes_DetailTypeId", + column: x => x.DetailTypeId, + principalTable: "DetailTypes", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Events", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + ResourceId = table.Column(type: "TEXT", nullable: false), + EventTypeId = table.Column(type: "INTEGER", nullable: false), + StartTime = table.Column(type: "TEXT", nullable: false), + EndTime = table.Column(type: "TEXT", nullable: true), + Description = table.Column(type: "TEXT", maxLength: 500, nullable: true), + CreatedDate = table.Column(type: "TEXT", nullable: false), + UpdatedDate = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Events", x => x.Id); + table.ForeignKey( + name: "FK_Events_EventTypes_EventTypeId", + column: x => x.EventTypeId, + principalTable: "EventTypes", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Details", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + ResourceId = table.Column(type: "TEXT", nullable: false), + EventId = table.Column(type: "INTEGER", nullable: false), + DetailTypeId = table.Column(type: "INTEGER", nullable: false), + IntensityId = table.Column(type: "INTEGER", nullable: false), + Notes = table.Column(type: "TEXT", maxLength: 512, nullable: true), + CreatedDate = table.Column(type: "TEXT", nullable: false), + UpdatedDate = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Details", x => x.Id); + table.ForeignKey( + name: "FK_Details_DetailTypes_DetailTypeId", + column: x => x.DetailTypeId, + principalTable: "DetailTypes", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Details_Events_EventId", + column: x => x.EventId, + principalTable: "Events", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Details_Intensities_IntensityId", + column: x => x.IntensityId, + principalTable: "Intensities", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Details_DetailTypeId", + table: "Details", + column: "DetailTypeId"); + + migrationBuilder.CreateIndex( + name: "IX_Details_EventId", + table: "Details", + column: "EventId"); + + migrationBuilder.CreateIndex( + name: "IX_Details_IntensityId", + table: "Details", + column: "IntensityId"); + + migrationBuilder.CreateIndex( + name: "IX_Events_EventTypeId", + table: "Events", + column: "EventTypeId"); + + migrationBuilder.CreateIndex( + name: "IX_Intensities_DetailTypeId", + table: "Intensities", + column: "DetailTypeId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Details"); + + migrationBuilder.DropTable( + name: "Events"); + + migrationBuilder.DropTable( + name: "Intensities"); + + migrationBuilder.DropTable( + name: "EventTypes"); + + migrationBuilder.DropTable( + name: "DetailTypes"); + } + } +} diff --git a/EventJournal.Data/Migrations/20250831211755_restructureEntities.Designer.cs b/EventJournal.Data/Migrations/20250831211755_restructureEntities.Designer.cs new file mode 100644 index 0000000..8a00dca --- /dev/null +++ b/EventJournal.Data/Migrations/20250831211755_restructureEntities.Designer.cs @@ -0,0 +1,255 @@ +// +using System; +using EventJournal.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EventJournal.Data.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20250831211755_restructureEntities")] + partial class restructureEntities + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.8"); + + modelBuilder.Entity("EventJournal.Data.Entities.Detail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedDate") + .HasColumnType("TEXT"); + + b.Property("DetailTypeId") + .HasColumnType("INTEGER"); + + b.Property("EventId") + .HasColumnType("INTEGER"); + + b.Property("IntensityId") + .HasColumnType("INTEGER"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ResourceId") + .HasColumnType("TEXT"); + + b.Property("UpdatedDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DetailTypeId"); + + b.HasIndex("EventId"); + + b.HasIndex("IntensityId"); + + b.ToTable("Details"); + }); + + modelBuilder.Entity("EventJournal.Data.Entities.Event", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedDate") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("EndTime") + .HasColumnType("TEXT"); + + b.Property("EventTypeId") + .HasColumnType("INTEGER"); + + b.Property("ResourceId") + .HasColumnType("TEXT"); + + b.Property("StartTime") + .HasColumnType("TEXT"); + + b.Property("UpdatedDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EventTypeId"); + + b.ToTable("Events"); + }); + + modelBuilder.Entity("EventJournal.Data.Entities.UserTypes.DetailType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedDate") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("IntensitySortType") + .IsRequired() + .HasColumnType("nvarchar(50)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ResourceId") + .HasColumnType("TEXT"); + + b.Property("UpdatedDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("DetailTypes"); + }); + + modelBuilder.Entity("EventJournal.Data.Entities.UserTypes.EventType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedDate") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ResourceId") + .HasColumnType("TEXT"); + + b.Property("UpdatedDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("EventTypes"); + }); + + modelBuilder.Entity("EventJournal.Data.Entities.UserTypes.Intensity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedDate") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DetailTypeId") + .HasColumnType("INTEGER"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ResourceId") + .HasColumnType("TEXT"); + + b.Property("UpdatedDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DetailTypeId"); + + b.ToTable("Intensities"); + }); + + modelBuilder.Entity("EventJournal.Data.Entities.Detail", b => + { + b.HasOne("EventJournal.Data.Entities.UserTypes.DetailType", "DetailType") + .WithMany() + .HasForeignKey("DetailTypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EventJournal.Data.Entities.Event", "Event") + .WithMany("Details") + .HasForeignKey("EventId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EventJournal.Data.Entities.UserTypes.Intensity", "Intensity") + .WithMany() + .HasForeignKey("IntensityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DetailType"); + + b.Navigation("Event"); + + b.Navigation("Intensity"); + }); + + modelBuilder.Entity("EventJournal.Data.Entities.Event", b => + { + b.HasOne("EventJournal.Data.Entities.UserTypes.EventType", "EventType") + .WithMany() + .HasForeignKey("EventTypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EventType"); + }); + + modelBuilder.Entity("EventJournal.Data.Entities.UserTypes.Intensity", b => + { + b.HasOne("EventJournal.Data.Entities.UserTypes.DetailType", "DetailType") + .WithMany("AllowedIntensities") + .HasForeignKey("DetailTypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DetailType"); + }); + + modelBuilder.Entity("EventJournal.Data.Entities.Event", b => + { + b.Navigation("Details"); + }); + + modelBuilder.Entity("EventJournal.Data.Entities.UserTypes.DetailType", b => + { + b.Navigation("AllowedIntensities"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/EventJournal.Data/Migrations/20250831211755_restructureEntities.cs b/EventJournal.Data/Migrations/20250831211755_restructureEntities.cs new file mode 100644 index 0000000..390c5d6 --- /dev/null +++ b/EventJournal.Data/Migrations/20250831211755_restructureEntities.cs @@ -0,0 +1,34 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EventJournal.Data.Migrations +{ + /// + public partial class restructureEntities : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "IntensitySortType", + table: "DetailTypes", + type: "nvarchar(50)", + nullable: false, + oldClrType: typeof(string), + oldType: "nvarchar(32)"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "IntensitySortType", + table: "DetailTypes", + type: "nvarchar(32)", + nullable: false, + oldClrType: typeof(string), + oldType: "nvarchar(50)"); + } + } +} diff --git a/EventJournal.Data/Migrations/DatabaseContextModelSnapshot.cs b/EventJournal.Data/Migrations/DatabaseContextModelSnapshot.cs new file mode 100644 index 0000000..6adb916 --- /dev/null +++ b/EventJournal.Data/Migrations/DatabaseContextModelSnapshot.cs @@ -0,0 +1,252 @@ +// +using System; +using EventJournal.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EventJournal.Data.Migrations +{ + [DbContext(typeof(DatabaseContext))] + partial class DatabaseContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.8"); + + modelBuilder.Entity("EventJournal.Data.Entities.Detail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedDate") + .HasColumnType("TEXT"); + + b.Property("DetailTypeId") + .HasColumnType("INTEGER"); + + b.Property("EventId") + .HasColumnType("INTEGER"); + + b.Property("IntensityId") + .HasColumnType("INTEGER"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ResourceId") + .HasColumnType("TEXT"); + + b.Property("UpdatedDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DetailTypeId"); + + b.HasIndex("EventId"); + + b.HasIndex("IntensityId"); + + b.ToTable("Details"); + }); + + modelBuilder.Entity("EventJournal.Data.Entities.Event", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedDate") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("EndTime") + .HasColumnType("TEXT"); + + b.Property("EventTypeId") + .HasColumnType("INTEGER"); + + b.Property("ResourceId") + .HasColumnType("TEXT"); + + b.Property("StartTime") + .HasColumnType("TEXT"); + + b.Property("UpdatedDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EventTypeId"); + + b.ToTable("Events"); + }); + + modelBuilder.Entity("EventJournal.Data.Entities.UserTypes.DetailType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedDate") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("IntensitySortType") + .IsRequired() + .HasColumnType("nvarchar(50)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ResourceId") + .HasColumnType("TEXT"); + + b.Property("UpdatedDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("DetailTypes"); + }); + + modelBuilder.Entity("EventJournal.Data.Entities.UserTypes.EventType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedDate") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ResourceId") + .HasColumnType("TEXT"); + + b.Property("UpdatedDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("EventTypes"); + }); + + modelBuilder.Entity("EventJournal.Data.Entities.UserTypes.Intensity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedDate") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DetailTypeId") + .HasColumnType("INTEGER"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ResourceId") + .HasColumnType("TEXT"); + + b.Property("UpdatedDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DetailTypeId"); + + b.ToTable("Intensities"); + }); + + modelBuilder.Entity("EventJournal.Data.Entities.Detail", b => + { + b.HasOne("EventJournal.Data.Entities.UserTypes.DetailType", "DetailType") + .WithMany() + .HasForeignKey("DetailTypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EventJournal.Data.Entities.Event", "Event") + .WithMany("Details") + .HasForeignKey("EventId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EventJournal.Data.Entities.UserTypes.Intensity", "Intensity") + .WithMany() + .HasForeignKey("IntensityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DetailType"); + + b.Navigation("Event"); + + b.Navigation("Intensity"); + }); + + modelBuilder.Entity("EventJournal.Data.Entities.Event", b => + { + b.HasOne("EventJournal.Data.Entities.UserTypes.EventType", "EventType") + .WithMany() + .HasForeignKey("EventTypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EventType"); + }); + + modelBuilder.Entity("EventJournal.Data.Entities.UserTypes.Intensity", b => + { + b.HasOne("EventJournal.Data.Entities.UserTypes.DetailType", "DetailType") + .WithMany("AllowedIntensities") + .HasForeignKey("DetailTypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DetailType"); + }); + + modelBuilder.Entity("EventJournal.Data.Entities.Event", b => + { + b.Navigation("Details"); + }); + + modelBuilder.Entity("EventJournal.Data.Entities.UserTypes.DetailType", b => + { + b.Navigation("AllowedIntensities"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/EventJournal.Data/UserTypeRepositories/DetailTypeRepository.cs b/EventJournal.Data/UserTypeRepositories/DetailTypeRepository.cs new file mode 100644 index 0000000..3f5e62f --- /dev/null +++ b/EventJournal.Data/UserTypeRepositories/DetailTypeRepository.cs @@ -0,0 +1,19 @@ +using EventJournal.Data.Entities.UserTypes; +using Microsoft.EntityFrameworkCore; + +namespace EventJournal.Data.UserTypeRepositories { + public class DetailTypeRepository(IDatabaseContext db) : BaseRepository(db, db.DetailTypes), IDetailTypeRepository { + public override async Task> GetAllAsync() { + return await table + .Include(dt => dt.AllowedIntensities) + .ToListAsync() + .ConfigureAwait(false); + } + + public override Task GetByResourceIdAsync(Guid resourceId) { + return table + .Include(dt => dt.AllowedIntensities) + .FirstOrDefaultAsync(dt => dt.ResourceId == resourceId); + } + } +} diff --git a/EventJournal.Data/UserTypeRepositories/EventTypeRepository.cs b/EventJournal.Data/UserTypeRepositories/EventTypeRepository.cs new file mode 100644 index 0000000..38434ff --- /dev/null +++ b/EventJournal.Data/UserTypeRepositories/EventTypeRepository.cs @@ -0,0 +1,6 @@ +using EventJournal.Data.Entities.UserTypes; + +namespace EventJournal.Data.UserTypeRepositories { + public class EventTypeRepository(IDatabaseContext db) : BaseRepository(db, db.EventTypes), IEventTypeRepository { + } +} diff --git a/EventJournal.Data/UserTypeRepositories/IDetailTypeRepository.cs b/EventJournal.Data/UserTypeRepositories/IDetailTypeRepository.cs new file mode 100644 index 0000000..782885e --- /dev/null +++ b/EventJournal.Data/UserTypeRepositories/IDetailTypeRepository.cs @@ -0,0 +1,6 @@ +using EventJournal.Data.Entities.UserTypes; + +namespace EventJournal.Data.UserTypeRepositories { + public interface IDetailTypeRepository : IBaseRepository { + } +} \ No newline at end of file diff --git a/EventJournal.Data/UserTypeRepositories/IEventTypeRepository.cs b/EventJournal.Data/UserTypeRepositories/IEventTypeRepository.cs new file mode 100644 index 0000000..dec3d7a --- /dev/null +++ b/EventJournal.Data/UserTypeRepositories/IEventTypeRepository.cs @@ -0,0 +1,6 @@ +using EventJournal.Data.Entities.UserTypes; + +namespace EventJournal.Data.UserTypeRepositories { + public interface IEventTypeRepository : IBaseRepository { + } +} \ No newline at end of file diff --git a/EventJournal.DomainModels/BaseDto.cs b/EventJournal.DomainModels/BaseDto.cs new file mode 100644 index 0000000..6409adc --- /dev/null +++ b/EventJournal.DomainModels/BaseDto.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace EventJournal.DomainDto { + public abstract class BaseDto { + + public DateTime CreatedDate { get; set; } = DateTime.UtcNow; + + public DateTime UpdatedDate { get; set; } = DateTime.UtcNow; + + [JsonIgnore] + public virtual Guid ResourceId { get; set; } + + /// + /// This method is predominantly for updating an entity based on the values in another entity. + /// Copy user values (ids and audit info (created, last modified, etc) should NOT be copied! + /// + /// + /// + internal abstract void CopyUserValues(T source); + } +} \ No newline at end of file diff --git a/EventJournal.DomainModels/DetailDto.cs b/EventJournal.DomainModels/DetailDto.cs new file mode 100644 index 0000000..95fe676 --- /dev/null +++ b/EventJournal.DomainModels/DetailDto.cs @@ -0,0 +1,27 @@ +using EventJournal.DomainDto.UserTypes; +using System.ComponentModel.DataAnnotations; + +namespace EventJournal.DomainDto { + public class DetailDto : BaseDto { + public override Guid ResourceId { get; set; } + + [Required] + public virtual required DetailTypeDto DetailType { get; set; } + + [Required] + public virtual required IntensityDto Intensity { get; set; } + + [MaxLength(512)] + public string? Notes { get; set; } + + internal override void CopyUserValues(T source) { + var sourceDetail = source as DetailDto ?? throw new InvalidCastException($"{nameof(source)} is not of type {typeof(DetailDto)}"); + DetailType = sourceDetail.DetailType; + Intensity = sourceDetail.Intensity; + Notes = sourceDetail.Notes; + } + public override string ToString() { + return this.Serialize(); + } + } +} diff --git a/EventJournal.DomainModels/DtoHelper.cs b/EventJournal.DomainModels/DtoHelper.cs new file mode 100644 index 0000000..c296c2c --- /dev/null +++ b/EventJournal.DomainModels/DtoHelper.cs @@ -0,0 +1,35 @@ +using EventJournal.Common.Configuration; +using System.Text.Json; + +namespace EventJournal.DomainDto { + public static partial class DtoHelper { + //TODO: consider using AutoMapper for this if it is even needed + public static T UpdateDTO(this T destination, T source) where T : BaseDto { + ArgumentNullException.ThrowIfNull(destination, nameof(destination)); + ArgumentNullException.ThrowIfNull(source, nameof(source)); + if (destination.ResourceId != source.ResourceId) + //TODO: custom exception that supports a ThrowIf parameter? + throw new InvalidOperationException("ResourceIds do not match"); + destination.CopyUserValues(source); + destination.CreatedDate = source.CreatedDate; + destination.UpdatedDate = DateTime.UtcNow; + return destination; + } + + public static string Serialize(this T entity, JsonSerializerOptions? serializerOptions = null) where T : BaseDto { + return JsonSerializer.Serialize(entity, entity.GetType(), serializerOptions ?? JsonSerializerSettings.JsonSerializerOptions); + } + + public static string Serialize(this IEnumerable list, JsonSerializerOptions? serializerOptions = null) where T : BaseDto { + return JsonSerializer.Serialize(list, list.GetType(), serializerOptions ?? JsonSerializerSettings.JsonSerializerOptions); + } + + //TODO: options should be setup in bootstrap + + public static T? Deserialize(this string json) where T : BaseDto { + //TODO: does this work properly for inherited types? + return JsonSerializer.Deserialize(json); + } + + } +} diff --git a/EventJournal.DomainModels/EventDto.cs b/EventJournal.DomainModels/EventDto.cs new file mode 100644 index 0000000..109be72 --- /dev/null +++ b/EventJournal.DomainModels/EventDto.cs @@ -0,0 +1,36 @@ +using EventJournal.DomainDto.UserTypes; +using System.ComponentModel.DataAnnotations; + +namespace EventJournal.DomainDto { + public class EventDto : BaseDto { + public override Guid ResourceId { get; set; } + + [Required] + public required EventTypeDto EventType { get; set; } + + [Required] + public DateTime StartTime { get; set; } = DateTime.Now; + + public DateTime? EndTime { get; set; } = null; + + [MaxLength(500)] + public string? Description { get; set; } + + public IEnumerable Details { get; set; } = []; + + internal override void CopyUserValues(T source) { + var soruceEvent = source as EventDto ?? throw new InvalidCastException($"{nameof(source)} is not of type {typeof(EventDto)}"); + EventType = soruceEvent.EventType; + StartTime = soruceEvent.StartTime; + EndTime = soruceEvent.EndTime; + Description = soruceEvent.Description; + //TODO: this might need to be a deep copy depending on usage + // or might need to copy by value instead of reference (eg. call inteisity.copyvaules for each item) + Details = soruceEvent.Details; + } + + public override string ToString() { + return this.Serialize(); + } + } +} diff --git a/EventJournal.DomainModels/EventJournal.DomainDto.csproj b/EventJournal.DomainModels/EventJournal.DomainDto.csproj new file mode 100644 index 0000000..6429f43 --- /dev/null +++ b/EventJournal.DomainModels/EventJournal.DomainDto.csproj @@ -0,0 +1,25 @@ + + + + net9.0 + enable + enable + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/EventJournal.DomainModels/UserTypes/DetailTypeDto.cs b/EventJournal.DomainModels/UserTypes/DetailTypeDto.cs new file mode 100644 index 0000000..4b7d848 --- /dev/null +++ b/EventJournal.DomainModels/UserTypes/DetailTypeDto.cs @@ -0,0 +1,34 @@ +using EventJournal.Common.Enumerations; +using System.ComponentModel.DataAnnotations; + +namespace EventJournal.DomainDto.UserTypes { + public class DetailTypeDto : BaseDto { + public override Guid ResourceId { get; set; } + + [Required] + public required string Name { get; set; } + + [MaxLength(500)] + public string? Description { get; set; } + + //TODO: is this useful/needed? + public IEnumerable AllowedIntensities { get; set; } = []; + + [Required] + public required SortType IntensitySortType { get; set; } = SortType.Descending; + + internal override void CopyUserValues(T source) { + var sourceDetailType = source as DetailTypeDto ?? throw new InvalidCastException($"{nameof(source)} is not of type {typeof(DetailTypeDto)}"); + Name = sourceDetailType.Name; + Description = sourceDetailType.Description; + IntensitySortType = sourceDetailType.IntensitySortType; + //TODO: this might need to be a deep copy depending on usage + // or might need to copy by value instead of reference (eg. call inteisity.copyvaules for each item) + AllowedIntensities = sourceDetailType.AllowedIntensities; + } + + public override string ToString() { + return this.Serialize(); + } + } +} diff --git a/EventJournal.DomainModels/UserTypes/EventTypeDto.cs b/EventJournal.DomainModels/UserTypes/EventTypeDto.cs new file mode 100644 index 0000000..22f1c34 --- /dev/null +++ b/EventJournal.DomainModels/UserTypes/EventTypeDto.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + +namespace EventJournal.DomainDto.UserTypes { + public class EventTypeDto : BaseDto { + public override Guid ResourceId { get; set; } + + [Required] + public required string Name { get; set; } + + [MaxLength(500)] + public string? Description { get; set; } + + internal override void CopyUserValues(T source) { + var sourceEventType = source as EventTypeDto ?? throw new InvalidCastException($"{nameof(source)} is not of type {typeof(EventTypeDto)}"); + Name = sourceEventType.Name; + Description = sourceEventType.Description; + } + + public override string ToString() { + return this.Serialize(); + } + } +} + diff --git a/EventJournal.DomainModels/UserTypes/IntensityDto.cs b/EventJournal.DomainModels/UserTypes/IntensityDto.cs new file mode 100644 index 0000000..09e35f4 --- /dev/null +++ b/EventJournal.DomainModels/UserTypes/IntensityDto.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; + +namespace EventJournal.DomainDto.UserTypes { + public class IntensityDto : BaseDto { + public override Guid ResourceId { get; set; } + + [Required, MaxLength(50)] + public required string Name { get; set; } + + [Required] + public required int Level { get; set; } + + [MaxLength(500)] + public string? Description { get; set; } + + internal override void CopyUserValues(T source) { + var sourceIntensity = source as IntensityDto ?? throw new InvalidCastException($"{nameof(source)} is not of type {typeof(IntensityDto)}"); + Name = sourceIntensity.Name; + Level = sourceIntensity.Level; + Description = sourceIntensity.Description; + } + public override string ToString() { + return this.Serialize(); + } + } +} + diff --git a/EventJournal.DomainService/DefaultDataProvider.cs b/EventJournal.DomainService/DefaultDataProvider.cs new file mode 100644 index 0000000..bdf8a55 --- /dev/null +++ b/EventJournal.DomainService/DefaultDataProvider.cs @@ -0,0 +1,157 @@ +using EventJournal.Common.Enumerations; +using EventJournal.DomainDto; +using EventJournal.DomainDto.UserTypes; + +namespace EventJournal.DomainService { + public class DefaultDataProvider( + IEventService eventService, + IUserTypesService userTypesService) + : IDefaultDataProvider { + private static readonly Guid DefaultEventResourceId = Guid.Parse("00000000-0000-0000-0000-000000000001"); + + private static readonly Guid[] DefaultEventTypeResourceIds = [ + Guid.Parse("00000000-0000-0000-0000-000000000001"), + Guid.Parse("00000000-0000-0000-0000-000000000002"), + Guid.Parse("00000000-0000-0000-0000-000000000003"), + Guid.Parse("00000000-0000-0000-0000-000000000004"), + Guid.Parse("00000000-0000-0000-0000-000000000005") + ]; + private static readonly Guid[] DefaultDetailTypeResourceIds = [ + Guid.Parse("00000000-0000-0000-0000-000000000001"), + Guid.Parse("00000000-0000-0000-0000-000000000002"), + Guid.Parse("00000000-0000-0000-0000-000000000003") + ]; + + public async Task AddResetDefaultDataAsync() { + var defaultEventTypes = await userTypesService.AddUpdateEventTypesAsync([ + new EventTypeDto{ ResourceId = DefaultEventTypeResourceIds[0], Name = "Random Event", Description="Use for tracking random things like onset of pain, headache, or whatever that isn't directly associated with a specific event type." }, + new EventTypeDto{ ResourceId = DefaultEventTypeResourceIds[1], Name = "Bowel Movement", Description=""}, + new EventTypeDto{ ResourceId = DefaultEventTypeResourceIds[2], Name = "Ate Something", Description=""}, + new EventTypeDto{ ResourceId = DefaultEventTypeResourceIds[3], Name = "Weigh In", Description=""}, + new EventTypeDto{ ResourceId = DefaultEventTypeResourceIds[4], Name = "Exercise", Description=""} + ]).ConfigureAwait(false); + + IEnumerable defaultDetailTypeDtos = await userTypesService.AddUpdateDetailTypesAsync([ + new DetailTypeDto { + ResourceId = DefaultDetailTypeResourceIds[1], + Description = "This is a generic detail type", + Name = "Generic", + IntensitySortType = SortType.Descending, + AllowedIntensities = [ + new IntensityDto { + ResourceId = Guid.NewGuid(), + Level = 0, + Name = "Zero Intensity", + Description = "Not intense at all. Normal." + }, + new IntensityDto { + ResourceId = Guid.NewGuid(), + Level = 1, + Name = "Mild Intensity", + Description = "Almost broke a sweat." + }, + new IntensityDto { + ResourceId = Guid.NewGuid(), + Level = 2, + Name = "Moderate Intensity", + Description = "Got sweaty, did some breathing. Might feel this tomorrow." + }, + new IntensityDto { + ResourceId = Guid.NewGuid(), + Level = 3, + Name = "High Intensity", + Description = "Breathing hard. Will definitely feel this for a few days." + }, + new IntensityDto { + ResourceId = Guid.NewGuid(), + Level = 4, + Name = "Insane Intensity", + Description = "Unbearable. Insane. No one needs to experience this. Why did I do this to myself?" + } + ] + }, + new DetailTypeDto { + ResourceId = DefaultDetailTypeResourceIds[2], + Description = "Use this detail type to track your breakfast.", + Name = "Meal", + IntensitySortType = SortType.Descending, + AllowedIntensities = [ + new IntensityDto { + ResourceId = Guid.NewGuid(), + Level = 0, + Name = "Skipped", + Description = "Skipped altogether or just had coffee, tea, or a soda." + }, + new IntensityDto { + ResourceId = Guid.NewGuid(), + Level = 1, + Name = "Unhealthy", + Description = "Ate something but it wasn't really a healthy choice." + }, + new IntensityDto { + ResourceId = Guid.NewGuid(), + Level = 2, + Name = "Moderately Healthy", + Description = "Made a moderately healthy choice." + }, + new IntensityDto { + ResourceId = Guid.NewGuid(), + Level = 3, + Name = "Power Meal", + Description = "Made a very healthy choice." + } + ] + } + ]).ConfigureAwait(false); + + var eventDtos = await eventService.AddUpdateEventsAsync([ + new EventDto { + ResourceId = DefaultEventResourceId, + StartTime = DateTime.Now, + Description = "Event History Started", + EventType = defaultEventTypes.First(d => d.Name == "Random Event") + }, + new EventDto { + ResourceId = Guid.NewGuid(), + StartTime = DateTime.Now.AddHours(-1), + EndTime = DateTime.Now.AddHours(-1).AddMinutes(30), + Description = "Ate breakfast", + EventType = defaultEventTypes.First(et => et.Name == "Ate Something") + } + ]); + + var genericDetailType = defaultDetailTypeDtos.First(dt => dt.Name == "Generic"); + var mealDetailType = defaultDetailTypeDtos.First(dt => dt.Name == "Meal"); + await eventService.AddUpdateDetailsAsync(eventDtos.First().ResourceId, [ + new DetailDto { + ResourceId = Guid.NewGuid(), + DetailType = genericDetailType, + Intensity = genericDetailType.AllowedIntensities.First(i => i.Level == 2), + Notes = "Congratulations on starting your Event History!\nTake the next step and add another event!" + }, + new DetailDto { + ResourceId = Guid.NewGuid(), + DetailType = mealDetailType, + Intensity = mealDetailType.AllowedIntensities.First(i => i.Level == 1), + Notes = "This is an example of how you can use details to track more information about your events.\nYou can add multiple details to an event." + } + ]); + + await eventService.AddUpdateDetailsAsync(eventDtos.ElementAt(1).ResourceId, [ + new DetailDto { + ResourceId = Guid.NewGuid(), + DetailType = mealDetailType, + Intensity = mealDetailType.AllowedIntensities.First(i => i.Level == 0), + Notes = "Was running late - Just had coffee." + }, + new DetailDto { + ResourceId = Guid.NewGuid(), + DetailType = mealDetailType, + Intensity = mealDetailType.AllowedIntensities.First(i => i.Level == 3), + Notes = "Realized I had time to stop at the cafe and grab Eggs and Fruit" + } + ]); + } + + } +} diff --git a/EventJournal.DomainService/DomainMapperProfile.cs b/EventJournal.DomainService/DomainMapperProfile.cs new file mode 100644 index 0000000..f770b18 --- /dev/null +++ b/EventJournal.DomainService/DomainMapperProfile.cs @@ -0,0 +1,20 @@ +using AutoMapper; +using EventJournal.Data.Entities; +using EventJournal.Data.Entities.UserTypes; +using EventJournal.DomainDto; +using EventJournal.DomainDto.UserTypes; + +namespace EventJournal.DomainService { + public class DomainMapperProfile : Profile { + public DomainMapperProfile() { + + //TODO: use reflection to find objects that inherit from BaseDto and map them + //TODO: also move to bootstrapper + CreateMap().ReverseMap(); + CreateMap().ReverseMap(); + CreateMap().ReverseMap(); + CreateMap().ReverseMap(); + CreateMap().ReverseMap(); + } + } +} diff --git a/EventJournal.DomainService/EventJournal.DomainService.csproj b/EventJournal.DomainService/EventJournal.DomainService.csproj new file mode 100644 index 0000000..0bc6208 --- /dev/null +++ b/EventJournal.DomainService/EventJournal.DomainService.csproj @@ -0,0 +1,26 @@ + + + + net9.0 + enable + enable + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/EventJournal.DomainService/EventService.cs b/EventJournal.DomainService/EventService.cs new file mode 100644 index 0000000..643a79d --- /dev/null +++ b/EventJournal.DomainService/EventService.cs @@ -0,0 +1,109 @@ +using AutoMapper; +using EventJournal.Data; +using EventJournal.Data.Entities; +using EventJournal.Data.Entities.UserTypes; +using EventJournal.DomainDto; +using EventJournal.DomainService.Exceptions; + +namespace EventJournal.DomainService { + public class EventService( + IEventRepository eventRepository, + //TODO: fix this. services shouldn't call other services + IUserTypesService userTypesService, + IMapper mapper) + : IEventService { + private readonly IInternalUserTypeService internalUserTypeService = userTypesService as IInternalUserTypeService ?? throw new InvalidOperationException("userTypesService must implement IInternalUserTypeService"); + // ======================> Events <====================== + public async Task> GetAllEventsAsync() { + return mapper.Map>(await eventRepository.GetAllAsync().ConfigureAwait(false)); + } + + public async Task GetEventByIdAsync(Guid resourceId) { + return mapper.Map(await eventRepository.GetByResourceIdAsync(resourceId).ConfigureAwait(false)); + } + + public async Task AddUpdateEventAsync(EventDto dto) { + ArgumentNullException.ThrowIfNull(dto, nameof(dto)); + //TODO: additional validations? + var eventTypeEntity = await internalUserTypeService.GetEventTypeEntityAsync(dto.EventType.ResourceId).ConfigureAwait(false) ?? throw new ResourceNotFoundException($"EventType with ResourceId {dto.EventType.ResourceId} not found."); + + var savedEvent = await AddUpdateEventPrivateAsync(mapper.Map(dto), eventTypeEntity); + + await eventRepository.SaveChangesAsync().ConfigureAwait(false); + return mapper.Map(savedEvent); + } + + private Task AddUpdateEventPrivateAsync(Event @event, EventType eventType) { + ArgumentNullException.ThrowIfNull(eventType, nameof(eventType)); + // don't duplicate details - match on either Id or ResourceId + @event.EventType = eventType; + foreach (var detail in @event.Details) { + detail.Event = @event; + var existingDetail = @event.Details.First(d => d.ResourceId == detail.ResourceId || (d.Id != 0 && d.Id == detail.Id)); + if (existingDetail != null) { + detail.Id = existingDetail.Id; + detail.ResourceId = existingDetail.ResourceId; + } + } + + return eventRepository.AddUpdateAsync(@event); + } + public async Task> AddUpdateEventsAsync(IEnumerable dtos) { + ArgumentNullException.ThrowIfNull(dtos, nameof(dtos)); + var events = new List(); + foreach (var dto in dtos) { + //TODO: additional validations? + var eventTypeEntity = await internalUserTypeService.GetEventTypeEntityAsync(dto.EventType.ResourceId).ConfigureAwait(false) ?? throw new ResourceNotFoundException($"EventType with ResourceId {dto.EventType.ResourceId} not found."); + Event @event = await AddUpdateEventPrivateAsync(mapper.Map(dto), eventTypeEntity).ConfigureAwait(false); + events.Add(@event); + } + await eventRepository.SaveChangesAsync().ConfigureAwait(false); + return mapper.Map>(events); + } + + public async Task DeleteEventAsync(Guid resourceId) { + var entity = await eventRepository.GetByResourceIdAsync(resourceId).ConfigureAwait(false); + if (entity == null) { + return; + } + //TODO: does this leave orphaned details? + eventRepository.Delete(entity); + await eventRepository.SaveChangesAsync().ConfigureAwait(false); + } + + public async Task AddUpdateDetailAsync(Guid eventResourceId, DetailDto detailDto) { + ArgumentNullException.ThrowIfNull(detailDto, nameof(detailDto)); + + var result = AddUpdateDetailPrivateAsync(eventResourceId, mapper.Map(detailDto)); + await eventRepository.SaveChangesAsync().ConfigureAwait(false); + return mapper.Map(result); + } + private async Task AddUpdateDetailPrivateAsync(Guid eventResourceId, Detail detail) { + ArgumentNullException.ThrowIfNull(detail, nameof(detail)); + var eventEntity = await eventRepository.GetByResourceIdAsync(eventResourceId).ConfigureAwait(false) ?? throw new ResourceNotFoundException($"Event with ResourceId {eventResourceId} not found."); + var detailTypeEntity = await internalUserTypeService.GetDetailTypeEntityAsync(detail.DetailType.ResourceId).ConfigureAwait(false) ?? throw new ResourceNotFoundException($"DetailType with ResourceId {detail.DetailType.ResourceId} not found."); + var intensityEntity = detailTypeEntity.AllowedIntensities.FirstOrDefault(i => i.ResourceId == detail.Intensity.ResourceId) ?? throw new ResourceNotFoundException($"Intensity with ResourceId {detail.Intensity.ResourceId} not found in DetailType with ResourceId {detail.DetailType.ResourceId}."); + detail.DetailType = detailTypeEntity; + detail.Intensity = intensityEntity; + return eventEntity.AddUpdateDetail(detail); + } + public async Task AddUpdateDetailsAsync(Guid eventResourceId, IEnumerable detailDtos) { + foreach (var detailDto in detailDtos) { + await AddUpdateDetailPrivateAsync(eventResourceId, mapper.Map(detailDto)).ConfigureAwait(false); + } + await eventRepository.SaveChangesAsync().ConfigureAwait(false); + } + + public async Task RemoveDetailAsync(Guid eventResourceId, Guid detailResourceId) { + var eventEntity = await eventRepository.GetByResourceIdAsync(eventResourceId).ConfigureAwait(false) ?? throw new ResourceNotFoundException($"Event with ResourceId {eventResourceId} not found."); + eventEntity.RemoveDetail(detailResourceId); + await eventRepository.SaveChangesAsync().ConfigureAwait(false); + } + + public async Task RemoveAllDetailsAsync(Guid eventResourceId) { + var eventEntity = await eventRepository.GetByResourceIdAsync(eventResourceId).ConfigureAwait(false) ?? throw new ResourceNotFoundException($"Event with ResourceId {eventResourceId} not found."); + eventEntity.RemoveAllDetails(); + await eventRepository.SaveChangesAsync().ConfigureAwait(false); + } + } +} diff --git a/EventJournal.DomainService/Exceptions/ResourceNotFoundException.cs b/EventJournal.DomainService/Exceptions/ResourceNotFoundException.cs new file mode 100644 index 0000000..01fd19f --- /dev/null +++ b/EventJournal.DomainService/Exceptions/ResourceNotFoundException.cs @@ -0,0 +1,27 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; + +namespace EventJournal.DomainService.Exceptions { + //TODO: add logging? + //TODO: consider making this a generic NotFoundException that takes the type that was not found? + //TODO: [Serializable] ? + //TODO: is this needed? + public class ResourceNotFoundException : Exception { + public ResourceNotFoundException() { } + + public ResourceNotFoundException(string message) : base(message) { + } + + public ResourceNotFoundException(string? message, Exception? innerException) : base(message, innerException) { + } + + public static void ThrowIfNull([NotNull] object? argument, [CallerArgumentExpression(nameof(argument))] string? paramName = null) { + if (argument is null) { + Throw(paramName); + } + } + + [DoesNotReturn] + internal static void Throw(string? paramName) => throw new ArgumentNullException(paramName); + } +} diff --git a/EventJournal.DomainService/IDefaultDataProvider.cs b/EventJournal.DomainService/IDefaultDataProvider.cs new file mode 100644 index 0000000..a947646 --- /dev/null +++ b/EventJournal.DomainService/IDefaultDataProvider.cs @@ -0,0 +1,6 @@ + +namespace EventJournal.DomainService { + public interface IDefaultDataProvider { + public Task AddResetDefaultDataAsync(); + } +} \ No newline at end of file diff --git a/EventJournal.DomainService/IEventService.cs b/EventJournal.DomainService/IEventService.cs new file mode 100644 index 0000000..0cb672c --- /dev/null +++ b/EventJournal.DomainService/IEventService.cs @@ -0,0 +1,18 @@ +using EventJournal.Data.Entities; +using EventJournal.DomainDto; + +namespace EventJournal.DomainService { + public interface IEventService { + // ======================> Events <====================== + Task> GetAllEventsAsync(); + Task GetEventByIdAsync(Guid resourceId); + Task AddUpdateEventAsync(EventDto dto); + Task> AddUpdateEventsAsync(IEnumerable dtos); + Task DeleteEventAsync(Guid resourceId); + + Task AddUpdateDetailAsync(Guid eventResourceId, DetailDto detailDto); + Task AddUpdateDetailsAsync(Guid eventResourceId, IEnumerable detailDtos); + Task RemoveDetailAsync(Guid eventResourceId, Guid detailResourceId); + Task RemoveAllDetailsAsync(Guid eventResourceId); + } +} \ No newline at end of file diff --git a/EventJournal.DomainService/IInternalUserTypeService.cs b/EventJournal.DomainService/IInternalUserTypeService.cs new file mode 100644 index 0000000..9322ad9 --- /dev/null +++ b/EventJournal.DomainService/IInternalUserTypeService.cs @@ -0,0 +1,8 @@ +using EventJournal.Data.Entities.UserTypes; + +namespace EventJournal.DomainService { + internal interface IInternalUserTypeService : IUserTypesService { + Task GetEventTypeEntityAsync(Guid resourceId); + Task GetDetailTypeEntityAsync(Guid resourceId); + } +} diff --git a/EventJournal.DomainService/IUserTypesService.cs b/EventJournal.DomainService/IUserTypesService.cs new file mode 100644 index 0000000..baddf92 --- /dev/null +++ b/EventJournal.DomainService/IUserTypesService.cs @@ -0,0 +1,25 @@ +using EventJournal.DomainDto.UserTypes; + +namespace EventJournal.DomainService { + public interface IUserTypesService { + + + // ======================> Event Types <====================== + Task> GetAllEventTypesAsync(); + Task GetEventTypeByIdAsync(Guid resourceId); + Task AddUpdateEventTypeAsync(EventTypeDto dto); + Task> AddUpdateEventTypesAsync(IEnumerable dtos); + Task DeleteEventTypeAsync(Guid resourceId); + + // ======================> Detail Types <====================== + Task> GetAllDetailTypesAsync(); + Task GetDetailTypeByIdAsync(Guid resourceId); + Task AddUpdateDetailTypeAsync(DetailTypeDto dto); + Task> AddUpdateDetailTypesAsync(IEnumerable dtos); + Task DeleteDetailTypeAsync(Guid resourceId); + Task AddUpdateAllowedIntensityAsync(Guid detailTypeResourceId, IntensityDto intensityDto); + Task AddUpdateAllowedIntensitiesAsync(Guid detailTypeResourceId, IEnumerable intensityDtos); + Task RemoveAllowedIntensityAsync(Guid detailTypeResourceId, Guid intensityResourceId); + Task RemoveAllAllowedIntensitiesAsync(Guid detailTypeResourceId); + } +} \ No newline at end of file diff --git a/EventJournal.DomainService/UserTypesService.cs b/EventJournal.DomainService/UserTypesService.cs new file mode 100644 index 0000000..e90b8be --- /dev/null +++ b/EventJournal.DomainService/UserTypesService.cs @@ -0,0 +1,139 @@ +using AutoMapper; +using EventJournal.Data.Entities.UserTypes; +using EventJournal.Data.UserTypeRepositories; +using EventJournal.DomainDto.UserTypes; +using EventJournal.DomainService.Exceptions; + +namespace EventJournal.DomainService { + public class UserTypesService( + IEventTypeRepository eventTypeRepository, + IDetailTypeRepository detailTypeRepository, + IMapper mapper) + : IUserTypesService, IInternalUserTypeService { + + + // ======================> Event Types <====================== + public async Task> GetAllEventTypesAsync() { + return mapper.Map>(await eventTypeRepository.GetAllAsync().ConfigureAwait(false)); + } + + public async Task GetEventTypeByIdAsync(Guid resourceId) { + return mapper.Map(await eventTypeRepository.GetByResourceIdAsync(resourceId).ConfigureAwait(false)); + } + + public async Task AddUpdateEventTypeAsync(EventTypeDto dto) { + ArgumentNullException.ThrowIfNull(dto, nameof(dto)); + //TODO: additional validations? + var savedEntity = await AddUpdateEventTypePrivateAsync(mapper.Map(dto)).ConfigureAwait(false); + await eventTypeRepository.SaveChangesAsync().ConfigureAwait(false); + return mapper.Map(savedEntity); + } + private Task AddUpdateEventTypePrivateAsync(EventType entity) { + return eventTypeRepository.AddUpdateAsync(entity); + } + public async Task> AddUpdateEventTypesAsync(IEnumerable dtos) { + ArgumentNullException.ThrowIfNull(dtos, nameof(dtos)); + List eventTypes = []; + foreach (var dto in dtos) { + EventType eventType = await AddUpdateEventTypePrivateAsync(mapper.Map(dto)).ConfigureAwait(false); + eventTypes.AddRange(eventType); + } + await eventTypeRepository.SaveChangesAsync().ConfigureAwait(false); + return mapper.Map>(eventTypes); + } + + public async Task DeleteEventTypeAsync(Guid resourceId) { + var entity = await eventTypeRepository.GetByResourceIdAsync(resourceId).ConfigureAwait(false); + if (entity == null) { + return; + } + eventTypeRepository.Delete(entity); + await eventTypeRepository.SaveChangesAsync().ConfigureAwait(false); + } + + public Task GetEventTypeEntityAsync(Guid resourceId) { + return eventTypeRepository.GetByResourceIdAsync(resourceId); + } + // ======================> Detail Types <====================== + public async Task> GetAllDetailTypesAsync() { + return mapper.Map>(await detailTypeRepository.GetAllAsync().ConfigureAwait(false)); + } + + public async Task GetDetailTypeByIdAsync(Guid resourceId) { + return mapper.Map(await detailTypeRepository.GetByResourceIdAsync(resourceId).ConfigureAwait(false)); + } + + public async Task AddUpdateDetailTypeAsync(DetailTypeDto dto) { + ArgumentNullException.ThrowIfNull(dto, nameof(dto)); + //TODO: additional validations? + var entity = await AddUpdateDetailTypePrivateAsync(mapper.Map(dto)).ConfigureAwait(false); + await detailTypeRepository.SaveChangesAsync().ConfigureAwait(false); + return mapper.Map(entity); + } + private Task AddUpdateDetailTypePrivateAsync(DetailType entity) { + // don't duplicate intensities - match on either Id or ResourceId + foreach (var intensity in entity.AllowedIntensities) { + intensity.DetailType = entity; + var existingIntensity = entity.AllowedIntensities.First(i => i.ResourceId == intensity.ResourceId || (i.Id != 0 && i.Id == intensity.Id)); + if (existingIntensity != null) { + intensity.Id = existingIntensity.Id; + intensity.ResourceId = existingIntensity.ResourceId; + } + } + return detailTypeRepository.AddUpdateAsync(entity); + } + public async Task> AddUpdateDetailTypesAsync(IEnumerable dtos) { + ArgumentNullException.ThrowIfNull(dtos, nameof(dtos)); + List detailTypeEntities = []; + foreach (var dto in dtos) { + detailTypeEntities.Add( await AddUpdateDetailTypePrivateAsync(mapper.Map(dto)).ConfigureAwait(false)); + } + await detailTypeRepository.SaveChangesAsync().ConfigureAwait(false); + return mapper.Map>(detailTypeEntities); + } + + public async Task DeleteDetailTypeAsync(Guid resourceId) { + var entity = await detailTypeRepository.GetByResourceIdAsync(resourceId).ConfigureAwait(false); + if (entity == null) { + return; + } + detailTypeRepository.Delete(entity); + await detailTypeRepository.SaveChangesAsync().ConfigureAwait(false); + } + + public async Task AddUpdateAllowedIntensityAsync(Guid detailTypeResourceId, IntensityDto intensityDto) { + ArgumentNullException.ThrowIfNull(intensityDto, nameof(intensityDto)); + var result = AddUpdateAllowedIntensityPrivateAsync(detailTypeResourceId, mapper.Map(intensityDto)); + await detailTypeRepository.SaveChangesAsync().ConfigureAwait(false); + return mapper.Map(result); + } + private async Task AddUpdateAllowedIntensityPrivateAsync(Guid detailTypeResourceId, Intensity intensity) { + var detailTypeEntity = await detailTypeRepository.GetByResourceIdAsync(detailTypeResourceId).ConfigureAwait(false) ?? throw new ResourceNotFoundException($"DetailType with ResourceId {detailTypeResourceId} not found."); + var result = detailTypeEntity.AddUpdateAllowedIntensity(intensity); + await detailTypeRepository.SaveChangesAsync().ConfigureAwait(false); + return result; + } + public async Task AddUpdateAllowedIntensitiesAsync(Guid detailTypeResourceId, IEnumerable intensityDtos) { + foreach (var intensityDto in intensityDtos) { + await AddUpdateAllowedIntensityPrivateAsync(detailTypeResourceId, mapper.Map(intensityDto)).ConfigureAwait(false); + } + await detailTypeRepository.SaveChangesAsync().ConfigureAwait(false); + } + + public async Task RemoveAllowedIntensityAsync(Guid detailTypeResourceId, Guid intensityResourceId) { + var detailTypeEntity = await detailTypeRepository.GetByResourceIdAsync(detailTypeResourceId).ConfigureAwait(false) ?? throw new ResourceNotFoundException($"DetailType with ResourceId {detailTypeResourceId} not found."); + detailTypeEntity.RemoveIntensity(intensityResourceId); + await detailTypeRepository.SaveChangesAsync().ConfigureAwait(false); + } + + public async Task RemoveAllAllowedIntensitiesAsync(Guid detailTypeResourceId) { + var detailTypeEntity = await detailTypeRepository.GetByResourceIdAsync(detailTypeResourceId).ConfigureAwait(false) ?? throw new ResourceNotFoundException($"DetailType with ResourceId {detailTypeResourceId} not found."); + detailTypeEntity.RemoveAllIntensities(); + await detailTypeRepository.SaveChangesAsync().ConfigureAwait(false); + } + + public Task GetDetailTypeEntityAsync(Guid resourceId) { + return detailTypeRepository.GetByResourceIdAsync(resourceId); + } + } +} diff --git a/EventJournal.Models/EventDataResponseModel.cs b/EventJournal.Models/EventDataResponseModel.cs new file mode 100644 index 0000000..6996f35 --- /dev/null +++ b/EventJournal.Models/EventDataResponseModel.cs @@ -0,0 +1,10 @@ +using EventJournal.DomainDto; +using EventJournal.DomainDto.UserTypes; + +namespace EventJournal.PublicModels { + public class EventDataResponseModel { + public required IList DetailTypes { get; set; } + public required IList EventTypes { get; set; } + public required IList Events { get; set; } + } +} \ No newline at end of file diff --git a/EventJournal.Models/EventJournal.PublicModels.csproj b/EventJournal.Models/EventJournal.PublicModels.csproj new file mode 100644 index 0000000..27241db --- /dev/null +++ b/EventJournal.Models/EventJournal.PublicModels.csproj @@ -0,0 +1,25 @@ + + + + net9.0 + enable + enable + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/EventJournal.WebAPI/Controllers/WeatherForecastController.cs b/EventJournal.WebAPI/Controllers/WeatherForecastController.cs new file mode 100644 index 0000000..34af65b --- /dev/null +++ b/EventJournal.WebAPI/Controllers/WeatherForecastController.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Mvc; + +namespace EventJournal.WebAPI.Controllers { + [ApiController] + [Route("[controller]")] + public class WeatherForecastController : ControllerBase { + private static readonly string[] Summaries = new[] + { + "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" + }; + + private readonly ILogger _logger; + + public WeatherForecastController(ILogger logger) { + _logger = logger; + } + + [HttpGet(Name = "GetWeatherForecast")] + public IEnumerable Get() { + return Enumerable.Range(1, 5).Select(index => new WeatherForecast { + Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)), + TemperatureC = Random.Shared.Next(-20, 55), + Summary = Summaries[Random.Shared.Next(Summaries.Length)] + }) + .ToArray(); + } + } +} diff --git a/EventJournal.WebAPI/Dockerfile b/EventJournal.WebAPI/Dockerfile new file mode 100644 index 0000000..b2d300c --- /dev/null +++ b/EventJournal.WebAPI/Dockerfile @@ -0,0 +1,30 @@ +# See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging. + +# This stage is used when running from VS in fast mode (Default for Debug configuration) +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +USER $APP_UID +WORKDIR /app +EXPOSE 8080 +EXPOSE 8081 + + +# This stage is used to build the service project +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["EventJournal.WebAPI/EventJournal.-WebAPI.csproj", "EventJournal.WebAPI/"] +RUN dotnet restore "./EventJournal.WebAPI/EventJournal.WebAPI.csproj" +COPY . . +WORKDIR "/src/EventJournal.WebAPI" +RUN dotnet build "./EventJournal.WebAPI.csproj" -c $BUILD_CONFIGURATION -o /app/build + +# This stage is used to publish the service project to be copied to the final stage +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "./EventJournal.WebAPI.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +# This stage is used in production or when running from VS in regular mode (Default when not using the Debug configuration) +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "EventJournal.WebAPI.dll"] \ No newline at end of file diff --git a/EventJournal.WebAPI/EventJournal.WebAPI.csproj b/EventJournal.WebAPI/EventJournal.WebAPI.csproj new file mode 100644 index 0000000..27c6517 --- /dev/null +++ b/EventJournal.WebAPI/EventJournal.WebAPI.csproj @@ -0,0 +1,30 @@ + + + + net9.0 + enable + enable + EventJournal.WebAPI + 8c63a320-2c3e-49fe-b702-bc5449d6d6fa + Linux + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + diff --git a/EventJournal.WebAPI/EventJournal.WebAPI.http b/EventJournal.WebAPI/EventJournal.WebAPI.http new file mode 100644 index 0000000..3ce565b --- /dev/null +++ b/EventJournal.WebAPI/EventJournal.WebAPI.http @@ -0,0 +1,6 @@ +@EventJournal.WebAPI_HostAddress = http://localhost:5256 + +GET {{EventJournal.WebAPI_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/EventJournal.WebAPI/Program.cs b/EventJournal.WebAPI/Program.cs new file mode 100644 index 0000000..607f356 --- /dev/null +++ b/EventJournal.WebAPI/Program.cs @@ -0,0 +1,28 @@ +internal class Program { + private static void Main(string[] args) { + var builder = WebApplication.CreateBuilder(args); + + // Add services to the container. + + builder.Services.AddControllers(); + // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle + builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddSwaggerGen(); + + var app = builder.Build(); + + // Configure the HTTP request pipeline. + if (app.Environment.IsDevelopment()) { + app.UseSwagger(); + app.UseSwaggerUI(); + } + + app.UseHttpsRedirection(); + + app.UseAuthorization(); + + app.MapControllers(); + + app.Run(); + } +} \ No newline at end of file diff --git a/EventJournal.WebAPI/Properties/launchSettings.json b/EventJournal.WebAPI/Properties/launchSettings.json new file mode 100644 index 0000000..bf7534f --- /dev/null +++ b/EventJournal.WebAPI/Properties/launchSettings.json @@ -0,0 +1,52 @@ +{ + "profiles": { + "http": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true, + "applicationUrl": "http://localhost:5256" + }, + "https": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true, + "applicationUrl": "https://localhost:7083;http://localhost:5256" + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Container (Dockerfile)": { + "commandName": "Docker", + "launchBrowser": true, + "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}/swagger", + "environmentVariables": { + "ASPNETCORE_HTTPS_PORTS": "8081", + "ASPNETCORE_HTTP_PORTS": "8080" + }, + "publishAllPorts": true, + "useSSL": true + } + }, + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:38163", + "sslPort": 44369 + } + } +} \ No newline at end of file diff --git a/EventJournal.WebAPI/WeatherForecast.cs b/EventJournal.WebAPI/WeatherForecast.cs new file mode 100644 index 0000000..f9177ed --- /dev/null +++ b/EventJournal.WebAPI/WeatherForecast.cs @@ -0,0 +1,11 @@ +namespace EventJournal.WebAPI { + public class WeatherForecast { + public DateOnly Date { get; set; } + + public int TemperatureC { get; set; } + + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); + + public string? Summary { get; set; } + } +} diff --git a/EventJournal.WebAPI/appsettings.Development.json b/EventJournal.WebAPI/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/EventJournal.WebAPI/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/EventJournal.WebAPI/appsettings.json b/EventJournal.WebAPI/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/EventJournal.WebAPI/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/EventJournal.sln b/EventJournal.sln new file mode 100644 index 0000000..afa22b1 --- /dev/null +++ b/EventJournal.sln @@ -0,0 +1,73 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.12.35521.163 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EventJournal.CLI", "EventJournal.CLI\EventJournal.CLI.csproj", "{D0E93737-A883-4020-9C99-E6C1D70D1237}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{994FEE9A-A85B-4AEC-894B-CD7FEDF3FC09}" + ProjectSection(SolutionItems) = preProject + LICENSE = LICENSE + README.md = README.md + EndProjectSection +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EventJournal.WebAPI", "EventJournal.WebAPI\EventJournal.WebAPI.csproj", "{AFC2B8DD-6CDE-450F-9181-93B2B67983D7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EventJournal.Data", "EventJournal.Data\EventJournal.Data.csproj", "{CD1F9E2E-4F39-4E6C-B018-B3BF858D57A5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EventJournal.DomainService", "EventJournal.DomainService\EventJournal.DomainService.csproj", "{EB6275FF-4E73-466D-9A44-EFDBF992CA75}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EventJournal.DomainDto", "EventJournal.DomainModels\EventJournal.DomainDto.csproj", "{153C0741-E492-490C-8C90-FB5805FD0AFE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EventJournal.PublicModels", "EventJournal.Models\EventJournal.PublicModels.csproj", "{2EE6003E-F3DD-471A-9DDA-8CF9A7602005}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EventJournal.Common", "EventJournal.Common\EventJournal.Common.csproj", "{1F36B474-30D4-4170-871A-76EEB7851439}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EventJournal.BootStrap", "Eventjournal.Bootstrap\EventJournal.BootStrap.csproj", "{F07A04E5-0066-4E61-B783-24FC8E95D96C}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {D0E93737-A883-4020-9C99-E6C1D70D1237}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D0E93737-A883-4020-9C99-E6C1D70D1237}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D0E93737-A883-4020-9C99-E6C1D70D1237}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D0E93737-A883-4020-9C99-E6C1D70D1237}.Release|Any CPU.Build.0 = Release|Any CPU + {AFC2B8DD-6CDE-450F-9181-93B2B67983D7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AFC2B8DD-6CDE-450F-9181-93B2B67983D7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AFC2B8DD-6CDE-450F-9181-93B2B67983D7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AFC2B8DD-6CDE-450F-9181-93B2B67983D7}.Release|Any CPU.Build.0 = Release|Any CPU + {CD1F9E2E-4F39-4E6C-B018-B3BF858D57A5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CD1F9E2E-4F39-4E6C-B018-B3BF858D57A5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CD1F9E2E-4F39-4E6C-B018-B3BF858D57A5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CD1F9E2E-4F39-4E6C-B018-B3BF858D57A5}.Release|Any CPU.Build.0 = Release|Any CPU + {EB6275FF-4E73-466D-9A44-EFDBF992CA75}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EB6275FF-4E73-466D-9A44-EFDBF992CA75}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EB6275FF-4E73-466D-9A44-EFDBF992CA75}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EB6275FF-4E73-466D-9A44-EFDBF992CA75}.Release|Any CPU.Build.0 = Release|Any CPU + {153C0741-E492-490C-8C90-FB5805FD0AFE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {153C0741-E492-490C-8C90-FB5805FD0AFE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {153C0741-E492-490C-8C90-FB5805FD0AFE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {153C0741-E492-490C-8C90-FB5805FD0AFE}.Release|Any CPU.Build.0 = Release|Any CPU + {2EE6003E-F3DD-471A-9DDA-8CF9A7602005}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2EE6003E-F3DD-471A-9DDA-8CF9A7602005}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2EE6003E-F3DD-471A-9DDA-8CF9A7602005}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2EE6003E-F3DD-471A-9DDA-8CF9A7602005}.Release|Any CPU.Build.0 = Release|Any CPU + {1F36B474-30D4-4170-871A-76EEB7851439}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1F36B474-30D4-4170-871A-76EEB7851439}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1F36B474-30D4-4170-871A-76EEB7851439}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1F36B474-30D4-4170-871A-76EEB7851439}.Release|Any CPU.Build.0 = Release|Any CPU + {F07A04E5-0066-4E61-B783-24FC8E95D96C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F07A04E5-0066-4E61-B783-24FC8E95D96C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F07A04E5-0066-4E61-B783-24FC8E95D96C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F07A04E5-0066-4E61-B783-24FC8E95D96C}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {50C11A8B-C5F5-42E2-BCBB-F012EE87507C} + EndGlobalSection +EndGlobal diff --git a/Eventjournal.Bootstrap/DefaultApplicationBootStrapper.cs b/Eventjournal.Bootstrap/DefaultApplicationBootStrapper.cs new file mode 100644 index 0000000..95532b9 --- /dev/null +++ b/Eventjournal.Bootstrap/DefaultApplicationBootStrapper.cs @@ -0,0 +1,20 @@ +using EventJournal.BootStrap.Installers; +using EventJournal.Common.Bootstrap; + +namespace EventJournal.BootStrap { + public class DefaultApplicationBootStrapper : BootStrapper { + public DefaultApplicationBootStrapper() { + installers = [ + new DatabaseContextInstaller(), + new DefaultDataProviderInstaller(), + new DistributedLockInstaller(), + new DomainServiceInstaller(), + new JsonOptionsInstaller(), + new LoggingInstaller(), + new MiniProfilerInstaller(), + new ModelMapperInstaller(), + new RepositoryInstaller(), + ]; + } + } +} diff --git a/Eventjournal.Bootstrap/EventJournal.BootStrap.csproj b/Eventjournal.Bootstrap/EventJournal.BootStrap.csproj new file mode 100644 index 0000000..96fc1ff --- /dev/null +++ b/Eventjournal.Bootstrap/EventJournal.BootStrap.csproj @@ -0,0 +1,26 @@ + + + + net9.0 + enable + enable + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/Eventjournal.Bootstrap/Installers/DatabaseContextInstaller.cs b/Eventjournal.Bootstrap/Installers/DatabaseContextInstaller.cs new file mode 100644 index 0000000..7231695 --- /dev/null +++ b/Eventjournal.Bootstrap/Installers/DatabaseContextInstaller.cs @@ -0,0 +1,18 @@ +using EventJournal.Common.Bootstrap; +using EventJournal.Common.Configuration; +using EventJournal.Data; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace EventJournal.BootStrap.Installers { + public class DatabaseContextInstaller : IInstaller { + public void Install(IServiceCollection services, IConfiguration configuration) { + + var dbSettings = configuration.GetSection(DatabaseSettings.ConfigurationSectionName).Get() + ?? throw new InvalidOperationException("Configuration settings does not contain a valid DatabaseSettings section."); + services.AddSingleton(dbSettings); + + services.AddDbContext(o => DatabaseContext.ConfigureFromSettings(o, dbSettings)); + } + } +} diff --git a/Eventjournal.Bootstrap/Installers/DefaultDataProviderInstaller.cs b/Eventjournal.Bootstrap/Installers/DefaultDataProviderInstaller.cs new file mode 100644 index 0000000..5ef3742 --- /dev/null +++ b/Eventjournal.Bootstrap/Installers/DefaultDataProviderInstaller.cs @@ -0,0 +1,12 @@ +using EventJournal.Common.Bootstrap; +using EventJournal.DomainService; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace EventJournal.BootStrap.Installers { + public class DefaultDataProviderInstaller : IInstaller { + public void Install(IServiceCollection services, IConfiguration configuration) { + services.AddScoped(); + } + } +} diff --git a/Eventjournal.Bootstrap/Installers/DistributedLockInstaller.cs b/Eventjournal.Bootstrap/Installers/DistributedLockInstaller.cs new file mode 100644 index 0000000..c77501d --- /dev/null +++ b/Eventjournal.Bootstrap/Installers/DistributedLockInstaller.cs @@ -0,0 +1,20 @@ +using EventJournal.Common.Bootstrap; +using EventJournal.Common.Configuration; +using EventJournal.Data; +using Medallion.Threading; +using Medallion.Threading.MySql; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace EventJournal.BootStrap.Installers { + public class DistributedLockInstaller : IInstaller { + public void Install(IServiceCollection services, IConfiguration configuration) { + var dbSettings = configuration.GetSection(DatabaseSettings.ConfigurationSectionName).Get() + ?? throw new InvalidOperationException("Configuration settings does not contain a valid DatabaseSettings section."); + + // TODO: configure based on Database PRovider from settings + IDistributedLockProvider provider = new MySqlDistributedSynchronizationProvider(DatabaseContext.GetConnectionString(dbSettings)); + services.AddSingleton(provider); + } + } +} diff --git a/Eventjournal.Bootstrap/Installers/DomainServiceInstaller.cs b/Eventjournal.Bootstrap/Installers/DomainServiceInstaller.cs new file mode 100644 index 0000000..0533142 --- /dev/null +++ b/Eventjournal.Bootstrap/Installers/DomainServiceInstaller.cs @@ -0,0 +1,12 @@ +using EventJournal.Common.Bootstrap; +using EventJournal.DomainService; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace EventJournal.BootStrap.Installers { + public class DomainServiceInstaller : IInstaller { + public void Install(IServiceCollection services, IConfiguration configuration) { + services.AddScopedInterfacesBySuffix("Service"); + } + } +} diff --git a/Eventjournal.Bootstrap/Installers/JsonOptionsInstaller.cs b/Eventjournal.Bootstrap/Installers/JsonOptionsInstaller.cs new file mode 100644 index 0000000..b91c1aa --- /dev/null +++ b/Eventjournal.Bootstrap/Installers/JsonOptionsInstaller.cs @@ -0,0 +1,13 @@ +using EventJournal.Common.Bootstrap; +using EventJournal.Common.Configuration; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace EventJournal.BootStrap.Installers { + public class JsonOptionsInstaller : IInstaller { + public void Install(IServiceCollection services, IConfiguration configuration) { + // make changes to JsonSerializerOptions in the static JsonSerializerSettings if needed + services.AddSingleton(JsonSerializerSettings.JsonSerializerOptions); + } + } +} diff --git a/Eventjournal.Bootstrap/Installers/LoggingInstaller.cs b/Eventjournal.Bootstrap/Installers/LoggingInstaller.cs new file mode 100644 index 0000000..189d0fa --- /dev/null +++ b/Eventjournal.Bootstrap/Installers/LoggingInstaller.cs @@ -0,0 +1,21 @@ +using EventJournal.Common.Bootstrap; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace EventJournal.BootStrap.Installers { + public class LoggingInstaller : IInstaller { + public void Install(IServiceCollection services, IConfiguration configuration) { + // TODO: Make logging configuration driven eg. build options from config + services.AddLogging(options => { + options.AddDebug(); + options.SetMinimumLevel(LogLevel.Error); + options.AddSimpleConsole(options => { + options.SingleLine = true; + options.TimestampFormat = "HH:mm:ss.fff "; + options.ColorBehavior = Microsoft.Extensions.Logging.Console.LoggerColorBehavior.Enabled; + }); + }); + } + } +} diff --git a/Eventjournal.Bootstrap/Installers/MiniProfilerInstaller.cs b/Eventjournal.Bootstrap/Installers/MiniProfilerInstaller.cs new file mode 100644 index 0000000..148660c --- /dev/null +++ b/Eventjournal.Bootstrap/Installers/MiniProfilerInstaller.cs @@ -0,0 +1,20 @@ +using EventJournal.Common.Bootstrap; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace EventJournal.BootStrap.Installers { + public class MiniProfilerInstaller : IInstaller { + public void Install(IServiceCollection services, IConfiguration configuration) { + services.AddMiniProfiler(options => { + options.RouteBasePath = "/profiler"; + + // Control which SQL formatter to use, InlineFormatter is the default + options.SqlFormatter = new StackExchange.Profiling.SqlFormatters.SqlServerFormatter(); + options.ColorScheme = StackExchange.Profiling.ColorScheme.Auto; + + // Enabled sending the Server-Timing header on responses + options.EnableServerTimingHeader = true; + }).AddEntityFramework(); + } + } +} diff --git a/Eventjournal.Bootstrap/Installers/ModelMapperInstaller.cs b/Eventjournal.Bootstrap/Installers/ModelMapperInstaller.cs new file mode 100644 index 0000000..e97f497 --- /dev/null +++ b/Eventjournal.Bootstrap/Installers/ModelMapperInstaller.cs @@ -0,0 +1,12 @@ +using EventJournal.Common.Bootstrap; +using EventJournal.DomainService; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace EventJournal.BootStrap.Installers { + public class ModelMapperInstaller :IInstaller { + public void Install(IServiceCollection services, IConfiguration configuration) { + services.AddAutoMapper(cfg => { }, typeof(DomainMapperProfile)); + } + } +} diff --git a/Eventjournal.Bootstrap/Installers/RepositoryInstaller.cs b/Eventjournal.Bootstrap/Installers/RepositoryInstaller.cs new file mode 100644 index 0000000..fab123d --- /dev/null +++ b/Eventjournal.Bootstrap/Installers/RepositoryInstaller.cs @@ -0,0 +1,12 @@ +using EventJournal.Common.Bootstrap; +using EventJournal.Data; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace EventJournal.BootStrap.Installers { + public class RepositoryInstaller : IInstaller { + public void Install(IServiceCollection services, IConfiguration configuration) { + services.AddScopedInterfacesBySuffix("Repository"); + } + } +} diff --git a/README.md b/README.md index a507064..4125f0d 100644 --- a/README.md +++ b/README.md @@ -1 +1,135 @@ # Event Journal + +Collect basic timestamped event data with the intent that the data can be analyzed/mined to see longer term patterns. + +Originally this was intended to track symptoms and intensity. But evolved into more of an event tracker system. +The concept of Intensity is still a bit muddy, but the idea is that events of a particular even type can be rated on a user defined scale. For example a headache event can have a pain intensity rating. Where an exercise type event could have a distance or workout intensity. + + - [Features](#features) + - [NOTES](#notes) + - [Entities](#entities) + - [Intensity Examples](#intensity-examples) + - [Pain level could be something like](#pain-level-could-be-something-like) + - [Bleeding could be](#bleeding-could-be) + - [Exercise Intensity could be](#exercise-intensity-could-be) + - [Entity Framework help](#entity-framework-help) + - [TODO](#todo) + - [NEXT STEPS](#next-steps) + - [Future TODOs](#future-todos) + - [Future Considerations](#future-considerations) + +## Features + +- User definable event types (ex. exercise, meal, headache ) +- USer definable detail types (ex. muscle pain, bleeding, restaurant or food eaten, pain location, pain level, nausea ) +- User definable detail intensity descriptions (tied to numeric values for graphing/data presentation purposes) + +## NOTES + +### Entities + +Events are the central entity. An `Event` has an `EventType` and one or more `Detail` records. Each `Detail` record has a `DetailType` and an `Intensity`. +`EventType`, `DetailType`, and `Intensity` are user defined types. + +### Intensity Examples + +#### Pain level could be something like + +- None (0) +- Slight to mild Discomfort (1) +- I need an OTC painkiller (2) +- I'm going to limit my activity (3) +- I can't function normally (4) +- Take me to the ER (5) + +#### Bleeding could be + +- None (0) +- Trace (on TP only) (1) +- Minor (drops visible in water) (2) +- Major (water mostly red) (3) +- Intense (water completely red) (4) + +#### Exercise Intensity could be +- Took it easy (0) +- Pushed a bit (1) +- Heart Pumping and light sweating (2) +- Breathing Hard and sweating good (3) +- Hardcore Must take it easy tomorrow (4) + +#### Entity Framework help +EF https://learn.microsoft.com/en-us/ef/core/get-started/overview/first-app?tabs=netcore-cli + + + + +### TODO +#### NEXT STEPS +- update dotnet EF to latest version - seems to be somethign wrong with runnign dotnet tool update -g + - might have version conflicts with version 9 vs version 10 +- Consider updatating to .net 10 +- finnish add config classes to handle settings including adding the new classes to bootstrap installers + - not sure if something is wrong with DB config but comments in the code suggest there might be + - need to refamiliarize self with how configuration is configured here + - extend dbsettings to support other db providers (sqlite, postgresql, mysql, etc) + - see if TODO item about supporting other db providers in dbcontext static methods can be done here + - see if TODO item in databasecontext about the empty constructor being needed + - TEST EF Migrations with new db context configuration code + - this means adding nuget references for the other db providers + - as well as extending the db settings class to have provider type + - and rounding out the static methods in dbcontext itself to handle other providers + - TEST EF migrations with other db providers + - might need to add a config class for default data settings + - might need to add a config class for logging settings + - might need to add a config class for web api settings (cors, etc) + - might need to add a config class for cli settings (verbosity, etc) + - might need to add nuget reference for Microsoft.Extensions.Configuration.Binder +- scan code for TODO comments and address them or add them to future todo list +- default data seeding + - ?add option to supply default data from config? +- add ability to add data in the cli project + - add commands to add event types + - add commands to add detail types (with allowed intensities) + - add commands to add events (with details) +- still need to test adding event with details at the same time +- unit tests for + - repositories + - entity methods + - entity validators (if added) + - services + - mappers? + - dto helpers? + - bootstrapper + - other helpers/extensions? + - ?? web api controllers +#### Future TODOs +- test full get event with details +- test full get detail type with allowed intensities +- add endpoints for: + - get all event types + - get all detail types with allowed intensities + - get all events (with details) + - get event by resource id (with details) + - get event type by resource id + - get detail type by resource id (with allowed intensities) + - add event type + - add detail type (with allowed intensities) + - add event (with details) + - update event type + - update detail type (with allowed intensities) + - update event (with details) +- Replace autoMapper with custom mappers +- add swagger to web api project +- Add common BootStrap code to be consumed by cli and web api projects + - remove microsoft.extension.hosting pkg where not needed +- move initializer code to an appropriate place (still needed?) + - want to let user get some defaults to start with + - but also wan to use these defaults for testing +- Entity validators +### Future Considerations +- basic CRUD web UI + - want to add the ability to add new types on the fly as a new event is added +- Integration tests for web api. +- `Event` entries could also have user defined tags. (Maybe `Details` could have tags too?) +not sure how to implement tags just yet, maybe a tag entity and table +and a tags list table (with an id) and have journal entry track tag-list id