Skip to content

Commit 68f14be

Browse files
xenobiasoftclaude
andcommitted
Implement player profile creation flow and profile page (#211, #213)
Adds the full player profile feature: first-visit onboarding gate, persistent UserProfile aggregate in CosmosDB, silent migration of legacy alias-only users, and /profile page with alias editing for both React and Blazor frontends. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6c7e826 commit 68f14be

49 files changed

Lines changed: 2098 additions & 179 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

infra/modules/storage.bicep

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,46 @@ resource gamesThroughput 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/con
229229
}
230230
}
231231

232+
resource profilesContainer 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers@2024-05-15' = {
233+
parent: sudokuDatabase
234+
name: 'profiles'
235+
properties: {
236+
resource: {
237+
id: 'profiles'
238+
partitionKey: {
239+
paths: ['/alias']
240+
kind: 'Hash'
241+
version: 2
242+
}
243+
indexingPolicy: {
244+
indexingMode: 'consistent'
245+
automatic: true
246+
includedPaths: [{ path: '/*' }]
247+
excludedPaths: [{ path: '/"_etag"/?' }]
248+
}
249+
uniqueKeyPolicy: {
250+
uniqueKeys: [
251+
{ paths: ['/alias'] }
252+
]
253+
}
254+
conflictResolutionPolicy: {
255+
mode: 'LastWriterWins'
256+
conflictResolutionPath: '/_ts'
257+
}
258+
}
259+
}
260+
}
261+
262+
resource profilesThroughput 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/throughputSettings@2024-05-15' = {
263+
parent: profilesContainer
264+
name: 'default'
265+
properties: {
266+
resource: {
267+
throughput: 400
268+
}
269+
}
270+
}
271+
232272
output storageAccountId string = storageAccount.id
233273
output storageAccountName string = storageAccount.name
234274
output cosmosDbAccountId string = cosmosDbAccount.id
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
using MediatR;
2+
using Microsoft.AspNetCore.Mvc;
3+
using Sudoku.Api.Models;
4+
using Sudoku.Application.Commands;
5+
using Sudoku.Application.DTOs;
6+
using Sudoku.Application.Queries;
7+
8+
namespace Sudoku.Api.Controllers;
9+
10+
[Route("api/profiles")]
11+
[ApiController]
12+
public class ProfilesController(IMediator mediator) : ControllerBase
13+
{
14+
[HttpPost]
15+
[ProducesResponseType(typeof(ProfileDto), StatusCodes.Status201Created)]
16+
[ProducesResponseType(StatusCodes.Status400BadRequest)]
17+
[ProducesResponseType(StatusCodes.Status409Conflict)]
18+
public async Task<ActionResult<ProfileDto>> CreateProfileAsync([FromBody] CreateProfileRequest request)
19+
{
20+
var result = await mediator.Send(new CreateProfileCommand(request.Alias));
21+
22+
if (!result.IsSuccess)
23+
{
24+
if (result.Error!.Contains("already taken", StringComparison.OrdinalIgnoreCase))
25+
return Conflict(result.Error);
26+
return BadRequest(result.Error);
27+
}
28+
29+
return StatusCode(StatusCodes.Status201Created, result.Value);
30+
}
31+
32+
[HttpGet("{alias}")]
33+
[ProducesResponseType(typeof(ProfileDto), StatusCodes.Status200OK)]
34+
[ProducesResponseType(StatusCodes.Status404NotFound)]
35+
public async Task<ActionResult<ProfileDto>> GetProfileAsync(string alias)
36+
{
37+
var result = await mediator.Send(new GetProfileByAliasQuery(alias));
38+
39+
if (!result.IsSuccess)
40+
return BadRequest(result.Error);
41+
42+
if (result.Value == null)
43+
return NotFound();
44+
45+
return Ok(result.Value);
46+
}
47+
48+
[HttpPatch("{alias}")]
49+
[ProducesResponseType(typeof(ProfileDto), StatusCodes.Status200OK)]
50+
[ProducesResponseType(StatusCodes.Status400BadRequest)]
51+
[ProducesResponseType(StatusCodes.Status404NotFound)]
52+
[ProducesResponseType(StatusCodes.Status409Conflict)]
53+
public async Task<ActionResult<ProfileDto>> UpdateProfileAliasAsync(string alias, [FromBody] UpdateProfileAliasRequest request)
54+
{
55+
// First resolve the profile by alias to get profileId
56+
var getResult = await mediator.Send(new GetProfileByAliasQuery(alias));
57+
if (!getResult.IsSuccess)
58+
return BadRequest(getResult.Error);
59+
if (getResult.Value == null)
60+
return NotFound();
61+
62+
var updateResult = await mediator.Send(new UpdateProfileAliasCommand(getResult.Value.ProfileId, request.NewAlias));
63+
64+
if (!updateResult.IsSuccess)
65+
{
66+
if (updateResult.Error!.Contains("already taken", StringComparison.OrdinalIgnoreCase))
67+
return Conflict(updateResult.Error);
68+
if (updateResult.Error.Contains("not found", StringComparison.OrdinalIgnoreCase))
69+
return NotFound();
70+
return BadRequest(updateResult.Error);
71+
}
72+
73+
return Ok(updateResult.Value);
74+
}
75+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
using System.ComponentModel.DataAnnotations;
2+
3+
namespace Sudoku.Api.Models;
4+
5+
public record CreateProfileRequest([Required] string Alias);
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
using System.ComponentModel.DataAnnotations;
2+
3+
namespace Sudoku.Api.Models;
4+
5+
public record UpdateProfileAliasRequest([Required] string NewAlias);
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
using Sudoku.Application.Common;
2+
using Sudoku.Application.DTOs;
3+
4+
namespace Sudoku.Application.Commands;
5+
6+
public record CreateProfileCommand(string Alias) : ICommand<ProfileDto>;
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
using Sudoku.Application.Common;
2+
using Sudoku.Application.DTOs;
3+
4+
namespace Sudoku.Application.Commands;
5+
6+
public record UpdateProfileAliasCommand(string ProfileId, string NewAlias) : ICommand<ProfileDto>;
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
using MediatR;
2+
3+
namespace Sudoku.Application.Common;
4+
5+
public interface ICommand<TResult> : IRequest<Result<TResult>> {}
6+
7+
public interface ICommandHandler<in TCommand, TResult> : IRequestHandler<TCommand, Result<TResult>>
8+
where TCommand : ICommand<TResult> {}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
using Sudoku.Domain.Entities;
2+
3+
namespace Sudoku.Application.DTOs;
4+
5+
public record ProfileDto(string ProfileId, string Alias, DateTime CreatedAt, DateTime UpdatedAt)
6+
{
7+
public static ProfileDto FromProfile(UserProfile profile) =>
8+
new(profile.Id.ToString(), profile.Alias.Value, profile.CreatedAt, profile.UpdatedAt);
9+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
using Microsoft.Extensions.Logging;
2+
using Sudoku.Application.Commands;
3+
using Sudoku.Application.Common;
4+
using Sudoku.Application.DTOs;
5+
using Sudoku.Application.Interfaces;
6+
using Sudoku.Domain.Exceptions;
7+
using Sudoku.Domain.ValueObjects;
8+
9+
namespace Sudoku.Application.Handlers;
10+
11+
public class CreateProfileCommandHandler(
12+
IUserProfileRepository profileRepository,
13+
ILogger<CreateProfileCommandHandler> logger) : ICommandHandler<CreateProfileCommand, ProfileDto>
14+
{
15+
public async Task<Result<ProfileDto>> Handle(CreateProfileCommand request, CancellationToken cancellationToken)
16+
{
17+
try
18+
{
19+
var normalizedAlias = request.Alias.Trim().ToLowerInvariant();
20+
var playerAlias = PlayerAlias.Create(normalizedAlias);
21+
22+
if (await profileRepository.AliasExistsAsync(playerAlias))
23+
{
24+
logger.LogWarning("Alias already taken: {Alias}", playerAlias.Value);
25+
return Result<ProfileDto>.Failure($"Alias '{playerAlias.Value}' is already taken.");
26+
}
27+
28+
var profile = Domain.Entities.UserProfile.Create(playerAlias);
29+
await profileRepository.SaveAsync(profile);
30+
31+
logger.LogInformation("Created profile {ProfileId} for alias {Alias}", profile.Id, playerAlias.Value);
32+
return Result<ProfileDto>.Success(ProfileDto.FromProfile(profile));
33+
}
34+
catch (DomainException ex)
35+
{
36+
logger.LogWarning("Domain error creating profile: {Error}", ex.Message);
37+
return Result<ProfileDto>.Failure(ex.Message);
38+
}
39+
catch (Exception ex)
40+
{
41+
logger.LogError(ex, "Unexpected error creating profile for alias {Alias}", request.Alias);
42+
return Result<ProfileDto>.Failure($"An unexpected error occurred: {ex.Message}");
43+
}
44+
}
45+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
using Microsoft.Extensions.Logging;
2+
using Sudoku.Application.Common;
3+
using Sudoku.Application.DTOs;
4+
using Sudoku.Application.Interfaces;
5+
using Sudoku.Application.Queries;
6+
using Sudoku.Domain.Exceptions;
7+
using Sudoku.Domain.ValueObjects;
8+
9+
namespace Sudoku.Application.Handlers;
10+
11+
public class GetProfileByAliasQueryHandler(
12+
IUserProfileRepository profileRepository,
13+
ILogger<GetProfileByAliasQueryHandler> logger) : IQueryHandler<GetProfileByAliasQuery, ProfileDto?>
14+
{
15+
public async Task<Result<ProfileDto?>> Handle(GetProfileByAliasQuery request, CancellationToken cancellationToken)
16+
{
17+
try
18+
{
19+
var normalizedAlias = request.Alias.Trim().ToLowerInvariant();
20+
var playerAlias = PlayerAlias.Create(normalizedAlias);
21+
var profile = await profileRepository.GetByAliasAsync(playerAlias);
22+
23+
if (profile == null)
24+
{
25+
logger.LogDebug("Profile not found for alias {Alias}", playerAlias.Value);
26+
return Result<ProfileDto?>.Success(null);
27+
}
28+
29+
logger.LogDebug("Retrieved profile {ProfileId} for alias {Alias}", profile.Id, playerAlias.Value);
30+
return Result<ProfileDto?>.Success(ProfileDto.FromProfile(profile));
31+
}
32+
catch (DomainException ex)
33+
{
34+
logger.LogWarning("Domain error getting profile for alias {Alias}: {Error}", request.Alias, ex.Message);
35+
return Result<ProfileDto?>.Failure(ex.Message);
36+
}
37+
catch (Exception ex)
38+
{
39+
logger.LogError(ex, "Unexpected error getting profile for alias {Alias}", request.Alias);
40+
return Result<ProfileDto?>.Failure($"An unexpected error occurred: {ex.Message}");
41+
}
42+
}
43+
}

0 commit comments

Comments
 (0)