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
37 changes: 37 additions & 0 deletions src/Orbit.Api/Controllers/ProfileController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ public record UpdatePublicProfileRequest(
bool ShowTopHabits,
bool Regenerate);

public record ApplyOnboardingRequest(
IReadOnlyList<ApplyHabitInput>? Habits,
ApplyLogInput? FirstLog,
ApplyGoalInput? Goal,
int? WeekStartDay,
string? ColorScheme);

private static readonly JsonSerializerOptions ExportJsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
Expand Down Expand Up @@ -216,6 +223,36 @@ public async Task<IActionResult> CompleteOnboarding(CancellationToken cancellati
return result.ToPayGateAwareResult(() => NoContent());
}

[HttpPost("onboarding/apply")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> ApplyOnboarding(
[FromBody] ApplyOnboardingRequest request,
CancellationToken cancellationToken)
{
var command = new ApplyOnboardingCommand(
HttpContext.GetUserId(),
request.Habits ?? [],
request.FirstLog,
request.Goal,
request.WeekStartDay,
request.ColorScheme);
var result = await mediator.Send(command, cancellationToken);
return result.ToPayGateAwareResult(value => Ok(value));
}

[HttpPut("import-prompt/dismiss")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> DismissImportPrompt(CancellationToken cancellationToken)
{
var command = new DismissImportPromptCommand(HttpContext.GetUserId());
var result = await mediator.Send(command, cancellationToken);
return result.ToPayGateAwareResult(() => NoContent());
}

[HttpPut("tour")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
Expand Down
252 changes: 252 additions & 0 deletions src/Orbit.Application/Profile/Commands/ApplyOnboardingCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
using MediatR;
using Microsoft.Extensions.Caching.Memory;
using Orbit.Application.Behaviors;
using Orbit.Application.Common;
using Orbit.Domain.Common;
using Orbit.Domain.Entities;
using Orbit.Domain.Enums;
using Orbit.Domain.Interfaces;
using Orbit.Domain.ValueObjects;

namespace Orbit.Application.Profile.Commands;

public record ApplyHabitInput(
string Title,
string? Description,
string? Emoji,
FrequencyUnit? FrequencyUnit,
int? FrequencyQuantity,
IReadOnlyList<DayOfWeek>? Days = null,
bool IsBadHabit = false,
bool IsGeneral = false,
bool IsFlexible = false,
DateOnly? DueDate = null,
TimeOnly? DueTime = null,
bool ReminderEnabled = false,
IReadOnlyList<int>? ReminderTimes = null,
IReadOnlyList<ChecklistItem>? ChecklistItems = null);

public record ApplyLogInput(int HabitIndex, DateOnly Date);

public record ApplyGoalInput(
string Title,
string? Description,
decimal TargetValue,
string Unit,
DateOnly? Deadline = null,
GoalType Type = GoalType.Standard);

public record ApplyOnboardingResponse(
bool Applied,
int CreatedHabitCount,
bool CreatedGoal,
bool LoggedFirstHabit);

public record ApplyOnboardingCommand(
Guid UserId,
IReadOnlyList<ApplyHabitInput> Habits,
ApplyLogInput? FirstLog,
ApplyGoalInput? Goal,
int? WeekStartDay,
string? ColorScheme) : IRequest<Result<ApplyOnboardingResponse>>, IConcurrencyRetryable;

/// <summary>
/// Applies the buffer of answers a user built during pre-auth onboarding in a single transaction:
/// creates the habits (trimmed to the free-plan allowance), an optional first log, an optional
/// Pro-gated goal, week-start/color preferences, and flips <c>HasCompletedOnboarding</c>. Idempotent
/// by construction — an already-onboarded user is a no-op (<c>Applied:false</c>) — so the client can
/// flush unconditionally after any successful auth and retry safely under the concurrency pipeline.
/// </summary>
public class ApplyOnboardingCommandHandler(

Check warning on line 60 in src/Orbit.Application/Profile/Commands/ApplyOnboardingCommand.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

Constructor has 8 parameters, which is greater than the 7 authorized.
IGenericRepository<User> userRepository,
IGenericRepository<Habit> habitRepository,
IGenericRepository<Goal> goalRepository,
IPayGateService payGate,
IUserDateService userDateService,
IAppConfigService appConfig,
IUnitOfWork unitOfWork,
IMemoryCache cache) : IRequestHandler<ApplyOnboardingCommand, Result<ApplyOnboardingResponse>>

Check warning on line 68 in src/Orbit.Application/Profile/Commands/ApplyOnboardingCommand.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Constructor has 8 parameters, which is greater than the 7 authorized.

See more on https://sonarcloud.io/project/issues?id=thomasluizon_orbit-api&issues=AZ8wuF7UpHPotZ9rNbYh&open=AZ8wuF7UpHPotZ9rNbYh&pullRequest=286
{
public async Task<Result<ApplyOnboardingResponse>> Handle(

Check warning on line 70 in src/Orbit.Application/Profile/Commands/ApplyOnboardingCommand.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

Refactor this method to reduce its Cognitive Complexity from 18 to the 15 allowed.

Check failure on line 70 in src/Orbit.Application/Profile/Commands/ApplyOnboardingCommand.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 18 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=thomasluizon_orbit-api&issues=AZ8wuF7UpHPotZ9rNbYj&open=AZ8wuF7UpHPotZ9rNbYj&pullRequest=286
ApplyOnboardingCommand request, CancellationToken cancellationToken)
{
Result<ApplyOnboardingResponse>? failure = null;
ApplyOnboardingResponse? response = null;

await unitOfWork.ExecuteInTransactionAsync(async ct =>
{
await unitOfWork.AcquireAdvisoryLockAsync($"onboarding-apply:{request.UserId}", ct);

var user = await userRepository.FindOneTrackedAsync(
u => u.Id == request.UserId, cancellationToken: ct);

if (user is null)
{
failure = Result.Failure<ApplyOnboardingResponse>(ErrorMessages.UserNotFound);
return;
}

if (user.HasCompletedOnboarding)
{
response = new ApplyOnboardingResponse(false, 0, false, false);
return;
}

var today = await userDateService.GetUserTodayAsync(request.UserId, ct);

var habitsToCreate = await TrimToAllowanceAsync(user, request.Habits, ct);

var createResult = await CreateHabitsAsync(request.UserId, habitsToCreate, today, ct);
if (createResult.IsFailure)
{
failure = createResult.PropagateError<ApplyOnboardingResponse>();
return;
}

var createdHabits = createResult.Value;

var loggedFirstHabit = false;
if (request.FirstLog is { } firstLog
&& firstLog.HabitIndex >= 0
&& firstLog.HabitIndex < createdHabits.Count)
{
var logResult = createdHabits[firstLog.HabitIndex].Log(firstLog.Date);
if (logResult.IsFailure)
{
failure = logResult.PropagateError<ApplyOnboardingResponse>();
return;
}
loggedFirstHabit = true;
}

var goalResult = await CreateGoalIfAllowedAsync(request.UserId, request.Goal, today, ct);
if (goalResult.IsFailure)
{
failure = goalResult.PropagateError<ApplyOnboardingResponse>();
return;
}
var createdGoal = goalResult.Value;

var prefsResult = ApplyPreferences(user, request.WeekStartDay, request.ColorScheme);
if (prefsResult.IsFailure)
{
failure = prefsResult.PropagateError<ApplyOnboardingResponse>();
return;
}

user.CompleteOnboarding();

await unitOfWork.SaveChangesAsync(ct);

response = new ApplyOnboardingResponse(true, createdHabits.Count, createdGoal, loggedFirstHabit);
}, cancellationToken);

if (failure is not null)

Check warning on line 144 in src/Orbit.Application/Profile/Commands/ApplyOnboardingCommand.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

Change this condition so that it does not always evaluate to 'False'. Some code paths are unreachable.

Check warning on line 144 in src/Orbit.Application/Profile/Commands/ApplyOnboardingCommand.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Change this condition so that it does not always evaluate to 'False'. Some code paths are unreachable.

See more on https://sonarcloud.io/project/issues?id=thomasluizon_orbit-api&issues=AZ8wuF7UpHPotZ9rNbYi&open=AZ8wuF7UpHPotZ9rNbYi&pullRequest=286
return failure;

if (response!.Applied)
CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId);

return Result.Success(response);
}

private async Task<IReadOnlyList<ApplyHabitInput>> TrimToAllowanceAsync(
User user, IReadOnlyList<ApplyHabitInput> habits, CancellationToken cancellationToken)
{
if (user.HasProAccess)
return habits;

var maxHabits = await appConfig.GetAsync(
AppConfigKeys.FreeMaxHabits, AppConstants.DefaultFreeMaxHabits, cancellationToken);
var existingRoots = await habitRepository.CountAsync(
h => h.UserId == user.Id && h.ParentHabitId == null, cancellationToken);
var allowance = Math.Max(0, maxHabits - existingRoots);

return allowance >= habits.Count ? habits : habits.Take(allowance).ToList();
}

private async Task<Result<List<Habit>>> CreateHabitsAsync(
Guid userId, IReadOnlyList<ApplyHabitInput> habits, DateOnly today, CancellationToken cancellationToken)
{
var createdHabits = new List<Habit>();
var position = 0;

foreach (var item in habits)
{
var habitResult = Habit.Create(new HabitCreateParams(
userId,
item.Title,
item.FrequencyUnit,
item.FrequencyQuantity,
item.Description,
Emoji: item.Emoji,
Days: item.Days,
IsBadHabit: item.IsBadHabit,
DueDate: item.DueDate ?? today,
DueTime: item.DueTime,
ReminderEnabled: item.ReminderEnabled,
ReminderTimes: item.ReminderTimes,
ChecklistItems: item.ChecklistItems,
IsGeneral: item.IsGeneral,
IsFlexible: item.IsFlexible,
Position: position++));

if (habitResult.IsFailure)
return habitResult.PropagateError<List<Habit>>();

await habitRepository.AddAsync(habitResult.Value, cancellationToken);
createdHabits.Add(habitResult.Value);
}

return Result.Success(createdHabits);
}

private async Task<Result<bool>> CreateGoalIfAllowedAsync(
Guid userId, ApplyGoalInput? goalInput, DateOnly today, CancellationToken cancellationToken)
{
if (goalInput is null)
return Result.Success(false);

var goalGate = await payGate.CanAccessGoals(userId, cancellationToken);
if (goalGate.IsFailure)
return Result.Success(false);

if (goalInput.Deadline is { } deadline && deadline < today)
return Result.Failure<bool>(ErrorMessages.DeadlineInPast);

var goalResult = Goal.Create(new Goal.CreateGoalParams(
userId,
goalInput.Title,
goalInput.TargetValue,
goalInput.Unit,
goalInput.Description,
goalInput.Deadline,
0,
goalInput.Type));

if (goalResult.IsFailure)
return goalResult.PropagateError<bool>();

await goalRepository.AddAsync(goalResult.Value, cancellationToken);
return Result.Success(true);
}

private static Result ApplyPreferences(User user, int? weekStartDay, string? colorScheme)
{
if (weekStartDay is { } day)
{
var weekStartResult = user.SetWeekStartDay(day);
if (weekStartResult.IsFailure)
return weekStartResult;
}

if (colorScheme is not null)
{
var colorResult = user.SetColorScheme(colorScheme);
if (colorResult.IsFailure)
return colorResult;
}

return Result.Success();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using MediatR;
using Orbit.Application.Behaviors;
using Orbit.Application.Common;
using Orbit.Domain.Common;
using Orbit.Domain.Entities;
using Orbit.Domain.Interfaces;

namespace Orbit.Application.Profile.Commands;

public record DismissImportPromptCommand(Guid UserId) : IRequest<Result>, IConcurrencyRetryable;

/// <summary>
/// Marks the one-time "import from another app?" prompt as seen for the user. Not pay-gated: the
/// prompt is shown to every account exactly once, so every account must be able to dismiss it
/// permanently regardless of plan.
/// </summary>
public class DismissImportPromptCommandHandler(
IGenericRepository<User> userRepository,
IUnitOfWork unitOfWork) : IRequestHandler<DismissImportPromptCommand, Result>
{
public async Task<Result> Handle(DismissImportPromptCommand request, CancellationToken cancellationToken)
{
var user = await userRepository.FindOneTrackedAsync(
u => u.Id == request.UserId,
cancellationToken: cancellationToken);

if (user is null)
return Result.Failure(ErrorMessages.UserNotFound);

user.MarkImportPromptSeen();

await unitOfWork.SaveChangesAsync(cancellationToken);

return Result.Success();
}
}
2 changes: 2 additions & 0 deletions src/Orbit.Application/Profile/Queries/GetProfileQuery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public record ProfileResponse(
int AiMessagesUsed,
int AiMessagesLimit,
bool HasImportedCalendar,
bool HasSeenImportPrompt,
bool HasGoogleConnection,
string? SubscriptionInterval,
string? SubscriptionSource,
Expand Down Expand Up @@ -117,6 +118,7 @@ public async Task<Result<ProfileResponse>> Handle(GetProfileQuery request, Cance
user.AiMessagesUsedThisMonth,
aiMessageLimit,
user.HasImportedCalendar,
user.HasSeenImportPrompt,
user.GoogleAccessToken is not null,
user.SubscriptionInterval?.ToString().ToLowerInvariant(),
user.SubscriptionSource.ToApiValue(),
Expand Down
Loading
Loading