diff --git a/web/backend/API/API.Specs/Features/PublicUserProfile.feature b/web/backend/API/API.Specs/Features/PublicUserProfile.feature
index 6469dcfc..4abcdbfa 100644
--- a/web/backend/API/API.Specs/Features/PublicUserProfile.feature
+++ b/web/backend/API/API.Specs/Features/PublicUserProfile.feature
@@ -13,9 +13,53 @@ Scenario: Public profile exposes only allowlisted fields
Then the response has HTTP status 200
And the public profile response should match the registered account
And the response JSON should have "biography" equal ""
+ And the response JSON should have "avatarUrl" equal ""
And the response JSON should not contain "email"
And the response JSON should not contain "dateOfBirth"
+Scenario: Public profile exposes the uploaded avatar
+ Given I have registered a new account
+ And I have a valid access token for my account
+ And I have uploaded an avatar
+ When I retrieve the public profile by ID
+ Then the response has HTTP status 200
+ And the public profile response should carry my current avatar
+
+Scenario: Public profile exposes the current avatar after it is replaced
+ Given I have registered a new account
+ And I have a valid access token for my account
+ And I have uploaded an avatar
+ And I have uploaded a new avatar, replacing the previous one
+ When I retrieve the public profile by ID
+ Then the response has HTTP status 200
+ And the public profile response should carry my current avatar
+ And the public profile response should not carry my previous avatar
+
+Scenario: Fetching the avatar without authentication succeeds
+ Given I have registered a new account
+ And I have a valid access token for my account
+ And I have uploaded an avatar
+ When I retrieve the public profile by ID without authentication
+ Then the response has HTTP status 200
+ And the public profile response should carry my current avatar
+
+Scenario: A different user can read someone else's avatar
+ Given I have registered a new account
+ And I have a valid access token for my account
+ And I have uploaded an avatar
+ And I am logged in as a different user
+ When I retrieve the public profile by ID
+ Then the response has HTTP status 200
+ And the public profile response should carry my current avatar
+
+Scenario: Listing user accounts exposes each account's avatar
+ Given I have registered a new account
+ And I have a valid access token for my account
+ And I have uploaded an avatar
+ When I list user accounts
+ Then the response has HTTP status 200
+ And my listed account should carry my current avatar
+
Scenario: Public profile for a non-existent account returns not found
Given I have registered a new account
And I have a valid access token for my account
@@ -26,6 +70,7 @@ Scenario: Fetching the public profile without authentication succeeds
Given I have registered a new account
When I retrieve the public profile by ID without authentication
Then the response has HTTP status 200
+ And the response JSON should have "avatarUrl" equal ""
And the response JSON should not contain "email"
And the response JSON should not contain "dateOfBirth"
@@ -50,6 +95,7 @@ Scenario: Listing user accounts as an authenticated user excludes private fields
And I have a valid access token for my account
When I list user accounts
Then the response has HTTP status 200
+ And the response JSON should contain "avatarUrl"
And the response JSON should not contain "email"
And the response JSON should not contain "dateOfBirth"
diff --git a/web/backend/API/API.Specs/Mocks/MockFileStorageProvider.cs b/web/backend/API/API.Specs/Mocks/MockFileStorageProvider.cs
new file mode 100644
index 00000000..391d3824
--- /dev/null
+++ b/web/backend/API/API.Specs/Mocks/MockFileStorageProvider.cs
@@ -0,0 +1,38 @@
+using Features.ImageUploads.Services;
+
+namespace API.Specs.Mocks;
+
+///
+/// Test double for that records uploads in memory, so specs can
+/// exercise photo-backed features without object storage.
+///
+public class MockFileStorageProvider : IFileStorageProvider
+{
+ private readonly Dictionary _objects = new();
+
+ public IReadOnlyCollection UploadedKeys => _objects.Keys.ToList();
+
+ public Task UploadAsync(
+ string key,
+ Stream content,
+ string contentType,
+ CancellationToken cancellationToken = default
+ )
+ {
+ using MemoryStream buffer = new();
+ content.CopyTo(buffer);
+ _objects[key] = buffer.ToArray();
+ return Task.CompletedTask;
+ }
+
+ public Task DeleteAsync(string key, CancellationToken cancellationToken = default)
+ {
+ _objects.Remove(key);
+ return Task.CompletedTask;
+ }
+
+ public string GetPresignedUrl(string key, TimeSpan expiresIn)
+ {
+ return $"https://storage.test/{key}";
+ }
+}
diff --git a/web/backend/API/API.Specs/Steps/ApiGeneralSteps.cs b/web/backend/API/API.Specs/Steps/ApiGeneralSteps.cs
index 211f9355..ea462f57 100644
--- a/web/backend/API/API.Specs/Steps/ApiGeneralSteps.cs
+++ b/web/backend/API/API.Specs/Steps/ApiGeneralSteps.cs
@@ -183,6 +183,25 @@ public class ApiGeneralSteps(ScenarioContext scenario)
AssertFieldAbsent(payloadElem, field);
}
+ [Then("the response JSON should contain {string}")]
+ public void ThenTheResponseJsonShouldContainString(string field)
+ {
+ scenario.TryGetValue(ResponseBodyKey, out string? responseBody).Should().BeTrue();
+
+ using JsonDocument doc = JsonDocument.Parse(responseBody!);
+ JsonElement root = doc.RootElement;
+
+ if (root.ValueKind == JsonValueKind.Object &&
+ !root.TryGetProperty(field, out _) &&
+ root.TryGetProperty("payload", out JsonElement payloadElem))
+ {
+ AssertFieldPresent(payloadElem, field);
+ return;
+ }
+
+ AssertFieldPresent(root, field);
+ }
+
///
/// Recurses into arrays so a listing endpoint's response can be asserted the same way as a
/// single-resource response.
@@ -203,4 +222,31 @@ public class ApiGeneralSteps(ScenarioContext scenario)
break;
}
}
+
+ ///
+ /// Recurses into arrays so every item of a listing endpoint's response must carry the field.
+ ///
+ private static void AssertFieldPresent(JsonElement element, string field)
+ {
+ switch (element.ValueKind)
+ {
+ case JsonValueKind.Object:
+ element
+ .TryGetProperty(field, out _)
+ .Should()
+ .BeTrue("Expected field '{0}' to be present in the response", field);
+ break;
+ case JsonValueKind.Array:
+ element
+ .EnumerateArray()
+ .Should()
+ .NotBeEmpty(
+ "Expected at least one item to assert field '{0}' against",
+ field
+ );
+ foreach (JsonElement item in element.EnumerateArray())
+ AssertFieldPresent(item, field);
+ break;
+ }
+ }
}
diff --git a/web/backend/API/API.Specs/Steps/UserSteps.cs b/web/backend/API/API.Specs/Steps/UserSteps.cs
index d1957205..e885f51d 100644
--- a/web/backend/API/API.Specs/Steps/UserSteps.cs
+++ b/web/backend/API/API.Specs/Steps/UserSteps.cs
@@ -1,5 +1,9 @@
using System.Text.Json;
+using Features.Users.Commands.Profile.UploadAvatar;
using FluentAssertions;
+using MediatR;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
using Reqnroll;
namespace API.Specs.Steps;
@@ -10,6 +14,10 @@ public class UserSteps(ScenarioContext scenario) : ApiStepsBase(scenario)
private const string AccessTokenKey = "accessToken";
private const string RegisteredUserIdKey = "registeredUserId";
private const string RegisteredUsernameKey = "registeredUsername";
+ private const string CurrentAvatarUrlKey = "currentAvatarUrl";
+ private const string PreviousAvatarUrlKey = "previousAvatarUrl";
+
+ private static readonly byte[] PngSignature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
private async Task SendAsync(HttpRequestMessage requestMessage)
{
@@ -36,6 +44,99 @@ public class UserSteps(ScenarioContext scenario) : ApiStepsBase(scenario)
: throw new InvalidOperationException("registered user ID not found in scenario");
}
+ ///
+ /// Builds the smallest upload the photo validator accepts: a PNG signature padded out to the
+ /// 1 KiB minimum.
+ ///
+ private static IFormFile CreateAvatarFile()
+ {
+ byte[] content = new byte[2048];
+ PngSignature.CopyTo(content, 0);
+ MemoryStream stream = new(content);
+
+ return new FormFile(stream, 0, content.Length, "file", "avatar.png")
+ {
+ Headers = new HeaderDictionary { ["Content-Type"] = "image/png" },
+ };
+ }
+
+ private async Task UploadAvatarAsync()
+ {
+ Guid userId = GetRegisteredUserId();
+
+ using IServiceScope scope = GetFactory().Services.CreateScope();
+ IMediator mediator = scope.ServiceProvider.GetRequiredService();
+ Guid photoId = await mediator.Send(new UploadAvatarCommand(userId, CreateAvatarFile()));
+
+ if (Scenario.TryGetValue(CurrentAvatarUrlKey, out string? existing))
+ Scenario[PreviousAvatarUrlKey] = existing;
+
+ Scenario[CurrentAvatarUrlKey] = $"avatars/{userId}/{photoId}";
+ }
+
+ private string GetCurrentAvatarUrl()
+ {
+ return Scenario.TryGetValue(CurrentAvatarUrlKey, out string? url) && url is not null
+ ? url
+ : throw new InvalidOperationException("no avatar has been uploaded in this scenario");
+ }
+
+ private string GetPreviousAvatarUrl()
+ {
+ return Scenario.TryGetValue(PreviousAvatarUrlKey, out string? url) && url is not null
+ ? url
+ : throw new InvalidOperationException("only one avatar has been uploaded in this scenario");
+ }
+
+ [Given("I have uploaded an avatar")]
+ public async Task GivenIHaveUploadedAnAvatar()
+ {
+ await UploadAvatarAsync();
+ }
+
+ [Given("I have uploaded a new avatar, replacing the previous one")]
+ public async Task GivenIHaveUploadedANewAvatarReplacingThePreviousOne()
+ {
+ await UploadAvatarAsync();
+ }
+
+ [Then("the public profile response should carry my current avatar")]
+ public void ThenThePublicProfileResponseShouldCarryMyCurrentAvatar()
+ {
+ Scenario.TryGetValue(ResponseBodyKey, out string? responseBody).Should().BeTrue();
+
+ using JsonDocument doc = JsonDocument.Parse(responseBody!);
+ doc.RootElement.GetProperty("avatarUrl").GetString().Should().Be(GetCurrentAvatarUrl());
+ }
+
+ [Then("the public profile response should not carry my previous avatar")]
+ public void ThenThePublicProfileResponseShouldNotCarryMyPreviousAvatar()
+ {
+ Scenario.TryGetValue(ResponseBodyKey, out string? responseBody).Should().BeTrue();
+
+ using JsonDocument doc = JsonDocument.Parse(responseBody!);
+ doc.RootElement.GetProperty("avatarUrl").GetString().Should().NotBe(GetPreviousAvatarUrl());
+ }
+
+ [Then("my listed account should carry my current avatar")]
+ public void ThenMyListedAccountShouldCarryMyCurrentAvatar()
+ {
+ Scenario.TryGetValue(ResponseBodyKey, out string? responseBody).Should().BeTrue();
+
+ using JsonDocument doc = JsonDocument.Parse(responseBody!);
+ doc.RootElement.ValueKind.Should().Be(JsonValueKind.Array);
+
+ JsonElement listed = doc
+ .RootElement.EnumerateArray()
+ .Should()
+ .ContainSingle(item =>
+ item.GetProperty("userAccountId").GetGuid() == GetRegisteredUserId()
+ )
+ .Subject;
+
+ listed.GetProperty("avatarUrl").GetString().Should().Be(GetCurrentAvatarUrl());
+ }
+
[When("I retrieve the public profile by ID")]
public async Task WhenIRetrieveThePublicProfileById()
{
diff --git a/web/backend/API/API.Specs/TestApiFactory.cs b/web/backend/API/API.Specs/TestApiFactory.cs
index e6b5c1e0..c9e57a00 100644
--- a/web/backend/API/API.Specs/TestApiFactory.cs
+++ b/web/backend/API/API.Specs/TestApiFactory.cs
@@ -1,6 +1,7 @@
using API.Core;
using API.Specs.Mocks;
using Features.Emails.Services;
+using Features.ImageUploads.Services;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
@@ -32,6 +33,15 @@ internal class TestApiFactory : WebApplicationFactory
services.Remove(emailDispatcherDescriptor);
services.AddScoped();
+
+ ServiceDescriptor? fileStorageDescriptor = services.SingleOrDefault(d =>
+ d.ServiceType == typeof(IFileStorageProvider)
+ );
+
+ if (fileStorageDescriptor != null)
+ services.Remove(fileStorageDescriptor);
+
+ services.AddSingleton();
});
}
}
diff --git a/web/backend/Domain/Domain.Entities/Entities/UserAccount.cs b/web/backend/Domain/Domain.Entities/Entities/UserAccount.cs
index 8a974e94..b512dc15 100644
--- a/web/backend/Domain/Domain.Entities/Entities/UserAccount.cs
+++ b/web/backend/Domain/Domain.Entities/Entities/UserAccount.cs
@@ -20,4 +20,5 @@ public sealed class UserAccount
// Navigation properties
public UserCredential? UserCredential { get; set; }
public UserProfile? UserProfile { get; set; }
+ public UserAvatar? UserAvatar { get; set; }
}
diff --git a/web/backend/Features/Features.Users.Tests/Queries/GetAllUsersHandlerTests.cs b/web/backend/Features/Features.Users.Tests/Queries/GetAllUsersHandlerTests.cs
index c7f8bd44..7d6d7086 100644
--- a/web/backend/Features/Features.Users.Tests/Queries/GetAllUsersHandlerTests.cs
+++ b/web/backend/Features/Features.Users.Tests/Queries/GetAllUsersHandlerTests.cs
@@ -47,4 +47,43 @@ public class GetAllUsersHandlerTests
result.Should().ContainSingle().Which.Should().BeEquivalentTo(user.ToPublicProfileDto());
}
+
+ [Fact]
+ public async Task Handle_MapsEachUsersOwnAvatarUrl()
+ {
+ Mock repoMock = new();
+ GetAllUsersHandler handler = new(repoMock.Object);
+ UserAccount withAvatar = new()
+ {
+ UserAccountId = Guid.NewGuid(),
+ Username = "has-avatar",
+ FirstName = "Has",
+ LastName = "Avatar",
+ CreatedAt = DateTime.UtcNow,
+ UserProfile = new UserProfile { Biography = "Drinks lagers." },
+ UserAvatar = new UserAvatar
+ {
+ Photo = new Photo { Hyperlink = "https://cdn.example.com/avatar.jpg" },
+ },
+ };
+ UserAccount withoutAvatar = new()
+ {
+ UserAccountId = Guid.NewGuid(),
+ Username = "no-avatar",
+ FirstName = "No",
+ LastName = "Avatar",
+ CreatedAt = DateTime.UtcNow,
+ };
+ repoMock.Setup(r => r.GetAllAsync(null, null)).ReturnsAsync([withAvatar, withoutAvatar]);
+
+ IEnumerable result = await handler.Handle(
+ new GetAllUsersQuery(null, null),
+ CancellationToken.None
+ );
+
+ result
+ .Select(dto => dto.AvatarUrl)
+ .Should()
+ .Equal("https://cdn.example.com/avatar.jpg", string.Empty);
+ }
}
diff --git a/web/backend/Features/Features.Users.Tests/Queries/GetPublicUserProfileByIdHandlerTests.cs b/web/backend/Features/Features.Users.Tests/Queries/GetPublicUserProfileByIdHandlerTests.cs
index 73d57615..c0dd6e5c 100644
--- a/web/backend/Features/Features.Users.Tests/Queries/GetPublicUserProfileByIdHandlerTests.cs
+++ b/web/backend/Features/Features.Users.Tests/Queries/GetPublicUserProfileByIdHandlerTests.cs
@@ -31,6 +31,10 @@ public class GetPublicUserProfileByIdHandlerTests
DateOfBirth = new DateTime(1990, 1, 1),
CreatedAt = new DateTime(2024, 1, 1),
UserProfile = new UserProfile { Biography = "Drinks lagers." },
+ UserAvatar = new UserAvatar
+ {
+ Photo = new Photo { Hyperlink = "https://cdn.example.com/avatar.jpg" },
+ },
};
_repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
@@ -48,13 +52,14 @@ public class GetPublicUserProfileByIdHandlerTests
user.FirstName,
user.LastName,
"Drinks lagers.",
+ "https://cdn.example.com/avatar.jpg",
user.CreatedAt
)
);
}
[Fact]
- public async Task Handle_ReturnsEmptyBiography_WhenProfileMissing()
+ public async Task Handle_ReturnsEmptyBiographyAndAvatarUrl_WhenProfileMissing()
{
UserAccount user = new()
{
@@ -72,6 +77,53 @@ public class GetPublicUserProfileByIdHandlerTests
);
result.Biography.Should().BeEmpty();
+ result.AvatarUrl.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task Handle_ReturnsEmptyAvatarUrl_WhenProfileHasNoAvatar()
+ {
+ UserAccount user = new()
+ {
+ UserAccountId = Guid.NewGuid(),
+ Username = "test",
+ FirstName = "Jane",
+ LastName = "Doe",
+ CreatedAt = new DateTime(2024, 1, 1),
+ UserProfile = new UserProfile { Biography = "Drinks lagers." },
+ };
+ _repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
+
+ PublicUserProfileDto result = await _handler.Handle(
+ new GetPublicUserProfileByIdQuery(user.UserAccountId),
+ CancellationToken.None
+ );
+
+ result.Biography.Should().Be("Drinks lagers.");
+ result.AvatarUrl.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task Handle_ReturnsEmptyAvatarUrl_WhenAvatarHasNoPhoto()
+ {
+ UserAccount user = new()
+ {
+ UserAccountId = Guid.NewGuid(),
+ Username = "test",
+ FirstName = "Jane",
+ LastName = "Doe",
+ CreatedAt = new DateTime(2024, 1, 1),
+ UserProfile = new UserProfile { Biography = "Drinks lagers." },
+ UserAvatar = new UserAvatar(),
+ };
+ _repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
+
+ PublicUserProfileDto result = await _handler.Handle(
+ new GetPublicUserProfileByIdQuery(user.UserAccountId),
+ CancellationToken.None
+ );
+
+ result.AvatarUrl.Should().BeEmpty();
}
[Fact]
diff --git a/web/backend/Features/Features.Users/Dtos/UserDtoMapper.cs b/web/backend/Features/Features.Users/Dtos/UserDtoMapper.cs
index 47277365..027ffbf0 100644
--- a/web/backend/Features/Features.Users/Dtos/UserDtoMapper.cs
+++ b/web/backend/Features/Features.Users/Dtos/UserDtoMapper.cs
@@ -14,6 +14,7 @@ public static class UserDtoMapper
FirstName : user.FirstName,
LastName : user.LastName,
Biography : user.UserProfile?.Biography ?? string.Empty,
+ AvatarUrl : user.UserAvatar?.Photo?.Hyperlink ?? string.Empty,
CreatedAt : user.CreatedAt
);
}
diff --git a/web/backend/Features/Features.Users/Dtos/UserDtos.cs b/web/backend/Features/Features.Users/Dtos/UserDtos.cs
index 2aee8af4..39ec4a9a 100644
--- a/web/backend/Features/Features.Users/Dtos/UserDtos.cs
+++ b/web/backend/Features/Features.Users/Dtos/UserDtos.cs
@@ -7,5 +7,6 @@ public record PublicUserProfileDto(
string FirstName,
string LastName,
string Biography,
+ string AvatarUrl,
DateTime CreatedAt
);
diff --git a/web/backend/Features/Features.Users/Repository/UserListRepository.cs b/web/backend/Features/Features.Users/Repository/UserListRepository.cs
index f5dc8543..b370f487 100644
--- a/web/backend/Features/Features.Users/Repository/UserListRepository.cs
+++ b/web/backend/Features/Features.Users/Repository/UserListRepository.cs
@@ -13,61 +13,87 @@ public class UserListRepository(ISqlConnectionFactory connectionFactory)
IUserListRepository
{
///
- ///
- /// Left-joins Social.UserProfile, so is
- /// null for an account whose profile has yet to be created.
- ///
public async Task GetByIdAsync(Guid id)
{
await using DbConnection connection = await CreateConnection();
IEnumerable results = await connection.QueryAsync<
UserAccount,
UserProfile,
+ Photo,
+ UserAvatar,
UserAccount
>(
"""
SELECT
- ua.UserAccountID,
- ua.Username,
- ua.FirstName, ua.LastName, ua.Email, ua.CreatedAt, ua.UpdatedAt,
- ua.DateOfBirth, ua.RowVersion,
- up.UserProfileID, up.UserAccountID, up.Biography, up.RowVersion
+ ua.UserAccountID, ua.Username, ua.FirstName, ua.LastName,
+ ua.Email, ua.CreatedAt, ua.UpdatedAt, ua.DateOfBirth,
+ ua.RowVersion, up.UserProfileID, up.UserAccountID,
+ up.Biography, up.RowVersion, p.PhotoID, p.UploadedByID,
+ p.Hyperlink, p.UploadedAt, p.RowVersion, av.UserAvatarID,
+ av.UserProfileID, av.PhotoID, av.ValidFrom, av.ValidTo,
+ av.RowVersion
FROM Auth.UserAccount ua
- LEFT JOIN Social.UserProfile up ON ua.UserAccountID = up.UserAccountID
+ LEFT JOIN Social.UserProfile up
+ ON ua.UserAccountID = up.UserAccountID
+ LEFT JOIN Social.UserAvatar av
+ ON up.UserProfileID = av.UserProfileID
+ AND av.ValidTo IS NULL
+ LEFT JOIN Media.Photo p ON av.PhotoID = p.PhotoID
WHERE ua.UserAccountID = @UserAccountId
""",
MapUserRow,
new { UserAccountId = id },
- splitOn: "UserProfileID"
+ splitOn: AvatarSplitOn
);
return results.SingleOrDefault();
}
- private static UserAccount MapUserRow(UserAccount user, UserProfile? profile)
- {
- user.UserProfile = profile;
- return user;
- }
-
///
- ///
- /// Always applies pagination via OFFSET/FETCH, ordered by creation date descending.
- /// A null / is treated as "no limit"/"no offset".
- /// Does not select Email or DateOfBirth: this listing is consumed only as public
- /// profiles, so those fields are left at their default rather than read from the database.
- ///
public async Task> GetAllAsync(int? limit, int? offset)
{
await using DbConnection connection = await CreateConnection();
- return await connection.QueryAsync(
+ return await connection.QueryAsync<
+ UserAccount,
+ UserProfile,
+ Photo,
+ UserAvatar,
+ UserAccount
+ >(
"""
- SELECT UserAccountID, Username, FirstName, LastName, CreatedAt, UpdatedAt, RowVersion
- FROM Auth.UserAccount
- ORDER BY CreatedAt DESC
+ SELECT
+ ua.UserAccountID, ua.Username, ua.FirstName, ua.LastName,
+ ua.CreatedAt, ua.UpdatedAt, ua.RowVersion,
+ up.UserProfileID, up.UserAccountID, up.Biography, up.RowVersion,
+ p.PhotoID, p.UploadedByID, p.Hyperlink, p.UploadedAt, p.RowVersion,
+ av.UserAvatarID, av.UserProfileID, av.PhotoID, av.ValidFrom, av.ValidTo,
+ av.RowVersion
+ FROM Auth.UserAccount ua
+ LEFT JOIN Social.UserProfile up ON ua.UserAccountID = up.UserAccountID
+ LEFT JOIN Social.UserAvatar av
+ ON up.UserProfileID = av.UserProfileID AND av.ValidTo IS NULL
+ LEFT JOIN Media.Photo p ON av.PhotoID = p.PhotoID
+ ORDER BY ua.CreatedAt DESC
OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY
""",
- new { Offset = offset ?? 0, Limit = limit ?? int.MaxValue }
+ MapUserRow,
+ new { Offset = offset ?? 0, Limit = limit ?? int.MaxValue },
+ splitOn: AvatarSplitOn
);
}
+
+ private const string AvatarSplitOn = "UserProfileID,PhotoID,UserAvatarID";
+
+ private static UserAccount MapUserRow(
+ UserAccount user,
+ UserProfile? profile,
+ Photo? photo,
+ UserAvatar? avatar
+ )
+ {
+ user.UserProfile = profile;
+ avatar?.Photo = photo;
+ user.UserAvatar = avatar;
+ return user;
+ }
}