Add avatar support
This commit is contained in:
47
src/API/Controllers/Avatar/V1/AvatarController.cs
Normal file
47
src/API/Controllers/Avatar/V1/AvatarController.cs
Normal file
@@ -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<ActionResult<AvatarDTO>> 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<ActionResult<AvatarDTO>> 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;
|
||||
}
|
||||
}
|
||||
@@ -50,4 +50,4 @@ public class ProfilesController(IProfileService profileService) : ControllerBase
|
||||
ExpiresIn = result.ExpiresIn
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,5 +8,6 @@ public class ApplicationMappingProfile : Profile
|
||||
public ApplicationMappingProfile()
|
||||
{
|
||||
CreateMap<Domain.Profiles.Profile, ProfileDTO>();
|
||||
CreateMap<Domain.Profiles.Avatar, AvatarDTO>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
15
src/RecNet.Application/Profiles/AvatarDto.cs
Normal file
15
src/RecNet.Application/Profiles/AvatarDto.cs
Normal file
@@ -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; }
|
||||
}
|
||||
@@ -3,6 +3,8 @@ namespace RecNet.Application.Profiles;
|
||||
public interface IProfileService
|
||||
{
|
||||
Task<ProfileDTO?> GetProfileAsync(Guid profileId, CancellationToken ct = default);
|
||||
Task<AvatarDTO?> GetAvatarAsync(Guid profileId, CancellationToken ct = default);
|
||||
Task<AvatarDTO?> UpdateAvatarAsync(Guid profileId, UpdateAvatarCommand command, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<ProfileDTO>> GetProfilesAsync(List<Guid> profileIds, CancellationToken ct = default);
|
||||
Task<LoginProfileResult> LoginAsync(LoginProfileCommand command, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,37 @@ public class ProfileService(
|
||||
: mapper.Map<ProfileDTO>(profile);
|
||||
}
|
||||
|
||||
public async Task<AvatarDTO?> GetAvatarAsync(Guid profileId, CancellationToken ct = default)
|
||||
{
|
||||
var avatar = await profileRepository.GetAvatarByProfileIdAsync(profileId, ct);
|
||||
|
||||
return avatar is null
|
||||
? null
|
||||
: mapper.Map<AvatarDTO>(avatar);
|
||||
}
|
||||
|
||||
public async Task<AvatarDTO?> 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<AvatarDTO>(profile.Avatar);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ProfileDTO>> GetProfilesAsync(
|
||||
List<Guid> 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");
|
||||
|
||||
|
||||
6
src/RecNet.Application/Profiles/UpdateAvatarCommand.cs
Normal file
6
src/RecNet.Application/Profiles/UpdateAvatarCommand.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace RecNet.Application.Profiles;
|
||||
|
||||
public sealed record UpdateAvatarCommand(
|
||||
string? OutfitSelections,
|
||||
string? SkinColor,
|
||||
string? HairColor);
|
||||
23
src/RecNet.Domain/Profiles/Avatar.cs
Normal file
23
src/RecNet.Domain/Profiles/Avatar.cs
Normal file
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace RecNet.Domain.Profiles;
|
||||
public interface IProfileRepository
|
||||
{
|
||||
Task<Profile?> GetByIdAsync(Guid profileId, CancellationToken ct = default);
|
||||
Task<Avatar?> GetAvatarByProfileIdAsync(Guid profileId, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<Profile>> GetByIdsAsync(List<Guid> profileIds, CancellationToken ct = default);
|
||||
|
||||
Task<Profile?> GetByPlatform(PlatformType platform, string platformId, CancellationToken ct = default);
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ public class ProfileConfiguration : IEntityTypeConfiguration<Profile>
|
||||
|
||||
builder.Property(x => x.CreatedAt)
|
||||
.IsRequired();
|
||||
|
||||
builder.OwnsOne(x => x.Avatar);
|
||||
|
||||
builder.HasIndex(x => new { x.Platform, x.PlatformId })
|
||||
.IsUnique();
|
||||
|
||||
132
src/RecNet.Infrastructure/Persistence/Migrations/20260620201950_Avatars.Designer.cs
generated
Normal file
132
src/RecNet.Infrastructure/Persistence/Migrations/20260620201950_Avatars.Designer.cs
generated
Normal file
@@ -0,0 +1,132 @@
|
||||
// <auto-generated />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<string>("Key")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.ToTable("ServerConfigs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("RecNet.Domain.GameVersions.GameVersion", b =>
|
||||
{
|
||||
b.Property<string>("Version")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<bool>("IsValid")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("Version");
|
||||
|
||||
b.ToTable("GameVersions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("RecNet.Domain.Profiles.Profile", b =>
|
||||
{
|
||||
b.Property<Guid>("ProfileId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.PrimitiveCollection<string>("DeviceIds")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<bool>("IsBanned")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsModerator")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("Platform")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("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<Guid>("ProfileId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b1.Property<string>("HairColor")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b1.Property<string>("OutfitSelections")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b1.Property<string>("SkinColor")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b1.HasKey("ProfileId");
|
||||
|
||||
b1.ToTable("Profiles");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("ProfileId");
|
||||
});
|
||||
|
||||
b.Navigation("Avatar")
|
||||
.IsRequired();
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace RecNet.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Avatars : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Avatar_HairColor",
|
||||
table: "Profiles",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Avatar_OutfitSelections",
|
||||
table: "Profiles",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Avatar_SkinColor",
|
||||
table: "Profiles",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Guid>("ProfileId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b1.Property<string>("HairColor")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b1.Property<string>("OutfitSelections")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b1.Property<string>("SkinColor")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b1.HasKey("ProfileId");
|
||||
|
||||
b1.ToTable("Profiles");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("ProfileId");
|
||||
});
|
||||
|
||||
b.Navigation("Avatar")
|
||||
.IsRequired();
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,15 @@ public class ProfileRepository(DatabaseContext dbContext) : IProfileRepository
|
||||
=> dbContext.Profiles
|
||||
.FirstOrDefaultAsync(x => x.ProfileId == profileId, ct);
|
||||
|
||||
public Task<Avatar?> GetAvatarByProfileIdAsync(
|
||||
Guid profileId,
|
||||
CancellationToken ct = default)
|
||||
=> dbContext.Profiles
|
||||
.AsNoTracking()
|
||||
.Where(x => x.ProfileId == profileId)
|
||||
.Select(x => x.Avatar)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
public async Task<IReadOnlyList<Profile>> GetByIdsAsync(
|
||||
List<Guid> profileIds,
|
||||
CancellationToken ct = default)
|
||||
@@ -36,4 +45,4 @@ public class ProfileRepository(DatabaseContext dbContext) : IProfileRepository
|
||||
|
||||
public Task SaveChangesAsync(CancellationToken ct = default)
|
||||
=> dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user