diff --git a/src/API/Controllers/Profiles/V1/ProfilesController.cs b/src/API/Controllers/Profiles/V1/ProfilesController.cs index 71004ef..edfdbc0 100644 --- a/src/API/Controllers/Profiles/V1/ProfilesController.cs +++ b/src/API/Controllers/Profiles/V1/ProfilesController.cs @@ -21,9 +21,9 @@ public class ProfilesController(IProfileService profileService) : ControllerBase return profile; } - [HttpGet("bulk")] + [HttpPost("bulk")] public async Task> GetBulkProfiles( - [FromQuery(Name = "id")] List ids, + [FromBody] List ids, CancellationToken ct) => await profileService.GetProfilesAsync(ids, ct); diff --git a/src/API/Controllers/Settings/V1/SettingsController.cs b/src/API/Controllers/Settings/V1/SettingsController.cs new file mode 100644 index 0000000..33bb235 --- /dev/null +++ b/src/API/Controllers/Settings/V1/SettingsController.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using RecNet.Application.Common.Security; +using RecNet.Application.Profiles; + +namespace API.Controllers.Settings.V1; + +[Authorize] +[ApiController] +[Route("api/[controller]/v1")] +public class SettingsController(IProfileService profileService) : ControllerBase +{ + [HttpGet] + public async Task> GetAllAsync(CancellationToken ct) + { + var profileId = User.GetProfileId(); + + return await profileService.GetPlayerSettingsAsync(profileId, ct); + } + + [HttpPost("set")] + public async Task SetAsync( + [FromBody] PlayerSettingDTO request, + CancellationToken ct) + { + var profileId = User.GetProfileId(); + + var updated = await profileService.UpdatePlayerSettingAsync + ( + profileId: profileId, + command: new UpdatePlayerSettingCommand(request.Key, request.Value), + ct: ct + ); + + if (!updated) + return NotFound(); + + return Ok(); + } +} diff --git a/src/RecNet.Application/Common/Mapping/ApplicationMappingProfile.cs b/src/RecNet.Application/Common/Mapping/ApplicationMappingProfile.cs index 3480ffb..62b811b 100644 --- a/src/RecNet.Application/Common/Mapping/ApplicationMappingProfile.cs +++ b/src/RecNet.Application/Common/Mapping/ApplicationMappingProfile.cs @@ -9,5 +9,6 @@ public class ApplicationMappingProfile : Profile { CreateMap(); CreateMap(); + CreateMap(); } } diff --git a/src/RecNet.Application/Profiles/IProfileService.cs b/src/RecNet.Application/Profiles/IProfileService.cs index da62810..3ca9652 100644 --- a/src/RecNet.Application/Profiles/IProfileService.cs +++ b/src/RecNet.Application/Profiles/IProfileService.cs @@ -1,3 +1,4 @@ + namespace RecNet.Application.Profiles; public interface IProfileService @@ -5,6 +6,8 @@ public interface IProfileService Task GetProfileAsync(Guid profileId, CancellationToken ct = default); Task GetAvatarAsync(Guid profileId, CancellationToken ct = default); Task UpdateAvatarAsync(Guid profileId, UpdateAvatarCommand command, CancellationToken ct = default); + Task> GetPlayerSettingsAsync(Guid profileId, CancellationToken ct = default); + Task UpdatePlayerSettingAsync(Guid profileId, UpdatePlayerSettingCommand command, CancellationToken ct = default); Task> GetProfilesAsync(List profileIds, CancellationToken ct = default); Task LoginAsync(LoginProfileCommand command, CancellationToken ct = default); } diff --git a/src/RecNet.Application/Profiles/PlayerSettingDto.cs b/src/RecNet.Application/Profiles/PlayerSettingDto.cs new file mode 100644 index 0000000..d03c776 --- /dev/null +++ b/src/RecNet.Application/Profiles/PlayerSettingDto.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace RecNet.Application.Profiles; + +public class PlayerSettingDTO +{ + [JsonPropertyName("Key")] + public required string Key { get; set; } + + [JsonPropertyName("Value")] + public required string Value { get; set; } +} \ No newline at end of file diff --git a/src/RecNet.Application/Profiles/ProfileService.cs b/src/RecNet.Application/Profiles/ProfileService.cs index 0b2de1f..1e62ce1 100644 --- a/src/RecNet.Application/Profiles/ProfileService.cs +++ b/src/RecNet.Application/Profiles/ProfileService.cs @@ -55,6 +55,31 @@ public class ProfileService( return mapper.Map(profile.Avatar); } + public async Task> GetPlayerSettingsAsync( + Guid profileId, + CancellationToken ct = default) + { + var settings = await profileRepository.GetSettingsByProfileIdAsync(profileId, ct); + + return mapper.Map>(settings); + } + + public async Task UpdatePlayerSettingAsync( + Guid profileId, + UpdatePlayerSettingCommand command, + CancellationToken ct = default) + { + var profile = await profileRepository.GetByIdWithSettingsAsync(profileId, ct); + if (profile is null) + return false; + + profile.SetSetting(command.Key, command.Value); + + await profileRepository.SaveChangesAsync(ct); + + return true; + } + public async Task> GetProfilesAsync( List profileIds, CancellationToken ct = default) @@ -123,4 +148,4 @@ public class ProfileService( expiresIn: token.ExpiresIn ); } -} \ No newline at end of file +} diff --git a/src/RecNet.Application/Profiles/UpdatePlayerSettingCommand.cs b/src/RecNet.Application/Profiles/UpdatePlayerSettingCommand.cs new file mode 100644 index 0000000..97cf3fe --- /dev/null +++ b/src/RecNet.Application/Profiles/UpdatePlayerSettingCommand.cs @@ -0,0 +1,5 @@ +namespace RecNet.Application.Profiles; + +public sealed record UpdatePlayerSettingCommand( + string Key, + string Value); \ No newline at end of file diff --git a/src/RecNet.Domain/Profiles/IProfileRepository.cs b/src/RecNet.Domain/Profiles/IProfileRepository.cs index 41f2353..32a56ce 100644 --- a/src/RecNet.Domain/Profiles/IProfileRepository.cs +++ b/src/RecNet.Domain/Profiles/IProfileRepository.cs @@ -5,7 +5,9 @@ namespace RecNet.Domain.Profiles; public interface IProfileRepository { Task GetByIdAsync(Guid profileId, CancellationToken ct = default); + Task GetByIdWithSettingsAsync(Guid profileId, CancellationToken ct = default); Task GetAvatarByProfileIdAsync(Guid profileId, CancellationToken ct = default); + Task> GetSettingsByProfileIdAsync(Guid profileId, CancellationToken ct = default); Task> GetByIdsAsync(List profileIds, CancellationToken ct = default); Task GetByPlatform(PlatformType platform, string platformId, CancellationToken ct = default); diff --git a/src/RecNet.Domain/Profiles/PlayerSetting.cs b/src/RecNet.Domain/Profiles/PlayerSetting.cs new file mode 100644 index 0000000..d4651ac --- /dev/null +++ b/src/RecNet.Domain/Profiles/PlayerSetting.cs @@ -0,0 +1,20 @@ +namespace RecNet.Domain.Profiles; + +public class PlayerSetting +{ + public Guid UserId { get; private set; } + public string Key { get; private set; } + public string Value { get; private set; } + + internal PlayerSetting(Guid userId, string key, string value) + { + UserId = userId; + Key = key; + Value = value; + } + + public void UpdateValue(string value) + { + Value = value; + } +} \ No newline at end of file diff --git a/src/RecNet.Domain/Profiles/Profile.cs b/src/RecNet.Domain/Profiles/Profile.cs index 7bdea03..b771bfc 100644 --- a/src/RecNet.Domain/Profiles/Profile.cs +++ b/src/RecNet.Domain/Profiles/Profile.cs @@ -36,6 +36,7 @@ public class Profile public DateTimeOffset CreatedAt { get; private set; } public Avatar Avatar { get; private set; } = Avatar.Empty; + public ICollection Settings { get; } = []; public static Profile Create( string name, @@ -86,6 +87,19 @@ public class Profile public void SetAvatar(Avatar avatar) => Avatar = avatar ?? throw new DomainException("Avatar is required."); + public void SetSetting(string key, string value) + { + var existing = Settings.FirstOrDefault(x => x.Key == key); + + if (existing is null) + { + Settings.Add(new PlayerSetting(ProfileId, key, value)); + return; + } + + existing.UpdateValue(value); + } + private static string RequireValue(string value, string parameterName) => !string.IsNullOrWhiteSpace(value) ? value : throw new DomainException($"{parameterName} is required."); } diff --git a/src/RecNet.Infrastructure/Persistence/Configurations/PlayerSettingConfiguration.cs b/src/RecNet.Infrastructure/Persistence/Configurations/PlayerSettingConfiguration.cs new file mode 100644 index 0000000..a1e392f --- /dev/null +++ b/src/RecNet.Infrastructure/Persistence/Configurations/PlayerSettingConfiguration.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using RecNet.Domain.Profiles; + +namespace RecNet.Infrastructure.Persistence.Configurations; + +public class PlayerSettingConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(x => new { x.UserId, x.Key }); + + builder.Property(x => x.Key) + .HasMaxLength(100) + .IsRequired(); + + builder.Property(x => x.Value) + .IsRequired(); + } +} \ No newline at end of file diff --git a/src/RecNet.Infrastructure/Persistence/Configurations/ProfileConfiguration.cs b/src/RecNet.Infrastructure/Persistence/Configurations/ProfileConfiguration.cs index e61f2f3..d31dd9f 100644 --- a/src/RecNet.Infrastructure/Persistence/Configurations/ProfileConfiguration.cs +++ b/src/RecNet.Infrastructure/Persistence/Configurations/ProfileConfiguration.cs @@ -28,6 +28,13 @@ public class ProfileConfiguration : IEntityTypeConfiguration builder.Property(x => x.CreatedAt) .IsRequired(); + + builder.HasMany(x => x.Settings) + .WithOne() + .HasForeignKey(x => x.UserId); + + builder.Navigation(x => x.Settings) + .UsePropertyAccessMode(PropertyAccessMode.Field); builder.OwnsOne(x => x.Avatar); diff --git a/src/RecNet.Infrastructure/Persistence/DatabaseContext.cs b/src/RecNet.Infrastructure/Persistence/DatabaseContext.cs index 3b8283e..8b7d3ea 100644 --- a/src/RecNet.Infrastructure/Persistence/DatabaseContext.cs +++ b/src/RecNet.Infrastructure/Persistence/DatabaseContext.cs @@ -8,6 +8,7 @@ namespace RecNet.Infrastructure.Persistence; public class DatabaseContext(DbContextOptions options) : DbContext(options) { public DbSet Profiles => Set(); + public DbSet PlayerSettings => Set(); public DbSet GameVersions => Set(); public DbSet ServerConfigs => Set(); diff --git a/src/RecNet.Infrastructure/Persistence/Migrations/20260621035308_PlayerSettings.Designer.cs b/src/RecNet.Infrastructure/Persistence/Migrations/20260621035308_PlayerSettings.Designer.cs new file mode 100644 index 0000000..374e4c2 --- /dev/null +++ b/src/RecNet.Infrastructure/Persistence/Migrations/20260621035308_PlayerSettings.Designer.cs @@ -0,0 +1,164 @@ +// +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("20260621035308_PlayerSettings")] + partial class PlayerSettings + { + /// + 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.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.GameVersions.GameVersion", b => + { + b.Property("Version") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("IsValid") + .HasColumnType("boolean"); + + b.HasKey("Version"); + + b.ToTable("GameVersions"); + }); + + modelBuilder.Entity("RecNet.Domain.Profiles.PlayerSetting", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("UserId", "Key"); + + b.ToTable("PlayerSettings"); + }); + + modelBuilder.Entity("RecNet.Domain.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("IsBanned") + .HasColumnType("boolean"); + + b.Property("IsModerator") + .HasColumnType("boolean"); + + 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"); + }); + + modelBuilder.Entity("RecNet.Domain.Profiles.PlayerSetting", b => + { + b.HasOne("RecNet.Domain.Profiles.Profile", null) + .WithMany("Settings") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RecNet.Domain.Profiles.Profile", b => + { + b.OwnsOne("RecNet.Domain.Profiles.Avatar", "Avatar", b1 => + { + b1.Property("ProfileId") + .HasColumnType("uuid"); + + b1.Property("HairColor") + .IsRequired() + .HasColumnType("text"); + + b1.Property("OutfitSelections") + .IsRequired() + .HasColumnType("text"); + + b1.Property("SkinColor") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("ProfileId"); + + b1.ToTable("Profiles"); + + b1.WithOwner() + .HasForeignKey("ProfileId"); + }); + + b.Navigation("Avatar") + .IsRequired(); + }); + + modelBuilder.Entity("RecNet.Domain.Profiles.Profile", b => + { + b.Navigation("Settings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/RecNet.Infrastructure/Persistence/Migrations/20260621035308_PlayerSettings.cs b/src/RecNet.Infrastructure/Persistence/Migrations/20260621035308_PlayerSettings.cs new file mode 100644 index 0000000..502a4ae --- /dev/null +++ b/src/RecNet.Infrastructure/Persistence/Migrations/20260621035308_PlayerSettings.cs @@ -0,0 +1,41 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RecNet.Infrastructure.Persistence.Migrations +{ + /// + public partial class PlayerSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PlayerSettings", + columns: table => new + { + UserId = table.Column(type: "uuid", nullable: false), + Key = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Value = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PlayerSettings", x => new { x.UserId, x.Key }); + table.ForeignKey( + name: "FK_PlayerSettings_Profiles_UserId", + column: x => x.UserId, + principalTable: "Profiles", + principalColumn: "ProfileId", + onDelete: ReferentialAction.Cascade); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PlayerSettings"); + } + } +} diff --git a/src/RecNet.Infrastructure/Persistence/Migrations/DatabaseContextModelSnapshot.cs b/src/RecNet.Infrastructure/Persistence/Migrations/DatabaseContextModelSnapshot.cs index e46aabb..8eb3f0c 100644 --- a/src/RecNet.Infrastructure/Persistence/Migrations/DatabaseContextModelSnapshot.cs +++ b/src/RecNet.Infrastructure/Persistence/Migrations/DatabaseContextModelSnapshot.cs @@ -51,6 +51,24 @@ namespace RecNet.Infrastructure.Persistence.Migrations b.ToTable("GameVersions"); }); + modelBuilder.Entity("RecNet.Domain.Profiles.PlayerSetting", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("UserId", "Key"); + + b.ToTable("PlayerSettings"); + }); + modelBuilder.Entity("RecNet.Domain.Profiles.Profile", b => { b.Property("ProfileId") @@ -93,6 +111,15 @@ namespace RecNet.Infrastructure.Persistence.Migrations b.ToTable("Profiles"); }); + modelBuilder.Entity("RecNet.Domain.Profiles.PlayerSetting", b => + { + b.HasOne("RecNet.Domain.Profiles.Profile", null) + .WithMany("Settings") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("RecNet.Domain.Profiles.Profile", b => { b.OwnsOne("RecNet.Domain.Profiles.Avatar", "Avatar", b1 => @@ -123,6 +150,11 @@ namespace RecNet.Infrastructure.Persistence.Migrations b.Navigation("Avatar") .IsRequired(); }); + + modelBuilder.Entity("RecNet.Domain.Profiles.Profile", b => + { + b.Navigation("Settings"); + }); #pragma warning restore 612, 618 } } diff --git a/src/RecNet.Infrastructure/Persistence/Repositories/ProfileRepository.cs b/src/RecNet.Infrastructure/Persistence/Repositories/ProfileRepository.cs index 71c7ef8..eb8245c 100644 --- a/src/RecNet.Infrastructure/Persistence/Repositories/ProfileRepository.cs +++ b/src/RecNet.Infrastructure/Persistence/Repositories/ProfileRepository.cs @@ -12,6 +12,13 @@ public class ProfileRepository(DatabaseContext dbContext) : IProfileRepository => dbContext.Profiles .FirstOrDefaultAsync(x => x.ProfileId == profileId, ct); + public Task GetByIdWithSettingsAsync( + Guid profileId, + CancellationToken ct = default) + => dbContext.Profiles + .Include(x => x.Settings) + .FirstOrDefaultAsync(x => x.ProfileId == profileId, ct); + public Task GetAvatarByProfileIdAsync( Guid profileId, CancellationToken ct = default) @@ -21,6 +28,14 @@ public class ProfileRepository(DatabaseContext dbContext) : IProfileRepository .Select(x => x.Avatar) .FirstOrDefaultAsync(ct); + public async Task> GetSettingsByProfileIdAsync( + Guid profileId, + CancellationToken ct = default) + => await dbContext.PlayerSettings + .AsNoTracking() + .Where(x => x.UserId == profileId) + .ToListAsync(ct); + public async Task> GetByIdsAsync( List profileIds, CancellationToken ct = default)