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
2 changes: 1 addition & 1 deletion architecture.html

Large diffs are not rendered by default.

22 changes: 17 additions & 5 deletions architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -2882,11 +2882,11 @@
"Behaviors": 12,
"Calendar": 16,
"Challenges": 14,
"Chat": 57,
"Chat": 59,
"ChecklistTemplates": 9,
"Common": 159,
"Common": 160,
"Gamification": 33,
"Goals": 37,
"Goals": 38,
"Habits": 80,
"Marketing": 6,
"Notifications": 16,
Expand Down Expand Up @@ -3221,7 +3221,8 @@
"references": [
"CreateGoalCommand",
"CreateGoalCommandHandler",
"Goal"
"Goal",
"Habit"
]
},
{
Expand Down Expand Up @@ -3936,7 +3937,18 @@
"references": [
"CreateGoalCommand",
"CreateGoalCommandHandler",
"Goal"
"Goal",
"Habit"
]
},
{
"testClass": "CreateGoalToolHabitLinkTests",
"file": "tests/Orbit.Application.Tests/Commands/Goals/CreateGoalCommandHandlerTests.cs",
"references": [
"CreateGoalCommand",
"CreateGoalCommandHandler",
"Goal",
"Habit"
]
},
{
Expand Down
4 changes: 2 additions & 2 deletions src/Orbit.Api/Controllers/GoalsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ namespace Orbit.Api.Controllers;
[Route("api/[controller]")]
public partial class GoalsController(IMediator mediator, ILogger<GoalsController> logger) : ControllerBase
{
public record CreateGoalRequest(string Title, string? Description, [property: JsonRequired] decimal TargetValue, string Unit, DateOnly? Deadline = null, GoalType Type = GoalType.Standard);
public record CreateGoalRequest(string Title, string? Description, [property: JsonRequired] decimal TargetValue, string Unit, DateOnly? Deadline = null, GoalType Type = GoalType.Standard, IReadOnlyList<Guid>? HabitIds = null);
public record UpdateGoalRequest(string Title, string? Description, [property: JsonRequired] decimal TargetValue, string Unit, DateOnly? Deadline = null);
public record UpdateProgressRequest([property: JsonRequired] decimal CurrentValue, string? Note = null);
public record UpdateStatusRequest([property: JsonRequired] GoalStatus Status);
Expand Down Expand Up @@ -57,7 +57,7 @@ public async Task<IActionResult> GetGoalById(Guid id, CancellationToken cancella
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> CreateGoal([FromBody] CreateGoalRequest request, CancellationToken cancellationToken)
{
var command = new CreateGoalCommand(HttpContext.GetUserId(), request.Title, request.Description, request.TargetValue, request.Unit, request.Deadline, Type: request.Type);
var command = new CreateGoalCommand(HttpContext.GetUserId(), request.Title, request.Description, request.TargetValue, request.Unit, request.Deadline, Type: request.Type, HabitIds: request.HabitIds);
var result = await mediator.Send(command, cancellationToken);
return result.ToPayGateAwareResult(value =>
{
Expand Down
10 changes: 10 additions & 0 deletions src/Orbit.Api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -12880,6 +12880,16 @@
},
"type": {
"$ref": "#/components/schemas/GoalType"
},
"habitIds": {
"type": [
"null",
"array"
],
"items": {
"type": "string",
"format": "uuid"
}
}
}
},
Expand Down
90 changes: 82 additions & 8 deletions src/Orbit.Application/Chat/Tools/Implementations/CreateGoalTool.cs
Original file line number Diff line number Diff line change
@@ -1,17 +1,40 @@
using System.Globalization;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Orbit.Application.Common;
using Orbit.Domain.Entities;
using Orbit.Domain.Enums;
using Orbit.Domain.Interfaces;

namespace Orbit.Application.Chat.Tools.Implementations;

public class CreateGoalTool(
IGenericRepository<Goal> goalRepository,
IUnitOfWork unitOfWork) : IAiTool
public class CreateGoalTool : IAiTool
{
private readonly IGenericRepository<Goal> _goalRepository;
private readonly IUnitOfWork _unitOfWork;
private readonly IGenericRepository<Habit>? _habitRepository;

[ActivatorUtilitiesConstructor]
public CreateGoalTool(
IGenericRepository<Goal> goalRepository,
IUnitOfWork unitOfWork,
IGenericRepository<Habit> habitRepository)
{
_goalRepository = goalRepository;
_unitOfWork = unitOfWork;
_habitRepository = habitRepository;
}

public CreateGoalTool(
IGenericRepository<Goal> goalRepository,
IUnitOfWork unitOfWork)
{
_goalRepository = goalRepository;
_unitOfWork = unitOfWork;
}

public string Name => "create_goal";
public string Description => "Create a new goal to track progress toward a target. Goals can have a target value and unit (e.g., 'read 12 books', 'lose 5 kg'). If the user doesn't specify a target, use target_value=1 and unit='goal'. Use when user wants to track measurable long-term progress. Use goal_type='Streak' to create a streak goal that tracks the habit's consecutive day streak.";
public string Description => "Create a new goal to track progress toward a target. Pass habit_ids inline in this call rather than following up with link_habits_to_goal. Goals can have a target value and unit (e.g., 'read 12 books', 'lose 5 kg'). If the user doesn't specify a target, use target_value=1 and unit='goal'. Use when user wants to track measurable long-term progress. Use goal_type='Streak' to create a streak goal that tracks the habit's consecutive day streak.";

public object GetParameterSchema() => new
{
Expand All @@ -23,12 +46,18 @@
target_value = new { type = "number", description = "Target number to reach (default: 1)" },
unit = new { type = JsonSchemaTypes.String, description = "Unit of measurement (e.g., 'books', 'kg', 'dollars', 'goal')" },
deadline = new { type = JsonSchemaTypes.String, description = "Optional deadline in YYYY-MM-DD format" },
goal_type = new { type = JsonSchemaTypes.String, description = "Goal type: 'Standard' (default) or 'Streak' (tracks consecutive habit streak)" }
goal_type = new { type = JsonSchemaTypes.String, description = "Goal type: 'Standard' (default) or 'Streak' (tracks consecutive habit streak)" },
habit_ids = new
{
type = JsonSchemaTypes.Array,
description = "Optional IDs of habits to link to the new goal",
items = new { type = JsonSchemaTypes.String }
}
},
required = new[] { "title" }
};

public async Task<ToolResult> ExecuteAsync(JsonElement args, Guid userId, CancellationToken ct)

Check warning on line 60 in src/Orbit.Application/Chat/Tools/Implementations/CreateGoalTool.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

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

Check failure on line 60 in src/Orbit.Application/Chat/Tools/Implementations/CreateGoalTool.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

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

See more on https://sonarcloud.io/project/issues?id=thomasluizon_orbit-api&issues=AZ_aCG3V9LZEQ4BjPmgu&open=AZ_aCG3V9LZEQ4BjPmgu&pullRequest=454
{
if (!args.TryGetProperty("title", out var titleEl) || string.IsNullOrWhiteSpace(titleEl.GetString()))
return new ToolResult(false, Error: "title is required.");
Expand All @@ -47,6 +76,13 @@
if (args.TryGetProperty("goal_type", out var goalTypeEl) && goalTypeEl.ValueKind == JsonValueKind.String)
Enum.TryParse(goalTypeEl.GetString(), ignoreCase: true, out goalType);

var habitIdsFailure = TryParseHabitIds(args, out var habitIds);
if (habitIdsFailure is not null)
return habitIdsFailure;

if (habitIds.Count > AppConstants.MaxHabitsPerGoal)
return new ToolResult(false, Error: ErrorMessages.MaxHabitsPerGoal.Format(AppConstants.MaxHabitsPerGoal).Message);

var goalResult = Goal.Create(new Goal.CreateGoalParams(
userId,
titleEl.GetString() ?? string.Empty,
Expand All @@ -57,8 +93,46 @@
Type: goalType));
if (goalResult.IsFailure) return ToolResult.FromFailure(goalResult);

await goalRepository.AddAsync(goalResult.Value, ct);
await unitOfWork.SaveChangesAsync(ct);
return new ToolResult(true, EntityId: goalResult.Value.Id.ToString(), EntityName: goalResult.Value.Title);
var goal = goalResult.Value;
if (habitIds.Count > 0)
{
if (_habitRepository is null)
return new ToolResult(false, Error: "habit_ids is unavailable for this tool instance.");

var habits = await _habitRepository.FindTrackedAsync(
h => habitIds.Contains(h.Id) && h.UserId == userId,
ct);

var habitsResolved = OwnershipValidation.AllResolved(habitIds, habits, h => h.Id, ErrorMessages.HabitNotFound);
if (habitsResolved.IsFailure)
return ToolResult.FromFailure(habitsResolved);

foreach (var habit in habits)
goal.AddHabit(habit);
}

await _goalRepository.AddAsync(goal, ct);
await _unitOfWork.SaveChangesAsync(ct);
return new ToolResult(true, EntityId: goal.Id.ToString(), EntityName: goal.Title);
}

private static ToolResult? TryParseHabitIds(JsonElement args, out List<Guid> habitIds)
{
habitIds = [];
if (!args.TryGetProperty("habit_ids", out var habitIdsElement))
return null;

if (habitIdsElement.ValueKind != JsonValueKind.Array)
return new ToolResult(false, Error: "habit_ids must be an array.");

foreach (var item in habitIdsElement.EnumerateArray())
{
if (item.ValueKind != JsonValueKind.String || !Guid.TryParse(item.GetString(), out var habitId))
return new ToolResult(false, Error: "habit_ids must contain only valid GUID strings.");

habitIds.Add(habitId);
}

return null;
}
}
22 changes: 21 additions & 1 deletion src/Orbit.Application/Goals/Commands/CreateGoalCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,18 @@
string Unit,
DateOnly? Deadline,
int Position = 0,
GoalType Type = GoalType.Standard) : IRequest<Result<Guid>>, IIdempotentCommand;
GoalType Type = GoalType.Standard,
IReadOnlyList<Guid>? HabitIds = null) : IRequest<Result<Guid>>, IIdempotentCommand;

public partial class CreateGoalCommandHandler(

Check warning on line 23 in src/Orbit.Application/Goals/Commands/CreateGoalCommand.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

Constructor has 8 parameters, which is greater than the 7 authorized.
IGenericRepository<Goal> goalRepository,
IGenericRepository<Habit> habitRepository,
IPayGateService payGate,
IUserDateService userDateService,
IGamificationService gamificationService,
IUnitOfWork unitOfWork,
IMemoryCache cache,
ILogger<CreateGoalCommandHandler> logger) : IRequestHandler<CreateGoalCommand, Result<Guid>>

Check warning on line 31 in src/Orbit.Application/Goals/Commands/CreateGoalCommand.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=AZ_aCG9J9LZEQ4BjPmgv&open=AZ_aCG9J9LZEQ4BjPmgv&pullRequest=454
{
public async Task<Result<Guid>> Handle(CreateGoalCommand request, CancellationToken cancellationToken)
{
Expand All @@ -52,6 +54,24 @@
return goalResult.PropagateError<Guid>();

var goal = goalResult.Value;

if (request.HabitIds is { Count: > 0 } habitIds)
{
if (habitIds.Count > AppConstants.MaxHabitsPerGoal)
return Result.Failure<Guid>(ErrorMessages.MaxHabitsPerGoal.Format(AppConstants.MaxHabitsPerGoal));

var habits = await habitRepository.FindTrackedAsync(
h => habitIds.Contains(h.Id) && h.UserId == request.UserId,
cancellationToken);

var habitsResolved = OwnershipValidation.AllResolved(habitIds, habits, h => h.Id, ErrorMessages.HabitNotFound);
if (habitsResolved.IsFailure)
return habitsResolved.PropagateError<Guid>();

foreach (var habit in habits)
goal.AddHabit(habit);
}

await goalRepository.AddAsync(goal, cancellationToken);
await unitOfWork.SaveChangesAsync(cancellationToken);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,8 @@ public CreateGoalCommandValidator()
RuleFor(x => x.TargetValue).GreaterThan(0);
RuleFor(x => x.Unit).NotEmpty().MaximumLength(50);
RuleFor(x => x.Type).IsInEnum();
RuleFor(x => x.HabitIds)
.Must(ids => ids is null || ids.Count <= AppConstants.MaxHabitsPerGoal)
.WithMessage(ErrorMessages.MaxHabitsPerGoal.Format(AppConstants.MaxHabitsPerGoal).Message);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ public async Task CreateGoal_InvalidatesCachedGoalReview()
cache.Set(GoalReviewKey("en"), "stale review");

var goalRepo = Substitute.For<IGenericRepository<Goal>>();
var habitRepo = Substitute.For<IGenericRepository<Habit>>();
var payGate = Substitute.For<IPayGateService>();
var userDateService = Substitute.For<IUserDateService>();
var gamificationService = Substitute.For<IGamificationService>();
Expand All @@ -57,7 +58,7 @@ public async Task CreateGoal_InvalidatesCachedGoalReview()
.Returns(new DateOnly(2026, 7, 12));

var handler = new CreateGoalCommandHandler(
goalRepo, payGate, userDateService, gamificationService, unitOfWork, cache,
goalRepo, habitRepo, payGate, userDateService, gamificationService, unitOfWork, cache,
Substitute.For<ILogger<CreateGoalCommandHandler>>());

var result = await handler.Handle(
Expand Down
Loading
Loading