From 608def3ac82a6c66f980f9719cc99c372f01a5c2 Mon Sep 17 00:00:00 2001 From: Holden <122419606+midozen@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:43:59 -0500 Subject: [PATCH] Add server configuration service and repo --- API/Controllers/Config/V1/ConfigController.cs | 12 ++- RecNet.Application/DependencyInjection.cs | 3 + .../Services/Configuration/ConfigService.cs | 33 ++++++++ .../Services/Configuration/IConfigService.cs | 7 ++ .../Entities/Configuration/ServerConfig.cs | 24 ++++++ RecNet.Domain/RecNet.Domain.csproj | 4 - .../Repositories/IServerConfigRepository.cs | 10 +++ RecNet.Infrastructure/DependencyInjection.cs | 1 + .../ServerConfigConfiguration.cs | 21 +++++ .../Persistence/DatabaseContext.cs | 2 + .../20260618183916_ServerConfigs.Designer.cs | 81 +++++++++++++++++++ .../20260618183916_ServerConfigs.cs | 33 ++++++++ .../DatabaseContextModelSnapshot.cs | 15 ++++ .../Repositories/ProfileRepository.cs | 5 +- .../Repositories/ServerConfigRepository.cs | 30 +++++++ 15 files changed, 272 insertions(+), 9 deletions(-) create mode 100644 RecNet.Application/Services/Configuration/ConfigService.cs create mode 100644 RecNet.Application/Services/Configuration/IConfigService.cs create mode 100644 RecNet.Domain/Entities/Configuration/ServerConfig.cs create mode 100644 RecNet.Domain/Repositories/IServerConfigRepository.cs create mode 100644 RecNet.Infrastructure/Persistence/Configurations/ServerConfigConfiguration.cs create mode 100644 RecNet.Infrastructure/Persistence/Migrations/20260618183916_ServerConfigs.Designer.cs create mode 100644 RecNet.Infrastructure/Persistence/Migrations/20260618183916_ServerConfigs.cs create mode 100644 RecNet.Infrastructure/Persistence/Repositories/ServerConfigRepository.cs diff --git a/API/Controllers/Config/V1/ConfigController.cs b/API/Controllers/Config/V1/ConfigController.cs index 62628ca..2a44e0a 100644 --- a/API/Controllers/Config/V1/ConfigController.cs +++ b/API/Controllers/Config/V1/ConfigController.cs @@ -1,13 +1,17 @@ using Microsoft.AspNetCore.Mvc; +using RecNet.Application.Services.Configuration; namespace API.Controllers.Config.V1; [Route("api/[controller]/v1")] [ApiController] -public class ConfigController : ControllerBase +public class ConfigController(IConfigService configService) : ControllerBase { - // TODO: Resolve from Database [HttpGet("motd")] - public ActionResult GetMotd() - => Ok("Hello World!"); + public async Task> GetMotd(CancellationToken ct = default) + { + var motd = await configService.GetAsync("Config:MOTD", "Ten Whole Years!", ct); + + return Ok(motd); + } } \ No newline at end of file diff --git a/RecNet.Application/DependencyInjection.cs b/RecNet.Application/DependencyInjection.cs index 4a2c78c..9984404 100644 --- a/RecNet.Application/DependencyInjection.cs +++ b/RecNet.Application/DependencyInjection.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using RecNet.Application.Services.Configuration; namespace RecNet.Application; @@ -7,6 +8,8 @@ public static class DependencyInjection public static IServiceCollection AddApplication(this IServiceCollection services) { services.AddAutoMapper(_ => { }, typeof(DependencyInjection).Assembly); + + services.AddScoped(); return services; } diff --git a/RecNet.Application/Services/Configuration/ConfigService.cs b/RecNet.Application/Services/Configuration/ConfigService.cs new file mode 100644 index 0000000..a7130b1 --- /dev/null +++ b/RecNet.Application/Services/Configuration/ConfigService.cs @@ -0,0 +1,33 @@ +using System.Text.Json; +using RecNet.Domain.Repositories; + +namespace RecNet.Application.Services.Configuration; + +public class ConfigService(IServerConfigRepository repository) : IConfigService +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + public async Task GetAsync( + string key, + T? defaultValue = default, + CancellationToken ct = default) + { + var config = await repository.GetAsync(key, ct); + if (config == null) + return defaultValue; + + return JsonSerializer.Deserialize(config.Value, JsonOptions); + } + + public async Task SetAsync( + string key, + T value, + CancellationToken ct = default) + { + var json = JsonSerializer.Serialize(value, JsonOptions); + + await repository.SetAsync(key, json, ct); + + await repository.SaveChangesAsync(ct); + } +} \ No newline at end of file diff --git a/RecNet.Application/Services/Configuration/IConfigService.cs b/RecNet.Application/Services/Configuration/IConfigService.cs new file mode 100644 index 0000000..6a036b3 --- /dev/null +++ b/RecNet.Application/Services/Configuration/IConfigService.cs @@ -0,0 +1,7 @@ +namespace RecNet.Application.Services.Configuration; + +public interface IConfigService +{ + Task GetAsync(string key, T? defaultValue = default, CancellationToken ct = default); + Task SetAsync(string key, T value, CancellationToken ct = default); +} \ No newline at end of file diff --git a/RecNet.Domain/Entities/Configuration/ServerConfig.cs b/RecNet.Domain/Entities/Configuration/ServerConfig.cs new file mode 100644 index 0000000..0c987f0 --- /dev/null +++ b/RecNet.Domain/Entities/Configuration/ServerConfig.cs @@ -0,0 +1,24 @@ +using RecNet.Domain.Exceptions; + +namespace RecNet.Domain.Entities.Configuration; + +public class ServerConfig +{ + private ServerConfig(string key, string value) + { + Key = RequireValue(key, nameof(key)); + Value = value; + } + + public string Key { get; private set; } + public string Value { get; private set; } + + public static ServerConfig Create(string key, string value) + => new(key, value); + + public void SetValue(string value) + => Value = value; + + private static string RequireValue(string value, string parameterName) + => !string.IsNullOrWhiteSpace(value) ? value : throw new DomainException($"{parameterName} is required."); +} \ No newline at end of file diff --git a/RecNet.Domain/RecNet.Domain.csproj b/RecNet.Domain/RecNet.Domain.csproj index 57d42eb..237d661 100644 --- a/RecNet.Domain/RecNet.Domain.csproj +++ b/RecNet.Domain/RecNet.Domain.csproj @@ -6,8 +6,4 @@ enable - - - - diff --git a/RecNet.Domain/Repositories/IServerConfigRepository.cs b/RecNet.Domain/Repositories/IServerConfigRepository.cs new file mode 100644 index 0000000..6d9bf36 --- /dev/null +++ b/RecNet.Domain/Repositories/IServerConfigRepository.cs @@ -0,0 +1,10 @@ +using RecNet.Domain.Entities.Configuration; + +namespace RecNet.Domain.Repositories; + +public interface IServerConfigRepository +{ + Task GetAsync(string key, CancellationToken ct = default); + Task SetAsync(string key, string value, CancellationToken ct = default); + Task SaveChangesAsync(CancellationToken ct = default); +} \ No newline at end of file diff --git a/RecNet.Infrastructure/DependencyInjection.cs b/RecNet.Infrastructure/DependencyInjection.cs index a5e4f42..5e74219 100644 --- a/RecNet.Infrastructure/DependencyInjection.cs +++ b/RecNet.Infrastructure/DependencyInjection.cs @@ -14,6 +14,7 @@ public static class DependencyInjection builder.AddNpgsqlDbContext("recnet"); builder.Services.AddScoped(); + builder.Services.AddScoped(); return builder; } diff --git a/RecNet.Infrastructure/Persistence/Configurations/ServerConfigConfiguration.cs b/RecNet.Infrastructure/Persistence/Configurations/ServerConfigConfiguration.cs new file mode 100644 index 0000000..fb57ebf --- /dev/null +++ b/RecNet.Infrastructure/Persistence/Configurations/ServerConfigConfiguration.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using RecNet.Domain.Entities.Configuration; + +namespace RecNet.Infrastructure.Persistence.Configurations; + +public class ServerConfigConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(x => x.Key); + + builder.Property(x => x.Key) + .HasMaxLength(128) + .IsRequired(); + + builder.Property(x => x.Value) + .HasColumnType("jsonb") + .IsRequired(); + } +} \ No newline at end of file diff --git a/RecNet.Infrastructure/Persistence/DatabaseContext.cs b/RecNet.Infrastructure/Persistence/DatabaseContext.cs index 24f11dd..bc778c5 100644 --- a/RecNet.Infrastructure/Persistence/DatabaseContext.cs +++ b/RecNet.Infrastructure/Persistence/DatabaseContext.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using RecNet.Domain.Entities.Configuration; using RecNet.Domain.Entities.Profiles; namespace RecNet.Infrastructure.Persistence; @@ -6,6 +7,7 @@ namespace RecNet.Infrastructure.Persistence; public class DatabaseContext(DbContextOptions options) : DbContext(options) { public DbSet Profiles => Set(); + public DbSet ServerConfigs => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/RecNet.Infrastructure/Persistence/Migrations/20260618183916_ServerConfigs.Designer.cs b/RecNet.Infrastructure/Persistence/Migrations/20260618183916_ServerConfigs.Designer.cs new file mode 100644 index 0000000..18007ff --- /dev/null +++ b/RecNet.Infrastructure/Persistence/Migrations/20260618183916_ServerConfigs.Designer.cs @@ -0,0 +1,81 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using RecNet.Infrastructure.Persistence; + +#nullable disable + +namespace RecNet.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20260618183916_ServerConfigs")] + partial class ServerConfigs + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("RecNet.Domain.Entities.Configuration.ServerConfig", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Key"); + + b.ToTable("ServerConfigs"); + }); + + modelBuilder.Entity("RecNet.Domain.Entities.Profiles.Profile", b => + { + b.Property("ProfileId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection("DeviceIds") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PlatformId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("ProfileId"); + + b.HasIndex("Platform", "PlatformId") + .IsUnique(); + + b.ToTable("Profiles"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/RecNet.Infrastructure/Persistence/Migrations/20260618183916_ServerConfigs.cs b/RecNet.Infrastructure/Persistence/Migrations/20260618183916_ServerConfigs.cs new file mode 100644 index 0000000..be034f7 --- /dev/null +++ b/RecNet.Infrastructure/Persistence/Migrations/20260618183916_ServerConfigs.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RecNet.Infrastructure.Persistence.Migrations +{ + /// + public partial class ServerConfigs : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ServerConfigs", + columns: table => new + { + Key = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + Value = table.Column(type: "jsonb", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ServerConfigs", x => x.Key); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ServerConfigs"); + } + } +} diff --git a/RecNet.Infrastructure/Persistence/Migrations/DatabaseContextModelSnapshot.cs b/RecNet.Infrastructure/Persistence/Migrations/DatabaseContextModelSnapshot.cs index 3f3d811..782eb17 100644 --- a/RecNet.Infrastructure/Persistence/Migrations/DatabaseContextModelSnapshot.cs +++ b/RecNet.Infrastructure/Persistence/Migrations/DatabaseContextModelSnapshot.cs @@ -22,6 +22,21 @@ namespace RecNet.Infrastructure.Persistence.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("RecNet.Domain.Entities.Configuration.ServerConfig", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Key"); + + b.ToTable("ServerConfigs"); + }); + modelBuilder.Entity("RecNet.Domain.Entities.Profiles.Profile", b => { b.Property("ProfileId") diff --git a/RecNet.Infrastructure/Persistence/Repositories/ProfileRepository.cs b/RecNet.Infrastructure/Persistence/Repositories/ProfileRepository.cs index 7cb69b4..8427d25 100644 --- a/RecNet.Infrastructure/Persistence/Repositories/ProfileRepository.cs +++ b/RecNet.Infrastructure/Persistence/Repositories/ProfileRepository.cs @@ -20,7 +20,10 @@ public class ProfileRepository(DatabaseContext dbContext) : IProfileRepository .Where(x => profileIds.Contains(x.ProfileId)) .ToListAsync(ct); - public Task GetByPlatform(PlatformType platform, string platformId, CancellationToken ct = default) + public Task GetByPlatform( + PlatformType platform, + string platformId, + CancellationToken ct = default) => dbContext.Profiles .FirstOrDefaultAsync(x => x.Platform == platform && diff --git a/RecNet.Infrastructure/Persistence/Repositories/ServerConfigRepository.cs b/RecNet.Infrastructure/Persistence/Repositories/ServerConfigRepository.cs new file mode 100644 index 0000000..85254c6 --- /dev/null +++ b/RecNet.Infrastructure/Persistence/Repositories/ServerConfigRepository.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; +using RecNet.Domain.Entities.Configuration; +using RecNet.Domain.Repositories; + +namespace RecNet.Infrastructure.Persistence.Repositories; + +public class ServerConfigRepository(DatabaseContext dbContext) : IServerConfigRepository +{ + public Task GetAsync(string key, CancellationToken ct = default) + => dbContext.ServerConfigs + .FirstOrDefaultAsync(x => x.Key == key, ct); + + public async Task SetAsync(string key, string value, CancellationToken ct = default) + { + var config = await GetAsync(key, ct); + + if (config is null) + { + config = ServerConfig.Create(key, value); + await dbContext.ServerConfigs.AddAsync(config, ct); + return; + } + + config.SetValue(value); + } + + public Task SaveChangesAsync(CancellationToken ct = default) + => dbContext.SaveChangesAsync(ct); +} \ No newline at end of file