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
106 changes: 106 additions & 0 deletions src/Orbit.Api/Controllers/ChallengesController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Orbit.Api.Extensions;
using Orbit.Api.RateLimiting;
using Orbit.Application.Challenges.Commands;
using Orbit.Application.Challenges.Queries;
using Orbit.Domain.Enums;

namespace Orbit.Api.Controllers;

[Authorize]
[ApiController]
[Route("api/challenges")]
public partial class ChallengesController(IMediator mediator, ILogger<ChallengesController> logger) : ControllerBase
{
public record CreateChallengeBody(
ChallengeType Type,

Check warning on line 18 in src/Orbit.Api/Controllers/ChallengesController.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Value type property used as input in a controller action should be nullable, required or annotated with the JsonRequiredAttribute to avoid under-posting.

See more on https://sonarcloud.io/project/issues?id=thomasluizon_orbit-api&issues=AZ8a1EsVvCmbNqWBCYBy&open=AZ8a1EsVvCmbNqWBCYBy&pullRequest=272
string Title,
string? Description,
int? TargetCount,
DateOnly PeriodStartUtc,

Check warning on line 22 in src/Orbit.Api/Controllers/ChallengesController.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Value type property used as input in a controller action should be nullable, required or annotated with the JsonRequiredAttribute to avoid under-posting.

See more on https://sonarcloud.io/project/issues?id=thomasluizon_orbit-api&issues=AZ8a1EsVvCmbNqWBCYBz&open=AZ8a1EsVvCmbNqWBCYBz&pullRequest=272
DateOnly? PeriodEndUtc,
IReadOnlyList<Guid>? LinkedHabitIds,
IReadOnlyList<Guid>? InvitedFriendUserIds);

public record JoinChallengeBody(string Code, IReadOnlyList<Guid>? LinkedHabitIds);

[HttpPost]
[DistributedRateLimit("challenges")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Create(
[FromBody] CreateChallengeBody body,
CancellationToken cancellationToken)
{
var userId = HttpContext.GetUserId();
var command = new CreateChallengeCommand(
userId,
body.Type,
body.Title,
body.Description,
body.TargetCount,
body.PeriodStartUtc,
body.PeriodEndUtc,
body.LinkedHabitIds ?? [],
body.InvitedFriendUserIds ?? []);
var result = await mediator.Send(command, cancellationToken);

if (result.IsSuccess)
LogChallengeCreated(logger, userId);

return result.ToPayGateAwareResult(id => Ok(new { id }));
}

[HttpPost("join")]
[DistributedRateLimit("challenges")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Join(
[FromBody] JoinChallengeBody body,
CancellationToken cancellationToken)
{
var userId = HttpContext.GetUserId();
var command = new JoinChallengeCommand(userId, body.Code, body.LinkedHabitIds ?? []);
var result = await mediator.Send(command, cancellationToken);

if (result.IsSuccess)
LogChallengeJoined(logger, userId);

return result.ToPayGateAwareResult(() => NoContent());
}

[HttpDelete("{challengeId:guid}/leave")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Leave(Guid challengeId, CancellationToken cancellationToken)
{
var command = new LeaveChallengeCommand(HttpContext.GetUserId(), challengeId);
var result = await mediator.Send(command, cancellationToken);
return result.ToPayGateAwareResult(() => NoContent());
}

[HttpGet("{challengeId:guid}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetDetail(Guid challengeId, CancellationToken cancellationToken)
{
var query = new GetChallengeDetailQuery(HttpContext.GetUserId(), challengeId);
var result = await mediator.Send(query, cancellationToken);
return result.ToPayGateAwareResult(value => Ok(value));
}

[LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "Challenge created by user {UserId}")]
private static partial void LogChallengeCreated(ILogger logger, Guid userId);

[LoggerMessage(EventId = 2, Level = LogLevel.Information, Message = "Challenge joined by user {UserId}")]
private static partial void LogChallengeJoined(ILogger logger, Guid userId);
}
7 changes: 7 additions & 0 deletions src/Orbit.Api/Extensions/ResultActionResultExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,17 @@ public static class ResultActionResultExtensions

[ErrorCodes.SocialDisabled] = StatusCodes.Status403Forbidden,
[ErrorCodes.Blocked] = StatusCodes.Status403Forbidden,
[ErrorCodes.NotChallengeParticipant] = StatusCodes.Status403Forbidden,

[ErrorCodes.FriendRequestNotFound] = StatusCodes.Status404NotFound,
[ErrorCodes.ChallengeNotFound] = StatusCodes.Status404NotFound,
[ErrorCodes.InvalidJoinCode] = StatusCodes.Status404NotFound,
[ErrorCodes.PairNotFound] = StatusCodes.Status404NotFound,

[ErrorCodes.ChallengeFull] = StatusCodes.Status409Conflict,
[ErrorCodes.AlreadyJoinedChallenge] = StatusCodes.Status409Conflict,
[ErrorCodes.ChallengeClosed] = StatusCodes.Status409Conflict,

[ErrorCodes.InternalServerError] = StatusCodes.Status500InternalServerError,
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ private static void AddHabitCommandDependencies(WebApplicationBuilder builder)
sp.GetRequiredService<IUserDateService>(),
sp.GetRequiredService<IUserStreakService>(),
sp.GetRequiredService<IGamificationService>(),
sp.GetRequiredService<Orbit.Application.Challenges.Services.IChallengeProgressService>(),
sp.GetRequiredService<MediatR.IMediator>()));
builder.Services.AddScoped<Orbit.Application.Habits.Commands.BulkLogServices>(sp =>
new Orbit.Application.Habits.Commands.BulkLogServices(
Expand Down
9 changes: 9 additions & 0 deletions src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,15 @@ public static WebApplicationBuilder AddOrbitDatabase(this WebApplicationBuilder
builder.Services.AddScoped<Orbit.Application.Social.Services.FriendGraphService>();
builder.Services.AddScoped<Orbit.Application.Social.Services.IFriendFeedEventEmitter, Orbit.Application.Social.Services.FriendFeedEmitter>();
builder.Services.AddScoped<IFriendFeedReader, FriendFeedReader>();
builder.Services.AddScoped<Orbit.Application.Challenges.Services.IChallengeProgressService, Orbit.Application.Challenges.Services.ChallengeProgressService>();
builder.Services.AddScoped<Orbit.Application.Challenges.Services.ChallengeProgressRepositories>(sp =>
new Orbit.Application.Challenges.Services.ChallengeProgressRepositories(
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.Challenge>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.ChallengeParticipant>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.ChallengeParticipantHabit>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.HabitLog>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.User>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.UserAchievement>>()));
builder.Services.AddScoped<Orbit.Application.Social.Commands.SendCheerRepositories>(sp =>
new Orbit.Application.Social.Commands.SendCheerRepositories(
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.User>>(),
Expand Down
127 changes: 127 additions & 0 deletions src/Orbit.Application/Challenges/Commands/CreateChallengeCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
using System.Security.Cryptography;
using MediatR;
using Orbit.Application.Common;
using Orbit.Application.Social.Services;
using Orbit.Domain.Common;
using Orbit.Domain.Entities;
using Orbit.Domain.Enums;
using Orbit.Domain.Interfaces;

namespace Orbit.Application.Challenges.Commands;

public record CreateChallengeCommand(
Guid UserId,
ChallengeType Type,
string Title,
string? Description,
int? TargetCount,
DateOnly PeriodStartUtc,
DateOnly? PeriodEndUtc,
IReadOnlyList<Guid> LinkedHabitIds,
IReadOnlyList<Guid> InvitedFriendUserIds) : IRequest<Result<Guid>>;

public class CreateChallengeCommandHandler(
SocialAccessGuard socialAccessGuard,
FriendGraphService friendGraphService,
IGenericRepository<Challenge> challengeRepository,
IGenericRepository<Habit> habitRepository,
IUnitOfWork unitOfWork) : IRequestHandler<CreateChallengeCommand, Result<Guid>>
{
private const string JoinCodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
private const int JoinCodeLength = 8;

public async Task<Result<Guid>> Handle(CreateChallengeCommand request, CancellationToken cancellationToken)
{
var access = await socialAccessGuard.EnsureEnabledAsync(request.UserId, cancellationToken);
if (access.IsFailure)
return access.PropagateError<Guid>();

var ownedHabits = await VerifyOwnedHabitsAsync(request.UserId, request.LinkedHabitIds, cancellationToken);
if (ownedHabits.IsFailure)
return ownedHabits.PropagateError<Guid>();

var invitedFriendIds = request.InvitedFriendUserIds
.Where(id => id != request.UserId)
.Distinct()
.ToList();

if (1 + invitedFriendIds.Count > AppConstants.MaxChallengeParticipants)
return Result.Failure<Guid>(ErrorMessages.ChallengeFull.Format(AppConstants.MaxChallengeParticipants));

var friendCheck = await VerifyInvitedFriendsAsync(request.UserId, invitedFriendIds, cancellationToken);
if (friendCheck.IsFailure)
return friendCheck.PropagateError<Guid>();

var joinCode = await GenerateUniqueJoinCodeAsync(cancellationToken);

var createResult = Challenge.Create(new CreateChallengeParams(
request.UserId,
request.Type,
request.Title,
request.Description,
request.TargetCount,
request.PeriodStartUtc,
request.PeriodEndUtc,
joinCode));
if (createResult.IsFailure)
return createResult.PropagateError<Guid>();

var challenge = createResult.Value;
challenge.AddParticipant(request.UserId, request.LinkedHabitIds);
foreach (var friendId in invitedFriendIds)
challenge.AddParticipant(friendId, []);

await challengeRepository.AddAsync(challenge, cancellationToken);
await unitOfWork.SaveChangesAsync(cancellationToken);

return Result.Success(challenge.Id);
}

private async Task<Result> VerifyOwnedHabitsAsync(Guid userId, IReadOnlyList<Guid> habitIds, CancellationToken cancellationToken)
{
var distinctIds = habitIds.Distinct().ToList();
var owned = await habitRepository.CountAsync(
h => h.UserId == userId && distinctIds.Contains(h.Id),
cancellationToken);

return owned == distinctIds.Count
? Result.Success()
: Result.Failure(ErrorMessages.HabitNotFound);
}

private async Task<Result> VerifyInvitedFriendsAsync(Guid userId, IReadOnlyList<Guid> invitedFriendIds, CancellationToken cancellationToken)
{
foreach (var friendId in invitedFriendIds)
{
if (!await friendGraphService.AreAcceptedFriendsAsync(userId, friendId, cancellationToken))
return Result.Failure(ErrorMessages.NotFriends);

if (await friendGraphService.IsBlockedBetweenAsync(userId, friendId, cancellationToken))
return Result.Failure(ErrorMessages.NotFriends);
}

return Result.Success();
}

private async Task<string> GenerateUniqueJoinCodeAsync(CancellationToken cancellationToken)
{
while (true)
{
var code = GenerateJoinCode();
var exists = await challengeRepository.AnyAsync(c => c.JoinCode == code, cancellationToken);
if (!exists)
return code;
}
}

private static string GenerateJoinCode()
{
return string.Create(JoinCodeLength, JoinCodeAlphabet, static (span, alphabet) =>
{
Span<byte> bytes = stackalloc byte[span.Length];
RandomNumberGenerator.Fill(bytes);
for (var i = 0; i < span.Length; i++)
span[i] = alphabet[bytes[i] % alphabet.Length];
});
}
}
106 changes: 106 additions & 0 deletions src/Orbit.Application/Challenges/Commands/JoinChallengeCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using Orbit.Application.Common;
using Orbit.Application.Gamification;
using Orbit.Application.Gamification.Models;
using Orbit.Application.Social.Services;
using Orbit.Domain.Common;
using Orbit.Domain.Entities;
using Orbit.Domain.Enums;
using Orbit.Domain.Interfaces;

namespace Orbit.Application.Challenges.Commands;

public record JoinChallengeCommand(
Guid UserId,
string Code,
IReadOnlyList<Guid> LinkedHabitIds) : IRequest<Result>;

public class JoinChallengeCommandHandler(
SocialAccessGuard socialAccessGuard,
FriendGraphService friendGraphService,
IGenericRepository<Challenge> challengeRepository,
IGenericRepository<Habit> habitRepository,
IGenericRepository<UserAchievement> achievementRepository,
IXpAwarder xpAwarder,
IUnitOfWork unitOfWork) : IRequestHandler<JoinChallengeCommand, Result>
{
private const string TeamPlayerAchievementId = "team_player";

public async Task<Result> Handle(JoinChallengeCommand request, CancellationToken cancellationToken)
{
var access = await socialAccessGuard.EnsureEnabledAsync(request.UserId, cancellationToken);
if (access.IsFailure)
return access.PropagateError();
var user = access.Value;

var ownedHabits = await VerifyOwnedHabitsAsync(request.UserId, request.LinkedHabitIds, cancellationToken);
if (ownedHabits.IsFailure)
return ownedHabits;

var normalizedCode = request.Code.Trim().ToUpperInvariant();
var challenge = await challengeRepository.FindOneTrackedAsync(
c => c.JoinCode == normalizedCode,
q => q.Include(c => c.Participants).ThenInclude(p => p.LinkedHabits),
cancellationToken);

if (challenge is null)
return Result.Failure(ErrorMessages.InvalidJoinCode);

if (challenge.Status != ChallengeStatus.Active)
return Result.Failure(ErrorMessages.ChallengeClosed);

if (challenge.Participants.Any(p => p.UserId == request.UserId && p.IsActive))
return Result.Failure(ErrorMessages.AlreadyJoinedChallenge);

if (await friendGraphService.IsBlockedBetweenAsync(request.UserId, challenge.CreatorId, cancellationToken))
return Result.Failure(ErrorMessages.InvalidJoinCode);

if (challenge.GetActiveParticipants().Count >= AppConstants.MaxChallengeParticipants)
return Result.Failure(ErrorMessages.ChallengeFull.Format(AppConstants.MaxChallengeParticipants));

challenge.AddParticipant(request.UserId, request.LinkedHabitIds);

await AwardTeamPlayerAsync(user, cancellationToken);
await unitOfWork.SaveChangesAsync(cancellationToken);

return Result.Success();
}

private async Task<Result> VerifyOwnedHabitsAsync(Guid userId, IReadOnlyList<Guid> habitIds, CancellationToken cancellationToken)
{
var distinctIds = habitIds.Distinct().ToList();
var owned = await habitRepository.CountAsync(
h => h.UserId == userId && distinctIds.Contains(h.Id),
cancellationToken);

return owned == distinctIds.Count
? Result.Success()
: Result.Failure(ErrorMessages.HabitNotFound);
}

private async Task AwardTeamPlayerAsync(User user, CancellationToken cancellationToken)
{
var alreadyEarned = await achievementRepository.AnyAsync(
a => a.UserId == user.Id && a.AchievementId == TeamPlayerAchievementId,
cancellationToken);
if (alreadyEarned)
return;

var earned = new HashSet<string>();
var newAchievements = new List<(UserAchievement Entity, AchievementDefinition Definition)>();
AchievementChecks.TryGrant(TeamPlayerAchievementId, user, earned, newAchievements);

if (newAchievements.Count == 0)
return;

await achievementRepository.AddAsync(newAchievements[0].Entity, cancellationToken);
await xpAwarder.AwardAsync(
user, newAchievements[0].Definition.XpReward, XpAwardSource.Achievement,
newAchievements[0].Entity.Id, awardedAtUtc: DateTime.UtcNow, cancellationToken);

var newLevel = LevelDefinitions.GetLevelForXp(user.TotalXp);
if (newLevel.Level != user.Level)
user.SetLevel(newLevel.Level);
}
}
Loading
Loading