Add avatar support

This commit is contained in:
Holden
2026-06-20 15:33:46 -05:00
parent 942bb4c80b
commit e24a4c6872
15 changed files with 365 additions and 9 deletions

View 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;
}
}

View File

@@ -8,5 +8,6 @@ public class ApplicationMappingProfile : Profile
public ApplicationMappingProfile() public ApplicationMappingProfile()
{ {
CreateMap<Domain.Profiles.Profile, ProfileDTO>(); CreateMap<Domain.Profiles.Profile, ProfileDTO>();
CreateMap<Domain.Profiles.Avatar, AvatarDTO>();
} }
} }

View 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; }
}

View File

@@ -3,6 +3,8 @@ namespace RecNet.Application.Profiles;
public interface IProfileService public interface IProfileService
{ {
Task<ProfileDTO?> GetProfileAsync(Guid profileId, CancellationToken ct = default); 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<IReadOnlyList<ProfileDTO>> GetProfilesAsync(List<Guid> profileIds, CancellationToken ct = default);
Task<LoginProfileResult> LoginAsync(LoginProfileCommand command, CancellationToken ct = default); Task<LoginProfileResult> LoginAsync(LoginProfileCommand command, CancellationToken ct = default);
} }

View File

@@ -24,6 +24,37 @@ public class ProfileService(
: mapper.Map<ProfileDTO>(profile); : 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( public async Task<IReadOnlyList<ProfileDTO>> GetProfilesAsync(
List<Guid> profileIds, List<Guid> profileIds,
CancellationToken ct = default) CancellationToken ct = default)

View File

@@ -0,0 +1,6 @@
namespace RecNet.Application.Profiles;
public sealed record UpdateAvatarCommand(
string? OutfitSelections,
string? SkinColor,
string? HairColor);

View 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
};
}

View File

@@ -5,6 +5,7 @@ namespace RecNet.Domain.Profiles;
public interface IProfileRepository public interface IProfileRepository
{ {
Task<Profile?> GetByIdAsync(Guid profileId, CancellationToken ct = default); 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<IReadOnlyList<Profile>> GetByIdsAsync(List<Guid> profileIds, CancellationToken ct = default);
Task<Profile?> GetByPlatform(PlatformType platform, string platformId, CancellationToken ct = default); Task<Profile?> GetByPlatform(PlatformType platform, string platformId, CancellationToken ct = default);

View File

@@ -35,6 +35,8 @@ public class Profile
public DateTimeOffset CreatedAt { get; private set; } public DateTimeOffset CreatedAt { get; private set; }
public Avatar Avatar { get; private set; } = Avatar.Empty;
public static Profile Create( public static Profile Create(
string name, string name,
PlatformType platform, PlatformType platform,
@@ -81,6 +83,9 @@ public class Profile
public void RevokeModerator() public void RevokeModerator()
=> IsModerator = false; => IsModerator = false;
public void SetAvatar(Avatar avatar)
=> Avatar = avatar ?? throw new DomainException("Avatar is required.");
private static string RequireValue(string value, string parameterName) private static string RequireValue(string value, string parameterName)
=> !string.IsNullOrWhiteSpace(value) ? value : throw new DomainException($"{parameterName} is required."); => !string.IsNullOrWhiteSpace(value) ? value : throw new DomainException($"{parameterName} is required.");
} }

View File

@@ -29,6 +29,8 @@ public class ProfileConfiguration : IEntityTypeConfiguration<Profile>
builder.Property(x => x.CreatedAt) builder.Property(x => x.CreatedAt)
.IsRequired(); .IsRequired();
builder.OwnsOne(x => x.Avatar);
builder.HasIndex(x => new { x.Platform, x.PlatformId }) builder.HasIndex(x => new { x.Platform, x.PlatformId })
.IsUnique(); .IsUnique();
} }

View 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
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -92,6 +92,37 @@ namespace RecNet.Infrastructure.Persistence.Migrations
b.ToTable("Profiles"); 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 #pragma warning restore 612, 618
} }
} }

View File

@@ -12,6 +12,15 @@ public class ProfileRepository(DatabaseContext dbContext) : IProfileRepository
=> dbContext.Profiles => dbContext.Profiles
.FirstOrDefaultAsync(x => x.ProfileId == profileId, ct); .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( public async Task<IReadOnlyList<Profile>> GetByIdsAsync(
List<Guid> profileIds, List<Guid> profileIds,
CancellationToken ct = default) CancellationToken ct = default)