Skip to content
Merged
181 changes: 181 additions & 0 deletions src/Orbit.Api/Controllers/FriendsController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Orbit.Api.Extensions;
using Orbit.Api.RateLimiting;
using Orbit.Application.Social.Commands;
using Orbit.Application.Social.Queries;
using Orbit.Domain.Enums;

namespace Orbit.Api.Controllers;

[Authorize]
[ApiController]
[Route("api/friends")]
public partial class FriendsController(IMediator mediator, ILogger<FriendsController> logger) : ControllerBase
{
public record SendFriendRequestBody(string? Handle, string? ReferralCode);
public record SendCheerBody(Guid RecipientId, Guid HabitId, string? Note);

Check warning on line 18 in src/Orbit.Api/Controllers/FriendsController.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=AZ8H7n5zUniKS_RlfYxg&open=AZ8H7n5zUniKS_RlfYxg&pullRequest=261

Check warning on line 18 in src/Orbit.Api/Controllers/FriendsController.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=AZ8H7n5zUniKS_RlfYxh&open=AZ8H7n5zUniKS_RlfYxh&pullRequest=261
public record BlockUserBody(Guid BlockedUserId);

Check warning on line 19 in src/Orbit.Api/Controllers/FriendsController.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=AZ8H7n5zUniKS_RlfYxi&open=AZ8H7n5zUniKS_RlfYxi&pullRequest=261
public record ReportUserBody(Guid ReportedUserId, ReportReason Reason, string? Details, Guid? CheerId);

Check warning on line 20 in src/Orbit.Api/Controllers/FriendsController.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=AZ8H7n5zUniKS_RlfYxk&open=AZ8H7n5zUniKS_RlfYxk&pullRequest=261

Check warning on line 20 in src/Orbit.Api/Controllers/FriendsController.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=AZ8H7n5zUniKS_RlfYxj&open=AZ8H7n5zUniKS_RlfYxj&pullRequest=261

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

[HttpGet("feed")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> GetFeed(
[FromQuery] string? cursor,
[FromQuery] int? pageSize,
CancellationToken cancellationToken)
{
var query = new GetFriendFeedQuery(HttpContext.GetUserId(), cursor, pageSize);
var result = await mediator.Send(query, cancellationToken);
return result.ToPayGateAwareResult(value => Ok(value));
}

[HttpGet("cheers")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> GetCheers(
[FromQuery] string direction = GetCheersQueryHandler.ReceivedDirection,
CancellationToken cancellationToken = default)
{
var query = new GetCheersQuery(HttpContext.GetUserId(), direction);
var result = await mediator.Send(query, cancellationToken);
return result.ToPayGateAwareResult(value => Ok(value));
}

[HttpPost("requests")]
[DistributedRateLimit("friend-requests")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SendRequest(
[FromBody] SendFriendRequestBody body,
CancellationToken cancellationToken)
{
var userId = HttpContext.GetUserId();
var command = new SendFriendRequestCommand(userId, body.Handle, body.ReferralCode);
var result = await mediator.Send(command, cancellationToken);

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

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

[HttpPost("requests/{friendshipId:guid}/accept")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> AcceptRequest(Guid friendshipId, CancellationToken cancellationToken)
{
var userId = HttpContext.GetUserId();
var result = await mediator.Send(new AcceptFriendRequestCommand(userId, friendshipId), cancellationToken);

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

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

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

[HttpPost("cheers")]
[DistributedRateLimit("cheers")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> SendCheer(
[FromBody] SendCheerBody body,
CancellationToken cancellationToken)
{
var userId = HttpContext.GetUserId();
var command = new SendCheerCommand(userId, body.RecipientId, body.HabitId, body.Note);
var result = await mediator.Send(command, cancellationToken);

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

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

[HttpPost("block")]
[DistributedRateLimit("block")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> Block(
[FromBody] BlockUserBody body,
CancellationToken cancellationToken)
{
var userId = HttpContext.GetUserId();
var result = await mediator.Send(new BlockUserCommand(userId, body.BlockedUserId), cancellationToken);

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

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

[HttpDelete("block/{blockedUserId:guid}")]
[DistributedRateLimit("unblock")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> Unblock(Guid blockedUserId, CancellationToken cancellationToken)
{
var result = await mediator.Send(new UnblockUserCommand(HttpContext.GetUserId(), blockedUserId), cancellationToken);
return result.ToPayGateAwareResult(() => NoContent());
}

[HttpPost("report")]
[DistributedRateLimit("reports")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> Report(
[FromBody] ReportUserBody body,
CancellationToken cancellationToken)
{
var userId = HttpContext.GetUserId();
var command = new ReportUserCommand(userId, body.ReportedUserId, body.Reason, body.Details, body.CheerId);
var result = await mediator.Send(command, cancellationToken);

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

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

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

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

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

[LoggerMessage(EventId = 4, Level = LogLevel.Information, Message = "User blocked by user {UserId}")]
private static partial void LogUserBlocked(ILogger logger, Guid userId);

[LoggerMessage(EventId = 5, Level = LogLevel.Information, Message = "User reported by user {UserId}")]
private static partial void LogUserReported(ILogger logger, Guid userId);
}
27 changes: 26 additions & 1 deletion src/Orbit.Api/Controllers/GamificationController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Orbit.Api.Extensions;
using Orbit.Application.Common;
using Orbit.Application.Gamification.Queries;
using Orbit.Application.Habits.Queries;
using Orbit.Domain.Interfaces;

namespace Orbit.Api.Controllers;

[Authorize]
[ApiController]
[Route("api/[controller]")]
public class GamificationController(IMediator mediator) : ControllerBase
public class GamificationController(IMediator mediator, IUserDateService userDateService) : ControllerBase
{
[HttpGet("profile")]
[ProducesResponseType(StatusCodes.Status200OK)]
Expand Down Expand Up @@ -48,4 +51,26 @@ public async Task<IActionResult> GetStreakInfo(CancellationToken cancellationTok

return result.ToPayGateAwareResult(v => Ok(v));
}

[HttpGet("recap")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> GetRecap(
[FromQuery] string period,
CancellationToken cancellationToken)
{
if (!RetrospectivePeriodRange.IsKnownPeriod(period))
return BadRequest(ErrorMessages.InvalidPeriod.ToErrorBody());

var userId = HttpContext.GetUserId();
var today = await userDateService.GetUserTodayAsync(userId, cancellationToken);
var weekStartDay = await userDateService.GetUserWeekStartDayAsync(userId, cancellationToken);
var (dateFrom, dateTo) = RetrospectivePeriodRange.Resolve(period, today, weekStartDay);

var query = new GetRecapQuery(userId, dateFrom, dateTo, period);
var result = await mediator.Send(query, cancellationToken);

return result.ToPayGateAwareResult(v => Ok(v));
}
}
7 changes: 6 additions & 1 deletion src/Orbit.Api/Controllers/HabitsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Microsoft.AspNetCore.Mvc;
using Orbit.Api.Extensions;
using Orbit.Api.RateLimiting;
using Orbit.Application.Common;
using Orbit.Application.Habits.Commands;
using Orbit.Application.Habits.Queries;
using Orbit.Domain.Interfaces;
Expand Down Expand Up @@ -107,6 +108,9 @@ public async Task<IActionResult> GetRetrospective(
[FromQuery] string language = "en",
CancellationToken cancellationToken = default)
{
if (!RetrospectivePeriodRange.IsKnownPeriod(period))
return BadRequest(ErrorMessages.InvalidPeriod.ToErrorBody());

var userId = HttpContext.GetUserId();
var today = await userDateService.GetUserTodayAsync(userId, cancellationToken);
var weekStartDay = await userDateService.GetUserWeekStartDayAsync(userId, cancellationToken);
Expand Down Expand Up @@ -566,7 +570,8 @@ private static BulkHabitItem MapToBulkHabitItem(BulkHabitItemRequest request)
IsFlexible: request.IsFlexible,
ScheduledReminders: request.ScheduledReminders,
ChecklistItems: request.ChecklistItems,
GoogleEventId: request.GoogleEventId);
GoogleEventId: request.GoogleEventId,
Tags: request.Tags);
}

[LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "Habit created {HabitId} by user {UserId}")]
Expand Down
3 changes: 2 additions & 1 deletion src/Orbit.Api/Controllers/HabitsControllerRequests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ public record BulkHabitItemRequest(
bool IsFlexible = false,
IReadOnlyList<ChecklistItem>? ChecklistItems = null,
string? GoogleEventId = null,
string? Emoji = null);
string? Emoji = null,
IReadOnlyList<string>? Tags = null);

public record BulkDeleteHabitsRequest(IReadOnlyList<Guid> HabitIds);

Expand Down
45 changes: 45 additions & 0 deletions src/Orbit.Api/Controllers/ProfileController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Orbit.Api.Extensions;
using Orbit.Api.RateLimiting;
using Orbit.Application.Profile.Commands;
using Orbit.Application.Profile.Queries;

Expand All @@ -24,6 +25,8 @@ public record SetLanguageRequest(string Language);
public record SetWeekStartDayRequest([property: JsonRequired] int WeekStartDay);
public record SetThemePreferenceRequest(string? ThemePreference);
public record SetColorSchemeRequest(string? ColorScheme);
public record SetHandleRequest(string Handle);
public record SetSocialOptInRequest([property: JsonRequired] bool Enabled);

private static readonly JsonSerializerOptions ExportJsonOptions = new(JsonSerializerDefaults.Web)
{
Expand Down Expand Up @@ -210,6 +213,42 @@ public async Task<IActionResult> ResetTour(CancellationToken cancellationToken)
return result.ToPayGateAwareResult(() => NoContent());
}

[HttpPut("handle")]
[DistributedRateLimit("set-handle")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> SetHandle(
[FromBody] SetHandleRequest request,
CancellationToken cancellationToken)
{
var command = new SetHandleCommand(HttpContext.GetUserId(), request.Handle);
var result = await mediator.Send(command, cancellationToken);

if (result.IsSuccess)
LogHandleChanged(logger, HttpContext.GetUserId());

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

[HttpPut("social-opt-in")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> SetSocialOptIn(
[FromBody] SetSocialOptInRequest request,
CancellationToken cancellationToken)
{
var command = new SetSocialOptInCommand(HttpContext.GetUserId(), request.Enabled);
var result = await mediator.Send(command, cancellationToken);

if (result.IsSuccess)
LogSocialOptInChanged(logger, request.Enabled ? "enabled" : "disabled", HttpContext.GetUserId());

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

[HttpPost("reset")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
Expand Down Expand Up @@ -269,4 +308,10 @@ public async Task<IActionResult> ExportUserData(CancellationToken cancellationTo
[LoggerMessage(EventId = 9, Level = LogLevel.Information, Message = "Display name changed for user {UserId}")]
private static partial void LogNameChanged(ILogger logger, Guid userId);

[LoggerMessage(EventId = 10, Level = LogLevel.Information, Message = "Handle changed for user {UserId}")]
private static partial void LogHandleChanged(ILogger logger, Guid userId);

[LoggerMessage(EventId = 11, Level = LogLevel.Information, Message = "Social opt-in {State} for user {UserId}")]
private static partial void LogSocialOptInChanged(ILogger logger, string state, Guid userId);

}
8 changes: 8 additions & 0 deletions src/Orbit.Api/Extensions/ResultActionResultExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ public static class ResultActionResultExtensions
[ErrorCodes.DuplicateFact] = StatusCodes.Status409Conflict,
[ErrorCodes.AlreadyReferred] = StatusCodes.Status409Conflict,
[ErrorCodes.ConcurrentUpdateConflict] = StatusCodes.Status409Conflict,
[ErrorCodes.HandleTaken] = StatusCodes.Status409Conflict,
[ErrorCodes.AlreadyFriends] = StatusCodes.Status409Conflict,
[ErrorCodes.FriendLimitReached] = StatusCodes.Status409Conflict,

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

[ErrorCodes.FriendRequestNotFound] = StatusCodes.Status404NotFound,

[ErrorCodes.InternalServerError] = StatusCodes.Status500InternalServerError,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ private static void AddAiPlatformServices(WebApplicationBuilder builder)
builder.Services.AddScoped<IRetrospectiveService, AiRetrospectiveService>();
builder.Services.AddScoped<IGoalReviewService, AiGoalReviewService>();
builder.Services.AddScoped<ITagSuggestionService, AiTagSuggestionService>();
builder.Services.AddHttpClient<IContentModerationService, ContentModerationService>()
.ConfigureHttpClient(client => client.Timeout = TimeSpan.FromSeconds(5));
builder.Services.AddScoped<IAgentCatalogService, AgentCatalogService>();
builder.Services.AddScoped<IPendingAgentOperationStore, PendingAgentOperationStore>();
builder.Services.AddScoped<IPendingClarificationStore, PendingClarificationStore>();
Expand Down Expand Up @@ -173,6 +175,7 @@ private static void AddChatCommandDependencies(WebApplicationBuilder builder)
sp.GetRequiredService<IServiceScopeFactory>(),
sp.GetRequiredService<IAgentOperationExecutor>(),
sp.GetRequiredService<IPendingClarificationStore>(),
sp.GetRequiredService<IStreakGoalReadSyncer>()));
sp.GetRequiredService<IStreakGoalReadSyncer>(),
sp.GetRequiredService<IGamificationService>()));
}
}
10 changes: 10 additions & 0 deletions src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,16 @@ public static WebApplicationBuilder AddOrbitDatabase(this WebApplicationBuilder
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.UserAchievement>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.Notification>>()));
builder.Services.AddScoped<IGamificationService, GamificationService>();
builder.Services.AddScoped<Orbit.Application.Social.Services.SocialAccessGuard>();
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.Social.Commands.SendCheerRepositories>(sp =>
new Orbit.Application.Social.Commands.SendCheerRepositories(
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.User>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.Habit>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.Cheer>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.UserAchievement>>()));
builder.Services.AddScoped<IGoogleTokenService, GoogleTokenService>();
builder.Services.AddGoogleCalendarServices();
builder.Services.AddSingleton(TimeProvider.System);
Expand Down
1 change: 1 addition & 0 deletions src/Orbit.Application/Auth/Commands/GoogleAuthCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@

user = createResult.Value;
user.SetLanguage(language);
user.SeedDefaultHandle();
await userRepository.AddAsync(user, cancellationToken);

try
Expand Down Expand Up @@ -151,7 +152,7 @@
}

private bool HandlePostLogin(
User user, GoogleAuthCommand request, bool isNewUser, CancellationToken cancellationToken)

Check warning on line 155 in src/Orbit.Application/Auth/Commands/GoogleAuthCommand.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

Remove this unused method parameter 'cancellationToken'.
{
if (isNewUser && !string.IsNullOrWhiteSpace(request.ReferralCode))
ProcessReferralInBackground(user.Id, request.ReferralCode);
Expand Down
Loading
Loading