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
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,8 @@ private static void AddHabitCommandDependencies(WebApplicationBuilder builder)
sp.GetRequiredService<IUserStreakService>(),
sp.GetRequiredService<IGamificationService>(),
sp.GetRequiredService<Orbit.Application.Challenges.Services.IChallengeProgressService>(),
sp.GetRequiredService<MediatR.IMediator>()));
sp.GetRequiredService<MediatR.IMediator>(),
sp.GetRequiredService<IPayGateService>()));
builder.Services.AddScoped<Orbit.Application.Habits.Commands.BulkLogServices>(sp =>
new Orbit.Application.Habits.Commands.BulkLogServices(
sp.GetRequiredService<IUserDateService>(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Orbit has a free plan and a Pro plan. The free plan is fully usable for daily ha

## Limits on the free plan

- **Habits** are capped at **10** top-level habits. Sub-habits and soft-deleted habits don't count toward the cap. Pro removes the cap.
- **Habits** are capped at **10** top-level habits. Sub-habits, completed habits, and soft-deleted habits don't count toward the cap. Pro removes the cap.
- **AI messages** are capped at **20** per month. Pro raises this to **500** per month.

Both plans can also earn a small bonus of extra AI messages from ad rewards, added on top of the plan limit.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Globalization;
using System.Text.Json;
using Orbit.Application.Chat.Tools;
using Orbit.Application.Common;
using Orbit.Domain.Entities;
using Orbit.Domain.Enums;
using Orbit.Domain.Interfaces;
Expand All @@ -9,7 +10,8 @@
namespace Orbit.Application.Chat.Tools.Implementations;

public class UpdateHabitTool(
IGenericRepository<Habit> habitRepository) : IAiTool
IGenericRepository<Habit> habitRepository,
IPayGateService? payGate = null) : IAiTool
{
public string Name => "update_habit";

Expand Down Expand Up @@ -96,7 +98,17 @@ public async Task<ToolResult> ExecuteAsync(JsonElement args, Guid userId, Cancel

var updateParams = ResolveUpdateParams(args, habit);

var result = habit.Update(updateParams);
var result = await HabitReactivationAllowance.ExecuteAsync(
userId,
HabitReactivationAllowance.IsRequiredForEndDateChange(
habit,
updateParams.FrequencyUnit,
updateParams.DueDate,
updateParams.EndDate,
updateParams.ClearEndDate == true),
payGate,
() => habit.Update(updateParams),
ct);
if (result.IsFailure)
return ToolResult.FromFailure(result);

Expand Down
62 changes: 61 additions & 1 deletion src/Orbit.Application/Common/PayGateService.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Orbit.Domain.Common;
using Orbit.Domain.Entities;
using Orbit.Domain.Enums;
using Orbit.Domain.Interfaces;

namespace Orbit.Application.Common;
Expand All @@ -20,7 +21,7 @@ public async Task<Result> CanCreateHabits(Guid userId, int count = 1, Cancellati

var maxHabits = await appConfig.GetAsync(AppConfigKeys.FreeMaxHabits, AppConstants.DefaultFreeMaxHabits, ct);
var activeHabitCount = await habitRepository.CountAsync(
h => h.UserId == userId && h.ParentHabitId == null, ct);
h => h.UserId == userId && h.ParentHabitId == null && !h.IsCompleted, ct);
Comment thread
thomasluizon marked this conversation as resolved.

if (activeHabitCount + count > maxHabits)
return Result.PayGateFailure($"You've reached the {maxHabits} habit limit on the free plan. Upgrade to Pro for unlimited habits.");
Expand Down Expand Up @@ -199,3 +200,62 @@ private async Task<Result> RequireProAccess(Guid userId, string errorMessage, Ca
: Result.PayGateFailure(errorMessage);
}
}

internal static class HabitReactivationAllowance
{
public static bool IsRequiredForUnlog(Habit habit) =>
habit.IsCompleted && habit.ParentHabitId is null;

public static bool IsRequiredForEndDateChange(
Habit habit,
FrequencyUnit? frequencyUnit,
DateOnly? dueDate,
DateOnly? endDate,
bool clearEndDate)
{
if (!habit.IsCompleted || habit.ParentHabitId is not null || frequencyUnit is null)
return false;

if (clearEndDate)
return true;

return endDate.HasValue && (dueDate ?? habit.DueDate) <= endDate.Value;
}

public static async Task<Result<T>> ExecuteAsync<T>(
Guid userId,
bool requiresAllowance,
IPayGateService? payGate,
Func<Result<T>> transition,
CancellationToken cancellationToken)
{
if (requiresAllowance)
{
var allowanceGate = await GetPayGate(payGate).CanCreateHabits(userId, 1, cancellationToken);
if (allowanceGate.IsFailure)
return allowanceGate.PropagateError<T>();
}

return transition();
}

public static async Task<Result> ExecuteAsync(
Guid userId,
bool requiresAllowance,
IPayGateService? payGate,
Func<Result> transition,
CancellationToken cancellationToken)
{
if (requiresAllowance)
{
var allowanceGate = await GetPayGate(payGate).CanCreateHabits(userId, 1, cancellationToken);
if (allowanceGate.IsFailure)
return allowanceGate;
}

return transition();
}

private static IPayGateService GetPayGate(IPayGateService? payGate) =>
payGate ?? throw new InvalidOperationException("Habit reactivation allowance service is not configured.");
}
10 changes: 8 additions & 2 deletions src/Orbit.Application/Habits/Commands/LogHabitCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@
IUserStreakService UserStreakService,
IGamificationService GamificationService,
IChallengeProgressService ChallengeProgressService,
IMediator Mediator);
IMediator Mediator,
IPayGateService? PayGate = null);

public partial class LogHabitCommandHandler(
LogHabitRepositories repos,
Expand Down Expand Up @@ -113,7 +114,12 @@
var attempt = 1;
while (true)
{
var unlogResult = habit.Unlog(targetDate);
var unlogResult = await HabitReactivationAllowance.ExecuteAsync(
habit.UserId,
HabitReactivationAllowance.IsRequiredForUnlog(habit),
services.PayGate,
() => habit.Unlog(targetDate),
cancellationToken);
if (unlogResult.IsFailure)
return unlogResult.PropagateError<LogHabitResponse>();
unlogEntity = unlogResult.Value;
Expand Down Expand Up @@ -226,7 +232,7 @@
var loggableWindowStart = today.AddDays(-AppConstants.DefaultOverdueWindowDays);
return repos.HabitRepository.FindOneTrackedAsync(
h => h.Id == habitId,
q => q.Include(h => h.Logs.Where(l => l.Date >= loggableWindowStart)).Include(h => h.Goals),

Check warning on line 235 in src/Orbit.Application/Habits/Commands/LogHabitCommand.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

Call 'AsSplitQuery' to avoid multiplying rows by including 'Logs' and 'Goals' in the same query (a Cartesian explosion).
cancellationToken);
}

Expand Down
50 changes: 30 additions & 20 deletions src/Orbit.Application/Habits/Commands/UpdateHabitCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
IUnitOfWork unitOfWork,
IMemoryCache cache) : IRequestHandler<UpdateHabitCommand, Result>
{
public async Task<Result> Handle(UpdateHabitCommand request, CancellationToken cancellationToken)

Check warning on line 36 in src/Orbit.Application/Habits/Commands/UpdateHabitCommand.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.
{
if (request.GoalIds is not null)
{
Expand Down Expand Up @@ -71,26 +71,36 @@

var opts = request.Options ?? new UpdateHabitCommandOptions();

var result = habit.Update(new HabitUpdateParams(
request.Title,
request.Description,
request.FrequencyUnit,
request.FrequencyQuantity,
opts.Days,
request.IsBadHabit,
request.DueDate,
DueTime: opts.DueTime,
DueEndTime: opts.DueEndTime,
ReminderEnabled: opts.ReminderEnabled,
ReminderTimes: opts.ReminderTimes,
SlipAlertEnabled: opts.SlipAlertEnabled,
ChecklistItems: opts.ChecklistItems,
IsGeneral: request.IsGeneral,
IsFlexible: opts.IsFlexible,
EndDate: opts.EndDate,
ClearEndDate: request.ClearEndDate,
ScheduledReminders: opts.ScheduledReminders,
Emoji: request.Emoji));
var result = await HabitReactivationAllowance.ExecuteAsync(
Comment thread
thomasluizon marked this conversation as resolved.
request.UserId,
HabitReactivationAllowance.IsRequiredForEndDateChange(
habit,
request.FrequencyUnit,
request.DueDate,
opts.EndDate,
request.ClearEndDate == true),
payGate,
() => habit.Update(new HabitUpdateParams(
request.Title,
request.Description,
request.FrequencyUnit,
request.FrequencyQuantity,
opts.Days,
request.IsBadHabit,
request.DueDate,
DueTime: opts.DueTime,
DueEndTime: opts.DueEndTime,
ReminderEnabled: opts.ReminderEnabled,
ReminderTimes: opts.ReminderTimes,
SlipAlertEnabled: opts.SlipAlertEnabled,
ChecklistItems: opts.ChecklistItems,
IsGeneral: request.IsGeneral,
IsFlexible: opts.IsFlexible,
EndDate: opts.EndDate,
ClearEndDate: request.ClearEndDate,
ScheduledReminders: opts.ScheduledReminders,
Emoji: request.Emoji)),
cancellationToken);

if (result.IsFailure)
return result;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ private async Task<IReadOnlyList<ApplyHabitInput>> TrimToAllowanceAsync(
var maxHabits = await appConfig.GetAsync(
AppConfigKeys.FreeMaxHabits, AppConstants.DefaultFreeMaxHabits, cancellationToken);
var existingRoots = await repos.Habits.CountAsync(
h => h.UserId == user.Id && h.ParentHabitId == null, cancellationToken);
h => h.UserId == user.Id && h.ParentHabitId == null && !h.IsCompleted, cancellationToken);
var allowance = Math.Max(0, maxHabits - existingRoots);

return allowance >= habits.Count ? habits : habits.Take(allowance).ToList();
Expand Down
46 changes: 46 additions & 0 deletions tests/Orbit.Application.Tests/Chat/Tools/UpdateHabitToolTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using NSubstitute;
using Orbit.Application.Chat.Tools;
using Orbit.Application.Chat.Tools.Implementations;
using Orbit.Domain.Common;
using Orbit.Domain.Entities;
using Orbit.Domain.Enums;
using Orbit.Domain.Interfaces;
Expand Down Expand Up @@ -38,6 +39,43 @@ public async Task SuccessfulUpdate_ReturnsSuccess()
result.EntityName.Should().Be("Drink Water");
}

[Fact]
public async Task AtCapReactivation_ReturnsPayGateFailureWithoutMutatingHabit()
{
var habit = CreateCompletedRecurringHabit();
SetupHabitFound(habit);
var payGate = Substitute.For<IPayGateService>();
payGate.CanCreateHabits(UserId, 1, Arg.Any<CancellationToken>())
.Returns(Result.PayGateFailure("Habit limit reached"));
var tool = new UpdateHabitTool(_habitRepo, payGate);

var args = JsonDocument.Parse($$$"""{"habit_id": "{{{habit.Id}}}", "end_date": null}""").RootElement;
var result = await tool.ExecuteAsync(args, UserId, CancellationToken.None);

result.Success.Should().BeFalse();
result.ErrorCode.Should().Be(Result.PayGateErrorCode);
habit.IsCompleted.Should().BeTrue();
habit.EndDate.Should().Be(Today);
}

[Fact]
public async Task AllowedReactivation_UpdatesCompletedHabit()
{
var habit = CreateCompletedRecurringHabit();
SetupHabitFound(habit);
var payGate = Substitute.For<IPayGateService>();
payGate.CanCreateHabits(UserId, 1, Arg.Any<CancellationToken>())
.Returns(Result.Success());
var tool = new UpdateHabitTool(_habitRepo, payGate);

var args = JsonDocument.Parse($$$"""{"habit_id": "{{{habit.Id}}}", "end_date": null}""").RootElement;
var result = await tool.ExecuteAsync(args, UserId, CancellationToken.None);

result.Success.Should().BeTrue();
habit.IsCompleted.Should().BeFalse();
habit.EndDate.Should().BeNull();
}

[Fact]
public async Task HabitNotFound_ReturnsError()
{
Expand Down Expand Up @@ -586,6 +624,14 @@ private static Habit CreateHabit(string title, FrequencyUnit? freq, int? qty)
return Habit.Create(new HabitCreateParams(UserId, title, freq, qty, DueDate: Today)).Value;
}

private static Habit CreateCompletedRecurringHabit()
{
var habit = Habit.Create(new HabitCreateParams(
UserId, "Finished recurring habit", FrequencyUnit.Day, 1, DueDate: Today, EndDate: Today)).Value;
habit.AdvanceDueDate(Today);
return habit;
}

private static Habit CreateHabitWithTime(string title, FrequencyUnit? freq, int? qty, TimeOnly dueTime)
{
return Habit.Create(new HabitCreateParams(UserId, title, freq, qty, DueDate: Today, DueTime: dueTime)).Value;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,15 @@ private static User CreateFreeUser()
private static ApplyHabitInput Habit(string title) =>
new(title, null, null, FrequencyUnit.Day, 1);

private static Habit ExistingOneTimeTask(Guid userId, string title, bool completed)
{
var task = Orbit.Domain.Entities.Habit.Create(new HabitCreateParams(
userId, title, null, null, Today)).Value;
if (completed)
task.Log(Today).IsSuccess.Should().BeTrue();
return task;
}

private static string SummaryCacheKey() =>
$"summary:{UserId}:{Today:yyyy-MM-dd}:en";

Expand Down Expand Up @@ -177,6 +186,34 @@ public async Task Apply_FreeUserOverCap_TrimsToAllowance()
user.HasCompletedOnboarding.Should().BeTrue();
}

[Fact]
public async Task Apply_FreeUserWithCompletedTasks_CreatesFullRequestedSet()
{
var user = CreateFreeUser();
SetupUser(user);
var existingHabits = Enumerable.Range(1, 9)
.Select(index => ExistingOneTimeTask(user.Id, $"Finished task {index}", completed: true))
.Append(ExistingOneTimeTask(user.Id, "Live task", completed: false))
.ToList();
_habitRepo.CountAsync(
Arg.Any<Expression<Func<Habit, bool>>>(),
Arg.Any<CancellationToken>())
.Returns(call => existingHabits.Count(
call.ArgAt<Expression<Func<Habit, bool>>>(0).Compile()));
var command = new ApplyOnboardingCommand(
UserId,
[Habit("One"), Habit("Two"), Habit("Three"), Habit("Four"), Habit("Five")],
null,
null,
null,
null);

var result = await CreateHandler().Handle(command, CancellationToken.None);

result.IsSuccess.Should().BeTrue();
result.Value.CreatedHabitCount.Should().Be(5);
}

[Fact]
public async Task Apply_GoalGateFails_SkipsGoalButStillApplies()
{
Expand Down
Loading
Loading