Skip to content
2 changes: 1 addition & 1 deletion architecture.html

Large diffs are not rendered by default.

16 changes: 12 additions & 4 deletions architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -2720,7 +2720,7 @@
"Challenges": 15,
"Chat": 80,
"ChecklistTemplates": 5,
"Common": 35,
"Common": 36,
"Gamification": 31,
"Goals": 35,
"Habits": 74,
Expand Down Expand Up @@ -2929,10 +2929,10 @@
"Challenges": 14,
"Chat": 61,
"ChecklistTemplates": 9,
"Common": 178,
"Common": 184,
"Gamification": 38,
"Goals": 54,
"Habits": 86,
"Habits": 87,
"Marketing": 6,
"Notifications": 19,
"Profile": 40,
Expand Down Expand Up @@ -3610,7 +3610,8 @@
"testClass": "MoveHabitToolTests",
"file": "tests/Orbit.Application.Tests/Chat/Tools/MoveHabitToolTests.cs",
"references": [
"Habit"
"Habit",
"MoveHabitParentCommand"
]
},
{
Expand Down Expand Up @@ -4823,6 +4824,13 @@
"file": "tests/Orbit.Application.Tests/Common/ErrorCodesAndMessagesTests.cs",
"references": []
},
{
"testClass": "HabitCeilingLockTests",
"file": "tests/Orbit.Application.Tests/Common/HabitCeilingLockTests.cs",
"references": [
"Habit"
]
},
{
"testClass": "LocaleHelperTests",
"file": "tests/Orbit.Application.Tests/Common/LocaleHelperTests.cs",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ derived_from:

# Free vs Pro

Orbit has a free plan and a Pro plan. The free plan is fully usable for daily habit tracking; Pro raises the limits and unlocks the advanced features.
Orbit has a free plan and a Pro plan. The free plan is fully usable for daily habit tracking; Pro raises the AI message limit and unlocks advanced features.

## Limits on the free plan

- **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.
- **Habits** have an abuse guard of **1000** live top-level habits on both the free and Pro plans. Sub-habits, completed habits, and soft-deleted habits don't count toward the guard.
- **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.Text.Json;
using Orbit.Application.Chat.Models;
using Orbit.Application.Chat.Tools;
using Orbit.Application.Common;
using Orbit.Domain.Entities;
using Orbit.Domain.Enums;
using Orbit.Domain.Interfaces;
Expand Down Expand Up @@ -149,6 +150,19 @@ public async Task<ToolResult> ExecuteAsync(JsonElement args, Guid userId, Cancel

var title = titleEl.GetString() ?? string.Empty;

return await HabitCeilingLock.ExecuteAsync(
unitOfWork,
userId,
transactionToken => ExecuteLockedAsync(args, userId, title, transactionToken),
ct);
}

private async Task<ToolResult> ExecuteLockedAsync(
JsonElement args,
Guid userId,
string title,
CancellationToken ct)
{
var habitGate = await payGate.CanCreateHabits(userId, 1, ct);
if (habitGate.IsFailure)
return ToolResult.FromFailure(habitGate);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,16 @@
parentId = parsedParentId;
}

var habit = await HabitToolHelpers.FindHabitAsync(habitRepository, habitId, userId, ct);
if (habit is null)
return HabitToolHelpers.HabitNotFoundResult(habitId);

var result = await mediator.Send(new MoveHabitParentCommand(userId, habitId, parentId), ct);

if (result.IsFailure)
return ToolResult.FromFailure(result);

return new ToolResult(true, EntityId: habitId.ToString(), EntityName: habit.Title);
var habits = await habitRepository.FindAsync(
habit => habit.Id == habitId && habit.UserId == userId, ct);
return new ToolResult(
true,
EntityId: habitId.ToString(),
EntityName: habits.FirstOrDefault()?.Title);

Check warning on line 53 in src/Orbit.Application/Chat/Tools/Implementations/MoveHabitParentTool.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not use Enumerable methods on indexable collections. Instead use the collection directly.

See more on https://sonarcloud.io/project/issues?id=thomasluizon_orbit-api&issues=AaAwrgs23Ll3VxoDSBIz&open=AaAwrgs23Ll3VxoDSBIz&pullRequest=490
}
}
69 changes: 14 additions & 55 deletions src/Orbit.Application/Chat/Tools/Implementations/MoveHabitTool.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
using System.Text.Json;
using MediatR;
using Orbit.Application.Habits.Commands;
using Orbit.Domain.Entities;
using Orbit.Domain.Interfaces;

namespace Orbit.Application.Chat.Tools.Implementations;

public class MoveHabitTool(
IMediator mediator,
IGenericRepository<Habit> habitRepository) : IAiTool
{
public string Name => "move_habit";
Expand All @@ -28,66 +31,22 @@
if (!HabitToolHelpers.TryParseHabitId(args, out var habitId))
return HabitToolHelpers.InvalidHabitIdResult();

var habit = await HabitToolHelpers.FindHabitAsync(habitRepository, habitId, userId, ct);
if (habit is null)
return HabitToolHelpers.HabitNotFoundResult(habitId);

Guid? newParentId = null;
if (args.TryGetProperty("new_parent_id", out var parentEl)
&& parentEl.ValueKind == JsonValueKind.String
&& Guid.TryParse(parentEl.GetString(), out var parsedParentId))
{
var parent = await habitRepository.FindOneTrackedAsync(
h => h.Id == parsedParentId && h.UserId == userId,
cancellationToken: ct);

if (parent is null)
return new ToolResult(false, Error: $"New parent habit {parsedParentId} not found.");

if (parsedParentId == habitId)
return new ToolResult(false, Error: "A habit cannot be its own parent.");

if (await WouldCreateCycleAsync(habitId, parsedParentId, userId, ct))
return new ToolResult(false, Error: "Cannot move habit: this would create a circular parent chain.");

newParentId = parsedParentId;
}

habit.SetParentHabitId(newParentId);

return new ToolResult(true, EntityId: habit.Id.ToString(), EntityName: habit.Title);
}

/// <summary>
/// Walks up the ancestor chain of <paramref name="candidateParentId"/> and returns true
/// if <paramref name="habitId"/> appears anywhere in that chain, which would create a cycle.
/// </summary>
private async Task<bool> WouldCreateCycleAsync(Guid habitId, Guid candidateParentId, Guid userId, CancellationToken ct)
{
var visited = new HashSet<Guid>();
var current = candidateParentId;

while (true)
{
if (!visited.Add(current))
break;
var ancestors = await habitRepository.FindAsync(
h => h.Id == current && h.UserId == userId,
ct);
var ancestor = ancestors.Count > 0 ? ancestors[0] : null;

if (ancestor is null)
break;

if (ancestor.ParentHabitId is null)
break;

if (ancestor.ParentHabitId.Value == habitId)
return true;

current = ancestor.ParentHabitId.Value;
}

return false;
var result = await mediator.Send(
Comment thread
pullfrog[bot] marked this conversation as resolved.
new MoveHabitParentCommand(userId, habitId, newParentId), ct);
if (result.IsFailure)
return ToolResult.FromFailure(result);

var habits = await habitRepository.FindAsync(
habit => habit.Id == habitId && habit.UserId == userId, ct);
return new ToolResult(
true,
EntityId: habitId.ToString(),
EntityName: habits.FirstOrDefault()?.Title);

Check warning on line 50 in src/Orbit.Application/Chat/Tools/Implementations/MoveHabitTool.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not use Enumerable methods on indexable collections. Instead use the collection directly.

See more on https://sonarcloud.io/project/issues?id=thomasluizon_orbit-api&issues=AaAwrgms3Ll3VxoDSBIy&open=AaAwrgms3Ll3VxoDSBIy&pullRequest=490
}
}
59 changes: 42 additions & 17 deletions src/Orbit.Application/Chat/Tools/Implementations/UpdateHabitTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Text.Json;
using Orbit.Application.Chat.Tools;
using Orbit.Application.Common;
using Orbit.Domain.Common;
using Orbit.Domain.Entities;
using Orbit.Domain.Enums;
using Orbit.Domain.Interfaces;
Expand All @@ -12,7 +13,8 @@ namespace Orbit.Application.Chat.Tools.Implementations;
public class UpdateHabitTool(
IGenericRepository<Habit> habitRepository,
IUserDateService userDateService,
IPayGateService? payGate = null) : IAiTool
IUnitOfWork unitOfWork,
IPayGateService payGate) : IAiTool
{
public string Name => "update_habit";

Expand Down Expand Up @@ -93,30 +95,53 @@ public async Task<ToolResult> ExecuteAsync(JsonElement args, Guid userId, Cancel
if (!HabitToolHelpers.TryParseHabitId(args, out var habitId))
return HabitToolHelpers.InvalidHabitIdResult();

var habit = await HabitToolHelpers.FindHabitAsync(habitRepository, habitId, userId, ct);
if (habit is null)
return HabitToolHelpers.HabitNotFoundResult(habitId);

var today = await userDateService.GetUserTodayAsync(userId, ct);
var updateParams = ResolveUpdateParams(args, habit, today);

var result = await HabitReactivationAllowance.ExecuteAsync(
var result = await HabitCeilingLock.ExecuteEntryAsync<UpdateToolState, ToolResult>(
unitOfWork,
userId,
HabitReactivationAllowance.IsRequiredForEndDateChange(
habit,
updateParams.FrequencyUnit,
updateParams.DueDate,
updateParams.EndDate,
updateParams.ClearEndDate == true),
payGate,
() => habit.Update(updateParams),
transactionToken => PrepareUpdateAsync(args, habitId, userId, transactionToken),
state => HabitLiveRootEntry.FromUpdate(state.Habit, state.Update),
ApplyUpdateAsync,
ct);

if (result.IsFailure)
return ToolResult.FromFailure(result);

return new ToolResult(true, EntityId: habit.Id.ToString(), EntityName: habit.Title);
return result.Value;
}

private async Task<Result<UpdateToolState>> PrepareUpdateAsync(
JsonElement args,
Guid habitId,
Guid userId,
CancellationToken cancellationToken)
{
var habit = await HabitToolHelpers.FindHabitAsync(
habitRepository, habitId, userId, cancellationToken);
if (habit is null)
return Result.Failure<UpdateToolState>($"Habit {habitId} not found.");

var today = await userDateService.GetUserTodayAsync(userId, cancellationToken);
return Result.Success(new UpdateToolState(habit, ResolveUpdateParams(args, habit, today)));
}

private async Task<Result<ToolResult>> ApplyUpdateAsync(
UpdateToolState state,
CancellationToken cancellationToken)
{
var result = state.Habit.Update(state.Update);
if (result.IsFailure)
return result.PropagateError<ToolResult>();

await unitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success(new ToolResult(
true,
EntityId: state.Habit.Id.ToString(),
EntityName: state.Habit.Title));
}

private sealed record UpdateToolState(Habit Habit, HabitUpdateParams Update);

/// <summary>
/// Resolve each field: absent = keep existing, null = clear, value = update.
/// Extracted to reduce ExecuteAsync cognitive complexity.
Expand Down
2 changes: 1 addition & 1 deletion src/Orbit.Application/Common/AppConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public static class AppConstants
public const int HabitLogsLookbackDays = 365;
public const int MaxHabitLogsReturned = 1000;
public const int DefaultReminderMinutes = 15;
public const int DefaultFreeMaxHabits = 10;
public const int DefaultFreeMaxHabits = 1000;
public const int DefaultFreeAiMessages = 20;
public const int DefaultProAiMessages = 500;
public const int MaxBulkOperationSize = 100;
Expand Down
Loading
Loading