Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions infra/modules/storage.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,44 @@ resource gamesThroughput 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/con
}
}

resource profilesContainer 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers@2024-05-15' = {
parent: sudokuDatabase
name: 'profiles'
properties: {
resource: {
id: 'profiles'
partitionKey: {
paths: ['/profileId']
kind: 'Hash'
version: 2
}
indexingPolicy: {
indexingMode: 'consistent'
automatic: true
includedPaths: [{ path: '/*' }]
excludedPaths: [{ path: '/"_etag"/?' }]
}
uniqueKeyPolicy: {
uniqueKeys: []
}
conflictResolutionPolicy: {
mode: 'LastWriterWins'
conflictResolutionPath: '/_ts'
}
}
}
}

resource profilesThroughput 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/throughputSettings@2024-05-15' = {
parent: profilesContainer
name: 'default'
properties: {
resource: {
throughput: 400
}
}
}

output storageAccountId string = storageAccount.id
output storageAccountName string = storageAccount.name
output cosmosDbAccountId string = cosmosDbAccount.id
Expand Down
76 changes: 76 additions & 0 deletions src/backend/Sudoku.Api/Controllers/ProfilesController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Sudoku.Api.Models;
using Sudoku.Application.Commands;
using Sudoku.Application.Common;
using Sudoku.Application.DTOs;
using Sudoku.Application.Queries;

namespace Sudoku.Api.Controllers;

[Route("api/profiles")]
[ApiController]
public class ProfilesController(IMediator mediator) : ControllerBase
{
[HttpPost]
[ProducesResponseType(typeof(ProfileDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<ProfileDto>> CreateProfileAsync([FromBody] CreateProfileRequest request)
{
var result = await mediator.Send(new CreateProfileCommand(request.Alias));

if (!result.IsSuccess)
{
if (result.ErrorCode == ProfileErrorCodes.AliasTaken)
return Conflict(result.Error);
return BadRequest(result.Error);
}
Comment thread
xenobiasoft marked this conversation as resolved.

return StatusCode(StatusCodes.Status201Created, result.Value);
}

[HttpGet("{alias}")]
[ProducesResponseType(typeof(ProfileDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ProfileDto>> GetProfileAsync(string alias)
{
var result = await mediator.Send(new GetProfileByAliasQuery(alias));

if (!result.IsSuccess)
return BadRequest(result.Error);

if (result.Value == null)
return NotFound();

return Ok(result.Value);
}

[HttpPatch("{alias}")]
[ProducesResponseType(typeof(ProfileDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<ProfileDto>> UpdateProfileAliasAsync(string alias, [FromBody] UpdateProfileAliasRequest request)
{
// First resolve the profile by alias to get profileId
var getResult = await mediator.Send(new GetProfileByAliasQuery(alias));
if (!getResult.IsSuccess)
return BadRequest(getResult.Error);
if (getResult.Value == null)
return NotFound();

var updateResult = await mediator.Send(new UpdateProfileAliasCommand(getResult.Value.ProfileId, request.NewAlias));

if (!updateResult.IsSuccess)
{
if (updateResult.ErrorCode == ProfileErrorCodes.AliasTaken)
return Conflict(updateResult.Error);
if (updateResult.ErrorCode == ProfileErrorCodes.NotFound)
return NotFound();
return BadRequest(updateResult.Error);
}

return Ok(updateResult.Value);
}
}
5 changes: 5 additions & 0 deletions src/backend/Sudoku.Api/Models/CreateProfileRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
using System.ComponentModel.DataAnnotations;

namespace Sudoku.Api.Models;

public record CreateProfileRequest([Required] string Alias);
5 changes: 5 additions & 0 deletions src/backend/Sudoku.Api/Models/UpdateProfileAliasRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
using System.ComponentModel.DataAnnotations;

namespace Sudoku.Api.Models;

public record UpdateProfileAliasRequest([Required] string NewAlias);
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
using Sudoku.Application.Common;
using Sudoku.Application.DTOs;

namespace Sudoku.Application.Commands;

public record CreateProfileCommand(string Alias) : ICommand<ProfileDto>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
using Sudoku.Application.Common;
using Sudoku.Application.DTOs;

namespace Sudoku.Application.Commands;

public record UpdateProfileAliasCommand(string ProfileId, string NewAlias) : ICommand<ProfileDto>;
8 changes: 8 additions & 0 deletions src/backend/Sudoku.Application/Common/ICommandOfT.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using MediatR;

namespace Sudoku.Application.Common;

public interface ICommand<TResult> : IRequest<Result<TResult>> {}

public interface ICommandHandler<in TCommand, TResult> : IRequestHandler<TCommand, Result<TResult>>
where TCommand : ICommand<TResult> {}
8 changes: 8 additions & 0 deletions src/backend/Sudoku.Application/Common/ProfileErrorCodes.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Sudoku.Application.Common;

public static class ProfileErrorCodes
{
public const string AliasTaken = "ALIAS_TAKEN";
public const string NotFound = "PROFILE_NOT_FOUND";
public const string SameAlias = "SAME_ALIAS";
}
18 changes: 12 additions & 6 deletions src/backend/Sudoku.Application/Common/Result.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,22 @@ public class Result<T>
public bool IsSuccess { get; }
public T? Value { get; }
public string? Error { get; }
public string? ErrorCode { get; }
public List<string> Errors { get; }

private Result(bool isSuccess, T? value, string? error, List<string>? errors = null)
private Result(bool isSuccess, T? value, string? error, string? errorCode = null, List<string>? errors = null)
{
IsSuccess = isSuccess;
Value = value;
Error = error;
ErrorCode = errorCode;
Errors = errors ?? new List<string>();
}

public static Result<T> Success(T value) => new(true, value, null);
public static Result<T> Failure(string error) => new(false, default, error, new List<string> { error });
public static Result<T> Failure(List<string> errors) => new(false, default, errors.FirstOrDefault(), errors);
public static Result<T> Failure(string error) => new(false, default, error, null, [error]);
public static Result<T> Failure(string error, string errorCode) => new(false, default, error, errorCode, [error]);
public static Result<T> Failure(List<string> errors) => new(false, default, errors.FirstOrDefault(), null, errors);

public Result<TNew> Map<TNew>(Func<T, TNew> mapper)
{
Expand All @@ -29,16 +32,19 @@ public class Result
{
public bool IsSuccess { get; }
public string? Error { get; }
public string? ErrorCode { get; }
public List<string> Errors { get; }

private Result(bool isSuccess, string? error, List<string>? errors = null)
private Result(bool isSuccess, string? error, string? errorCode = null, List<string>? errors = null)
{
IsSuccess = isSuccess;
Error = error;
ErrorCode = errorCode;
Errors = errors ?? new List<string>();
}

public static Result Success() => new(true, null);
public static Result Failure(string error) => new(false, error, new List<string> { error });
public static Result Failure(List<string> errors) => new(false, errors.FirstOrDefault(), errors);
public static Result Failure(string error) => new(false, error, null, [error]);
public static Result Failure(string error, string errorCode) => new(false, error, errorCode, [error]);
public static Result Failure(List<string> errors) => new(false, errors.FirstOrDefault(), null, errors);
}
9 changes: 9 additions & 0 deletions src/backend/Sudoku.Application/DTOs/ProfileDto.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using Sudoku.Domain.Entities;

namespace Sudoku.Application.DTOs;

public record ProfileDto(string ProfileId, string Alias, DateTime CreatedAt, DateTime UpdatedAt)
{
public static ProfileDto FromProfile(UserProfile profile) =>
new(profile.Id.ToString(), profile.Alias.Value, profile.CreatedAt, profile.UpdatedAt);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using Microsoft.Extensions.Logging;
using Sudoku.Application.Commands;
using Sudoku.Application.Common;
using Sudoku.Application.DTOs;
using Sudoku.Application.Interfaces;
using Sudoku.Domain.Exceptions;
using Sudoku.Domain.ValueObjects;

namespace Sudoku.Application.Handlers;

public class CreateProfileCommandHandler(
IUserProfileRepository profileRepository,
ILogger<CreateProfileCommandHandler> logger) : ICommandHandler<CreateProfileCommand, ProfileDto>
{
public async Task<Result<ProfileDto>> Handle(CreateProfileCommand request, CancellationToken cancellationToken)
{
try
{
var normalizedAlias = request.Alias.Trim().ToLowerInvariant();
var playerAlias = PlayerAlias.Create(normalizedAlias);

if (await profileRepository.AliasExistsAsync(playerAlias))
{
logger.LogWarning("Alias already taken: {Alias}", playerAlias.Value);
return Result<ProfileDto>.Failure($"Alias '{playerAlias.Value}' is already taken.", ProfileErrorCodes.AliasTaken);
}

var profile = Domain.Entities.UserProfile.Create(playerAlias);
await profileRepository.SaveAsync(profile);

logger.LogInformation("Created profile {ProfileId} for alias {Alias}", profile.Id, playerAlias.Value);
return Result<ProfileDto>.Success(ProfileDto.FromProfile(profile));
}
catch (DomainException ex)
{
logger.LogWarning("Domain error creating profile: {Error}", ex.Message);
return Result<ProfileDto>.Failure(ex.Message);
}
catch (Exception ex)
{
logger.LogError(ex, "Unexpected error creating profile for alias {Alias}", request.Alias);
return Result<ProfileDto>.Failure($"An unexpected error occurred: {ex.Message}");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using Microsoft.Extensions.Logging;
using Sudoku.Application.Common;
using Sudoku.Application.DTOs;
using Sudoku.Application.Interfaces;
using Sudoku.Application.Queries;
using Sudoku.Domain.Exceptions;
using Sudoku.Domain.ValueObjects;

namespace Sudoku.Application.Handlers;

public class GetProfileByAliasQueryHandler(
IUserProfileRepository profileRepository,
ILogger<GetProfileByAliasQueryHandler> logger) : IQueryHandler<GetProfileByAliasQuery, ProfileDto?>
{
public async Task<Result<ProfileDto?>> Handle(GetProfileByAliasQuery request, CancellationToken cancellationToken)
{
try
{
var normalizedAlias = request.Alias.Trim().ToLowerInvariant();
var playerAlias = PlayerAlias.Create(normalizedAlias);
var profile = await profileRepository.GetByAliasAsync(playerAlias);

if (profile == null)
{
logger.LogDebug("Profile not found for alias {Alias}", playerAlias.Value);
return Result<ProfileDto?>.Success(null);
}

logger.LogDebug("Retrieved profile {ProfileId} for alias {Alias}", profile.Id, playerAlias.Value);
return Result<ProfileDto?>.Success(ProfileDto.FromProfile(profile));
}
catch (DomainException ex)
{
logger.LogWarning("Domain error getting profile for alias {Alias}: {Error}", request.Alias, ex.Message);
return Result<ProfileDto?>.Failure(ex.Message);
}
catch (Exception ex)
{
logger.LogError(ex, "Unexpected error getting profile for alias {Alias}", request.Alias);
return Result<ProfileDto?>.Failure($"An unexpected error occurred: {ex.Message}");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
using Microsoft.Extensions.Logging;
using Sudoku.Application.Commands;
using Sudoku.Application.Common;
using Sudoku.Application.DTOs;
using Sudoku.Application.Interfaces;
using Sudoku.Domain.Exceptions;
using Sudoku.Domain.ValueObjects;

namespace Sudoku.Application.Handlers;

public class UpdateProfileAliasCommandHandler(
IUserProfileRepository profileRepository,
IGameRepository gameRepository,
ILogger<UpdateProfileAliasCommandHandler> logger) : ICommandHandler<UpdateProfileAliasCommand, ProfileDto>
{
public async Task<Result<ProfileDto>> Handle(UpdateProfileAliasCommand request, CancellationToken cancellationToken)
{
try
{
var normalizedAlias = request.NewAlias.Trim().ToLowerInvariant();
var newAlias = PlayerAlias.Create(normalizedAlias);

var profile = await profileRepository.GetByIdAsync(ProfileId.From(request.ProfileId));
if (profile == null)
{
return Result<ProfileDto>.Failure($"Profile not found: {request.ProfileId}", ProfileErrorCodes.NotFound);
}

if (string.Equals(profile.Alias.Value, newAlias.Value, StringComparison.OrdinalIgnoreCase))
{
return Result<ProfileDto>.Success(ProfileDto.FromProfile(profile));
}

if (await profileRepository.AliasExistsAsync(newAlias))
{
logger.LogWarning("Alias already taken: {Alias}", newAlias.Value);
return Result<ProfileDto>.Failure($"Alias '{newAlias.Value}' is already taken.", ProfileErrorCodes.AliasTaken);
}
Comment thread
xenobiasoft marked this conversation as resolved.

var oldAlias = profile.Alias;
profile.UpdateAlias(newAlias);
await profileRepository.SaveAsync(profile);

var games = await gameRepository.GetByPlayerAsync(oldAlias);
foreach (var game in games)
{
try
{
// Update playerAlias via reconstitution is not straightforward;
// games store alias as a string field - update via SaveAsync after mutation
// The domain game does not expose alias mutation — we need to handle this at document level
// For now, we log a note; a full implementation would update the document directly
logger.LogDebug("Game {GameId} associated with old alias {OldAlias} — batch update pending", game.Id, oldAlias.Value);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to update game {GameId} for alias change", game.Id);
}
}
Comment thread
xenobiasoft marked this conversation as resolved.

logger.LogInformation("Updated alias from {OldAlias} to {NewAlias} for profile {ProfileId}",
oldAlias.Value, newAlias.Value, profile.Id);
return Result<ProfileDto>.Success(ProfileDto.FromProfile(profile));
}
catch (DomainException ex)
{
logger.LogWarning("Domain error updating profile alias: {Error}", ex.Message);
return Result<ProfileDto>.Failure(ex.Message);
}
catch (Exception ex)
{
logger.LogError(ex, "Unexpected error updating alias for profile {ProfileId}", request.ProfileId);
return Result<ProfileDto>.Failure($"An unexpected error occurred: {ex.Message}");
}
}
}
Loading
Loading