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

namespace Orbit.Api.Controllers;

[Authorize]
[ApiController]
[Route("api/accountability")]
public partial class AccountabilityController(IMediator mediator, ILogger<AccountabilityController> logger) : ControllerBase
{
public record InviteAccountabilityBuddyBody(Guid BuddyUserId, AccountabilityCadence Cadence, IReadOnlyList<Guid> HabitIds);

Check warning on line 17 in src/Orbit.Api/Controllers/AccountabilityController.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=AZ8azCxufUNf2JucbOVF&open=AZ8azCxufUNf2JucbOVF&pullRequest=271

Check warning on line 17 in src/Orbit.Api/Controllers/AccountabilityController.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=AZ8azCxufUNf2JucbOVE&open=AZ8azCxufUNf2JucbOVE&pullRequest=271
public record AcceptAccountabilityPairBody(IReadOnlyList<Guid> HabitIds);
public record SetAccountabilityHabitsBody(IReadOnlyList<Guid> HabitIds);
public record CheckInAccountabilityBody(string? Note);

[HttpGet("pairs")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> GetPairs(CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetAccountabilityPairsQuery(HttpContext.GetUserId()), cancellationToken);
return result.ToPayGateAwareResult(value => Ok(value));
}

[HttpPost("pairs")]
[DistributedRateLimit("accountability-invites")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Invite(
[FromBody] InviteAccountabilityBuddyBody body,
CancellationToken cancellationToken)
{
var userId = HttpContext.GetUserId();
var command = new InviteAccountabilityBuddyCommand(userId, body.BuddyUserId, body.Cadence, body.HabitIds);
var result = await mediator.Send(command, cancellationToken);

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

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

[HttpPost("pairs/{pairId:guid}/accept")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Accept(
Guid pairId,
[FromBody] AcceptAccountabilityPairBody body,
CancellationToken cancellationToken)
{
var userId = HttpContext.GetUserId();
var command = new AcceptAccountabilityPairCommand(userId, pairId, body.HabitIds);
var result = await mediator.Send(command, cancellationToken);

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

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

[HttpDelete("pairs/{pairId:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> End(Guid pairId, CancellationToken cancellationToken)
{
var userId = HttpContext.GetUserId();
var result = await mediator.Send(new EndAccountabilityPairCommand(userId, pairId), cancellationToken);

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

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

[HttpPut("pairs/{pairId:guid}/habits")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetHabits(
Guid pairId,
[FromBody] SetAccountabilityHabitsBody body,
CancellationToken cancellationToken)
{
var command = new SetAccountabilityHabitsCommand(HttpContext.GetUserId(), pairId, body.HabitIds);
var result = await mediator.Send(command, cancellationToken);
return result.ToPayGateAwareResult(() => NoContent());
}

[HttpGet("pairs/{pairId:guid}/check-ins")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetCheckIns(Guid pairId, CancellationToken cancellationToken)
{
var query = new GetAccountabilityCheckInsQuery(HttpContext.GetUserId(), pairId);
var result = await mediator.Send(query, cancellationToken);
return result.ToPayGateAwareResult(value => Ok(value));
}

[HttpPost("pairs/{pairId:guid}/check-ins")]
[DistributedRateLimit("accountability-checkins")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> CheckIn(
Guid pairId,
[FromBody] CheckInAccountabilityBody body,
CancellationToken cancellationToken)
{
var userId = HttpContext.GetUserId();
var command = new CheckInAccountabilityCommand(userId, pairId, body.Note);
var result = await mediator.Send(command, cancellationToken);

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

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

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

[LoggerMessage(EventId = 2, Level = LogLevel.Information, Message = "Accountability invite accepted by user {UserId}")]
private static partial void LogInviteAccepted(ILogger logger, Guid userId);

[LoggerMessage(EventId = 3, Level = LogLevel.Information, Message = "Accountability pair ended by user {UserId}")]
private static partial void LogPairEnded(ILogger logger, Guid userId);

[LoggerMessage(EventId = 4, Level = LogLevel.Information, Message = "Accountability check-in by user {UserId}")]
private static partial void LogCheckedIn(ILogger logger, Guid userId);
}
4 changes: 4 additions & 0 deletions src/Orbit.Api/Extensions/ResultActionResultExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,15 @@ public static class ResultActionResultExtensions
[ErrorCodes.HandleTaken] = StatusCodes.Status409Conflict,
[ErrorCodes.AlreadyFriends] = StatusCodes.Status409Conflict,
[ErrorCodes.FriendLimitReached] = StatusCodes.Status409Conflict,
[ErrorCodes.AlreadyPaired] = StatusCodes.Status409Conflict,
[ErrorCodes.PairLimitReached] = StatusCodes.Status409Conflict,
[ErrorCodes.AlreadyCheckedIn] = StatusCodes.Status409Conflict,

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

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

[ErrorCodes.InternalServerError] = StatusCodes.Status500InternalServerError,
};
Expand Down
7 changes: 7 additions & 0 deletions src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@ public static WebApplicationBuilder AddOrbitDatabase(this WebApplicationBuilder
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.Habit>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.Cheer>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.UserAchievement>>()));
builder.Services.AddScoped<Orbit.Application.Accountability.Services.AccountabilityPairService>();
builder.Services.AddScoped<Orbit.Application.Accountability.Commands.AccountabilityRepositories>(sp =>
new Orbit.Application.Accountability.Commands.AccountabilityRepositories(
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.User>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.AccountabilityPair>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.AccountabilityCheckIn>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.UserAchievement>>()));
builder.Services.AddScoped<IGoogleTokenService, GoogleTokenService>();
builder.Services.AddGoogleCalendarServices();
builder.Services.AddSingleton(TimeProvider.System);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
using MediatR;
using Microsoft.Extensions.Logging;
using Orbit.Application.Accountability.Services;
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.Accountability.Commands;

public record AcceptAccountabilityPairCommand(
Guid UserId,
Guid PairId,
IReadOnlyList<Guid> HabitIds) : IRequest<Result>;

public partial class AcceptAccountabilityPairCommandHandler(
SocialAccessGuard socialAccessGuard,
AccountabilityPairService accountabilityPairService,
AccountabilityRepositories repositories,
IXpAwarder xpAwarder,
IPushNotificationService pushNotificationService,
IUnitOfWork unitOfWork,
ILogger<AcceptAccountabilityPairCommandHandler> logger) : IRequestHandler<AcceptAccountabilityPairCommand, Result>
{
private const string BattleBuddyAchievementId = "battle_buddy";

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

var pair = await repositories.Pairs.FindOneTrackedAsync(
p => p.Id == request.PairId && p.AddresseeId == request.UserId,
cancellationToken: cancellationToken);
if (pair is null)
return Result.Failure(ErrorMessages.PairNotFound);

var acceptResult = pair.Accept();
if (acceptResult.IsFailure)
return acceptResult;

var linkResult = await accountabilityPairService.ReplaceLinkedHabitsAsync(
pair, request.UserId, request.HabitIds, cancellationToken);
if (linkResult.IsFailure)
return linkResult;

var requester = await repositories.Users.FindOneTrackedAsync(
u => u.Id == pair.RequesterId,
cancellationToken: cancellationToken);

await AwardBattleBuddyAsync(accepter, cancellationToken);
if (requester is not null)
await AwardBattleBuddyAsync(requester, cancellationToken);

await unitOfWork.SaveChangesAsync(cancellationToken);

await NotifyRequesterAsync(requester, accepter, cancellationToken);

return Result.Success();
}

/// <summary>
/// Idempotently grants the Battle Buddy achievement (and its XP reward) to a pair participant on accept.
/// </summary>
private async Task AwardBattleBuddyAsync(User user, CancellationToken cancellationToken)
{
var alreadyEarned = await repositories.Achievements.AnyAsync(
a => a.UserId == user.Id && a.AchievementId == BattleBuddyAchievementId,
cancellationToken);
if (alreadyEarned)
return;

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

if (newAchievements.Count == 0)
return;

await repositories.Achievements.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);
}

private async Task NotifyRequesterAsync(User? requester, User accepter, CancellationToken cancellationToken)
{
if (requester is null || !requester.SocialOptIn)
return;

var isPortuguese = LocaleHelper.IsPortuguese(requester.Language);
var title = isPortuguese ? "Parceria aceita" : "Accountability invite accepted";
var body = isPortuguese
? $"{accepter.Name} aceitou seu convite de parceria."
: $"{accepter.Name} accepted your accountability invite.";

try
{
await pushNotificationService.SendToUserAsync(requester.Id, title, body, cancellationToken: cancellationToken);
}
catch (Exception ex)
{
LogPushNotificationFailed(logger, ex, requester.Id);
}
}

[LoggerMessage(EventId = 1, Level = LogLevel.Warning, Message = "Accountability accept push failed for user {UserId}")]
private static partial void LogPushNotificationFailed(ILogger logger, Exception ex, Guid userId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using Orbit.Domain.Entities;
using Orbit.Domain.Interfaces;

namespace Orbit.Application.Accountability.Commands;

/// <summary>Groups the repositories the accountability handlers touch to keep their constructors small.</summary>
public record AccountabilityRepositories(
IGenericRepository<User> Users,
IGenericRepository<AccountabilityPair> Pairs,
IGenericRepository<AccountabilityCheckIn> CheckIns,
IGenericRepository<UserAchievement> Achievements);
Loading
Loading