diff --git a/src/API/Controllers/Avatar/V1/AvatarController.cs b/src/API/Controllers/Avatar/V1/AvatarController.cs new file mode 100644 index 0000000..eba798a --- /dev/null +++ b/src/API/Controllers/Avatar/V1/AvatarController.cs @@ -0,0 +1,47 @@ +using AutoMapper; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using RecNet.Application.Common.Security; +using RecNet.Application.Profiles; +using AvatarEntity = RecNet.Domain.Profiles.Avatar; + +namespace API.Controllers.Avatar.V1; + +[Authorize] +[ApiController] +[Route("api/[controller]/v1")] +public class AvatarController(IProfileService profileService) : ControllerBase +{ + [HttpGet] + public async Task> GetAvatar( + CancellationToken ct) + { + var profileId = User.GetProfileId(); + + var avatar = await profileService.GetAvatarAsync(profileId, ct); + if (avatar == null) + return NotFound(); + + return avatar; + } + + [HttpPost("set")] + public async Task> UpdateAvatar( + [FromBody] AvatarDTO request, + CancellationToken ct) + { + var profileId = User.GetProfileId(); + + var avatar = await profileService.UpdateAvatarAsync( + profileId, + new UpdateAvatarCommand( + request.OutfitSelections, + request.SkinColor, + request.HairColor), + ct); + if (avatar == null) + return NotFound(); + + return avatar; + } +} \ No newline at end of file diff --git a/src/API/Controllers/Profiles/V1/ProfilesController.cs b/src/API/Controllers/Profiles/V1/ProfilesController.cs index 6d67427..71004ef 100644 --- a/src/API/Controllers/Profiles/V1/ProfilesController.cs +++ b/src/API/Controllers/Profiles/V1/ProfilesController.cs @@ -50,4 +50,4 @@ public class ProfilesController(IProfileService profileService) : ControllerBase ExpiresIn = result.ExpiresIn }; } -} \ No newline at end of file +} diff --git a/src/RecNet.Application/Common/Mapping/ApplicationMappingProfile.cs b/src/RecNet.Application/Common/Mapping/ApplicationMappingProfile.cs index e5658dd..3480ffb 100644 --- a/src/RecNet.Application/Common/Mapping/ApplicationMappingProfile.cs +++ b/src/RecNet.Application/Common/Mapping/ApplicationMappingProfile.cs @@ -8,5 +8,6 @@ public class ApplicationMappingProfile : Profile public ApplicationMappingProfile() { CreateMap(); + CreateMap(); } -} \ No newline at end of file +} diff --git a/src/RecNet.Application/Profiles/AvatarDto.cs b/src/RecNet.Application/Profiles/AvatarDto.cs new file mode 100644 index 0000000..bf721e0 --- /dev/null +++ b/src/RecNet.Application/Profiles/AvatarDto.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace RecNet.Application.Profiles; + +public class AvatarDTO +{ + [JsonPropertyName("OutfitSelections")] + public required string OutfitSelections { get; init; } + + [JsonPropertyName("SkinColor")] + public required string SkinColor { get; init; } + + [JsonPropertyName("HairColor")] + public required string HairColor { get; init; } +} diff --git a/src/RecNet.Application/Profiles/IProfileService.cs b/src/RecNet.Application/Profiles/IProfileService.cs index 2c1b91b..da62810 100644 --- a/src/RecNet.Application/Profiles/IProfileService.cs +++ b/src/RecNet.Application/Profiles/IProfileService.cs @@ -3,6 +3,8 @@ namespace RecNet.Application.Profiles; 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> GetProfilesAsync(List profileIds, CancellationToken ct = default); Task LoginAsync(LoginProfileCommand command, CancellationToken ct = default); } diff --git a/src/RecNet.Application/Profiles/ProfileService.cs b/src/RecNet.Application/Profiles/ProfileService.cs index 37aa97a..0b2de1f 100644 --- a/src/RecNet.Application/Profiles/ProfileService.cs +++ b/src/RecNet.Application/Profiles/ProfileService.cs @@ -24,6 +24,37 @@ public class ProfileService( : mapper.Map(profile); } + public async Task GetAvatarAsync(Guid profileId, CancellationToken ct = default) + { + var avatar = await profileRepository.GetAvatarByProfileIdAsync(profileId, ct); + + return avatar is null + ? null + : mapper.Map(avatar); + } + + public async Task UpdateAvatarAsync( + Guid profileId, + UpdateAvatarCommand command, + CancellationToken ct = default) + { + var profile = await profileRepository.GetByIdAsync(profileId, ct); + if (profile is null) + return null; + + profile.SetAvatar( + Avatar.Create( + command.OutfitSelections, + command.SkinColor, + command.HairColor + ) + ); + + await profileRepository.SaveChangesAsync(ct); + + return mapper.Map(profile.Avatar); + } + public async Task> GetProfilesAsync( List profileIds, CancellationToken ct = default) @@ -44,21 +75,21 @@ public class ProfileService( var isValidGameVersion = await gameVersionRepository.IsValidAsync(command.AppVersion, ct); if (!isValidGameVersion) return LoginProfileResult.Fail("Invalid app version."); - + // 2. Validate Platform Authentication var validator = validators.FirstOrDefault(x => x.PlatformType == command.PlatformType); if (validator is null) return LoginProfileResult.Fail("Invalid platform type"); - + var ignoreAuthValidation = await configService.GetAsync("Profiles:IgnoreAuthValidation", true, ct); - + var auth = await validator.ValidateAsync( command.PlatformAuthentication, command.PlatformId, ct); if (!auth.Succeeded && !ignoreAuthValidation) return LoginProfileResult.Fail($"Platform Auth Failed: {auth.Message}"); - + // 3. Get or Create Profile var profile = await profileRepository.GetByPlatform( command.PlatformType, @@ -68,7 +99,7 @@ public class ProfileService( if (profile is null) { var name = await nameGenerator.GenerateAsync(ct); - + profile = ProfileEntity.Create( name, command.PlatformType, @@ -76,7 +107,7 @@ public class ProfileService( await profileRepository.AddAsync(profile, ct); } - + if (profile.IsBanned) return LoginProfileResult.Fail("Profile is banned"); diff --git a/src/RecNet.Application/Profiles/UpdateAvatarCommand.cs b/src/RecNet.Application/Profiles/UpdateAvatarCommand.cs new file mode 100644 index 0000000..58d8975 --- /dev/null +++ b/src/RecNet.Application/Profiles/UpdateAvatarCommand.cs @@ -0,0 +1,6 @@ +namespace RecNet.Application.Profiles; + +public sealed record UpdateAvatarCommand( + string? OutfitSelections, + string? SkinColor, + string? HairColor); diff --git a/src/RecNet.Domain/Profiles/Avatar.cs b/src/RecNet.Domain/Profiles/Avatar.cs new file mode 100644 index 0000000..3338fd3 --- /dev/null +++ b/src/RecNet.Domain/Profiles/Avatar.cs @@ -0,0 +1,23 @@ +namespace RecNet.Domain.Profiles; + +public class Avatar +{ + private Avatar() { } + + public string OutfitSelections { get; private set; } = string.Empty; + public string SkinColor { get; private set; } = string.Empty; + public string HairColor { get; private set; } = string.Empty; + + public static Avatar Empty => new(); + + public static Avatar Create( + string? outfitSelections, + string? skinColor, + string? hairColor) + => new() + { + OutfitSelections = outfitSelections ?? string.Empty, + SkinColor = skinColor ?? string.Empty, + HairColor = hairColor ?? string.Empty + }; +} \ No newline at end of file diff --git a/src/RecNet.Domain/Profiles/IProfileRepository.cs b/src/RecNet.Domain/Profiles/IProfileRepository.cs index 4d069b5..41f2353 100644 --- a/src/RecNet.Domain/Profiles/IProfileRepository.cs +++ b/src/RecNet.Domain/Profiles/IProfileRepository.cs @@ -5,6 +5,7 @@ namespace RecNet.Domain.Profiles; public interface IProfileRepository { Task GetByIdAsync(Guid profileId, CancellationToken ct = default); + Task GetAvatarByProfileIdAsync(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/Profile.cs b/src/RecNet.Domain/Profiles/Profile.cs index 572736b..7bdea03 100644 --- a/src/RecNet.Domain/Profiles/Profile.cs +++ b/src/RecNet.Domain/Profiles/Profile.cs @@ -35,6 +35,8 @@ public class Profile public DateTimeOffset CreatedAt { get; private set; } + public Avatar Avatar { get; private set; } = Avatar.Empty; + public static Profile Create( string name, PlatformType platform, @@ -81,6 +83,9 @@ public class Profile public void RevokeModerator() => IsModerator = false; + public void SetAvatar(Avatar avatar) + => Avatar = avatar ?? throw new DomainException("Avatar is required."); + 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/ProfileConfiguration.cs b/src/RecNet.Infrastructure/Persistence/Configurations/ProfileConfiguration.cs index a65c68a..e61f2f3 100644 --- a/src/RecNet.Infrastructure/Persistence/Configurations/ProfileConfiguration.cs +++ b/src/RecNet.Infrastructure/Persistence/Configurations/ProfileConfiguration.cs @@ -28,6 +28,8 @@ public class ProfileConfiguration : IEntityTypeConfiguration builder.Property(x => x.CreatedAt) .IsRequired(); + + builder.OwnsOne(x => x.Avatar); builder.HasIndex(x => new { x.Platform, x.PlatformId }) .IsUnique(); diff --git a/src/RecNet.Infrastructure/Persistence/Migrations/20260620201950_Avatars.Designer.cs b/src/RecNet.Infrastructure/Persistence/Migrations/20260620201950_Avatars.Designer.cs new file mode 100644 index 0000000..e6ab3e4 --- /dev/null +++ b/src/RecNet.Infrastructure/Persistence/Migrations/20260620201950_Avatars.Designer.cs @@ -0,0 +1,132 @@ +// +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("20260620201950_Avatars")] + partial class Avatars + { + /// + 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.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.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(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/RecNet.Infrastructure/Persistence/Migrations/20260620201950_Avatars.cs b/src/RecNet.Infrastructure/Persistence/Migrations/20260620201950_Avatars.cs new file mode 100644 index 0000000..aac305e --- /dev/null +++ b/src/RecNet.Infrastructure/Persistence/Migrations/20260620201950_Avatars.cs @@ -0,0 +1,51 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RecNet.Infrastructure.Persistence.Migrations +{ + /// + public partial class Avatars : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Avatar_HairColor", + table: "Profiles", + type: "text", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "Avatar_OutfitSelections", + table: "Profiles", + type: "text", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "Avatar_SkinColor", + table: "Profiles", + type: "text", + nullable: false, + defaultValue: ""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Avatar_HairColor", + table: "Profiles"); + + migrationBuilder.DropColumn( + name: "Avatar_OutfitSelections", + table: "Profiles"); + + migrationBuilder.DropColumn( + name: "Avatar_SkinColor", + table: "Profiles"); + } + } +} diff --git a/src/RecNet.Infrastructure/Persistence/Migrations/DatabaseContextModelSnapshot.cs b/src/RecNet.Infrastructure/Persistence/Migrations/DatabaseContextModelSnapshot.cs index 50d5103..e46aabb 100644 --- a/src/RecNet.Infrastructure/Persistence/Migrations/DatabaseContextModelSnapshot.cs +++ b/src/RecNet.Infrastructure/Persistence/Migrations/DatabaseContextModelSnapshot.cs @@ -92,6 +92,37 @@ namespace RecNet.Infrastructure.Persistence.Migrations b.ToTable("Profiles"); }); + + 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(); + }); #pragma warning restore 612, 618 } } diff --git a/src/RecNet.Infrastructure/Persistence/Repositories/ProfileRepository.cs b/src/RecNet.Infrastructure/Persistence/Repositories/ProfileRepository.cs index 473ce9e..71c7ef8 100644 --- a/src/RecNet.Infrastructure/Persistence/Repositories/ProfileRepository.cs +++ b/src/RecNet.Infrastructure/Persistence/Repositories/ProfileRepository.cs @@ -12,6 +12,15 @@ public class ProfileRepository(DatabaseContext dbContext) : IProfileRepository => dbContext.Profiles .FirstOrDefaultAsync(x => x.ProfileId == profileId, ct); + public Task GetAvatarByProfileIdAsync( + Guid profileId, + CancellationToken ct = default) + => dbContext.Profiles + .AsNoTracking() + .Where(x => x.ProfileId == profileId) + .Select(x => x.Avatar) + .FirstOrDefaultAsync(ct); + public async Task> GetByIdsAsync( List profileIds, CancellationToken ct = default) @@ -36,4 +45,4 @@ public class ProfileRepository(DatabaseContext dbContext) : IProfileRepository public Task SaveChangesAsync(CancellationToken ct = default) => dbContext.SaveChangesAsync(ct); -} \ No newline at end of file +}