From f405614c19a0eebd4b74fb8e3132826e248fdde5 Mon Sep 17 00:00:00 2001 From: thomasluizon Date: Thu, 6 Aug 2026 23:02:32 -0300 Subject: [PATCH 1/4] chore: start ORB-213 From ce16aec3480f219d95b0bf01ca045279ad408288 Mon Sep 17 00:00:00 2001 From: thomasluizon Date: Thu, 6 Aug 2026 23:12:24 -0300 Subject: [PATCH 2/4] feat(goals): link habits during creation --- src/Orbit.Api/Controllers/GoalsController.cs | 4 +- src/Orbit.Api/openapi.json | 10 ++ .../Tools/Implementations/CreateGoalTool.cs | 49 +++++++- .../Goals/Commands/CreateGoalCommand.cs | 22 +++- .../Validators/CreateGoalCommandValidator.cs | 3 + .../Caching/GoalAiCacheInvalidationTests.cs | 3 +- .../Chat/Tools/CreateGoalToolTests.cs | 41 ++++++- .../Goals/CreateGoalCommandHandlerTests.cs | 115 +++++++++++++++++- 8 files changed, 236 insertions(+), 11 deletions(-) diff --git a/src/Orbit.Api/Controllers/GoalsController.cs b/src/Orbit.Api/Controllers/GoalsController.cs index c356c682..c6e277e7 100644 --- a/src/Orbit.Api/Controllers/GoalsController.cs +++ b/src/Orbit.Api/Controllers/GoalsController.cs @@ -16,7 +16,7 @@ namespace Orbit.Api.Controllers; [Route("api/[controller]")] public partial class GoalsController(IMediator mediator, ILogger 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? 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); @@ -57,7 +57,7 @@ public async Task GetGoalById(Guid id, CancellationToken cancella [ProducesResponseType(StatusCodes.Status403Forbidden)] public async Task 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 => { diff --git a/src/Orbit.Api/openapi.json b/src/Orbit.Api/openapi.json index baaa6ed1..559ad042 100644 --- a/src/Orbit.Api/openapi.json +++ b/src/Orbit.Api/openapi.json @@ -12880,6 +12880,16 @@ }, "type": { "$ref": "#/components/schemas/GoalType" + }, + "habitIds": { + "type": [ + "null", + "array" + ], + "items": { + "type": "string", + "format": "uuid" + } } } }, diff --git a/src/Orbit.Application/Chat/Tools/Implementations/CreateGoalTool.cs b/src/Orbit.Application/Chat/Tools/Implementations/CreateGoalTool.cs index 8afa4bf3..de3bbd87 100644 --- a/src/Orbit.Application/Chat/Tools/Implementations/CreateGoalTool.cs +++ b/src/Orbit.Application/Chat/Tools/Implementations/CreateGoalTool.cs @@ -1,5 +1,6 @@ using System.Globalization; using System.Text.Json; +using Orbit.Application.Common; using Orbit.Domain.Entities; using Orbit.Domain.Enums; using Orbit.Domain.Interfaces; @@ -8,10 +9,11 @@ namespace Orbit.Application.Chat.Tools.Implementations; public class CreateGoalTool( IGenericRepository goalRepository, - IUnitOfWork unitOfWork) : IAiTool + IUnitOfWork unitOfWork, + IGenericRepository? habitRepository = null) : IAiTool { 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 { @@ -23,7 +25,13 @@ public class CreateGoalTool( 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" } }; @@ -47,6 +55,21 @@ public async Task ExecuteAsync(JsonElement args, Guid userId, Cancel if (args.TryGetProperty("goal_type", out var goalTypeEl) && goalTypeEl.ValueKind == JsonValueKind.String) Enum.TryParse(goalTypeEl.GetString(), ignoreCase: true, out goalType); + var habitIds = new List(); + if (args.TryGetProperty("habit_ids", out var habitIdsEl) && habitIdsEl.ValueKind == JsonValueKind.Array) + { + foreach (var item in habitIdsEl.EnumerateArray()) + { + if (!Guid.TryParse(item.GetString(), out var habitId)) + return new ToolResult(false, Error: ErrorMessages.HabitNotFound.Message); + + habitIds.Add(habitId); + } + } + + 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, @@ -57,8 +80,24 @@ public async Task ExecuteAsync(JsonElement args, Guid userId, Cancel Type: goalType)); if (goalResult.IsFailure) return ToolResult.FromFailure(goalResult); - await goalRepository.AddAsync(goalResult.Value, ct); + var goal = goalResult.Value; + if (habitIds.Count > 0) + { + ArgumentNullException.ThrowIfNull(habitRepository); + 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: goalResult.Value.Id.ToString(), EntityName: goalResult.Value.Title); + return new ToolResult(true, EntityId: goal.Id.ToString(), EntityName: goal.Title); } } diff --git a/src/Orbit.Application/Goals/Commands/CreateGoalCommand.cs b/src/Orbit.Application/Goals/Commands/CreateGoalCommand.cs index 6fc3f20b..a72d43b9 100644 --- a/src/Orbit.Application/Goals/Commands/CreateGoalCommand.cs +++ b/src/Orbit.Application/Goals/Commands/CreateGoalCommand.cs @@ -17,10 +17,12 @@ public record CreateGoalCommand( string Unit, DateOnly? Deadline, int Position = 0, - GoalType Type = GoalType.Standard) : IRequest>, IIdempotentCommand; + GoalType Type = GoalType.Standard, + IReadOnlyList? HabitIds = null) : IRequest>, IIdempotentCommand; public partial class CreateGoalCommandHandler( IGenericRepository goalRepository, + IGenericRepository habitRepository, IPayGateService payGate, IUserDateService userDateService, IGamificationService gamificationService, @@ -52,6 +54,24 @@ public async Task> Handle(CreateGoalCommand request, CancellationTo return goalResult.PropagateError(); var goal = goalResult.Value; + + if (request.HabitIds is { Count: > 0 } habitIds) + { + if (habitIds.Count > AppConstants.MaxHabitsPerGoal) + return Result.Failure(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(); + + foreach (var habit in habits) + goal.AddHabit(habit); + } + await goalRepository.AddAsync(goal, cancellationToken); await unitOfWork.SaveChangesAsync(cancellationToken); diff --git a/src/Orbit.Application/Goals/Validators/CreateGoalCommandValidator.cs b/src/Orbit.Application/Goals/Validators/CreateGoalCommandValidator.cs index 23ed0fa9..ecae5596 100644 --- a/src/Orbit.Application/Goals/Validators/CreateGoalCommandValidator.cs +++ b/src/Orbit.Application/Goals/Validators/CreateGoalCommandValidator.cs @@ -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); } } diff --git a/tests/Orbit.Application.Tests/Caching/GoalAiCacheInvalidationTests.cs b/tests/Orbit.Application.Tests/Caching/GoalAiCacheInvalidationTests.cs index c26d4187..73e06ee1 100644 --- a/tests/Orbit.Application.Tests/Caching/GoalAiCacheInvalidationTests.cs +++ b/tests/Orbit.Application.Tests/Caching/GoalAiCacheInvalidationTests.cs @@ -47,6 +47,7 @@ public async Task CreateGoal_InvalidatesCachedGoalReview() cache.Set(GoalReviewKey("en"), "stale review"); var goalRepo = Substitute.For>(); + var habitRepo = Substitute.For>(); var payGate = Substitute.For(); var userDateService = Substitute.For(); var gamificationService = Substitute.For(); @@ -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>()); var result = await handler.Handle( diff --git a/tests/Orbit.Application.Tests/Chat/Tools/CreateGoalToolTests.cs b/tests/Orbit.Application.Tests/Chat/Tools/CreateGoalToolTests.cs index 6d5be59e..d7a6b671 100644 --- a/tests/Orbit.Application.Tests/Chat/Tools/CreateGoalToolTests.cs +++ b/tests/Orbit.Application.Tests/Chat/Tools/CreateGoalToolTests.cs @@ -3,7 +3,9 @@ using NSubstitute; using Orbit.Application.Chat.Tools; using Orbit.Application.Chat.Tools.Implementations; +using Orbit.Application.Common; using Orbit.Domain.Entities; +using Orbit.Domain.Enums; using Orbit.Domain.Interfaces; namespace Orbit.Application.Tests.Chat.Tools; @@ -11,6 +13,7 @@ namespace Orbit.Application.Tests.Chat.Tools; public class CreateGoalToolTests { private readonly IGenericRepository _goalRepo = Substitute.For>(); + private readonly IGenericRepository _habitRepo = Substitute.For>(); private readonly IUnitOfWork _unitOfWork = Substitute.For(); private readonly CreateGoalTool _tool; @@ -18,7 +21,7 @@ public class CreateGoalToolTests public CreateGoalToolTests() { - _tool = new CreateGoalTool(_goalRepo, _unitOfWork); + _tool = new CreateGoalTool(_goalRepo, _unitOfWork, _habitRepo); } [Fact] @@ -87,6 +90,42 @@ public async Task WithDescription_CreatesGoalWithDescription() result.EntityName.Should().Be("Save money"); } + [Fact] + public void ParameterSchema_ExposesOptionalHabitIds() + { + JsonSerializer.Serialize(_tool.GetParameterSchema()).Should().Contain("habit_ids"); + } + + [Fact] + public async Task CreateGoalTool_WithHabitIds_LinksThem() + { + var habit = Habit.Create(new HabitCreateParams(UserId, "Read", FrequencyUnit.Day, 1, DueDate: new DateOnly(2026, 8, 6))).Value; + _habitRepo.FindTrackedAsync(Arg.Any>>(), Arg.Any()) + .Returns([habit]); + + var result = await Execute($$$"""{"title":"Read daily","habit_ids":["{{{habit.Id}}}"]}"""); + + result.Success.Should().BeTrue(); + await _goalRepo.Received(1).AddAsync( + Arg.Is(goal => goal.Habits.Count == 1 && goal.Habits.Contains(habit)), + Arg.Any()); + await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task CreateGoalTool_WithForeignHabitId_FailsWithoutCreatingGoal() + { + _habitRepo.FindTrackedAsync(Arg.Any>>(), Arg.Any()) + .Returns([]); + + var result = await Execute($$$"""{"title":"Read daily","habit_ids":["{{{Guid.NewGuid()}}}"]}"""); + + result.Success.Should().BeFalse(); + result.Error.Should().Be(ErrorMessages.HabitNotFound.Message); + await _goalRepo.DidNotReceiveWithAnyArgs().AddAsync(default!, default); + await _unitOfWork.DidNotReceiveWithAnyArgs().SaveChangesAsync(default); + } + private async Task Execute(string json) { var args = JsonDocument.Parse(json).RootElement; diff --git a/tests/Orbit.Application.Tests/Commands/Goals/CreateGoalCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Goals/CreateGoalCommandHandlerTests.cs index 96ba72e3..142f9e45 100644 --- a/tests/Orbit.Application.Tests/Commands/Goals/CreateGoalCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Goals/CreateGoalCommandHandlerTests.cs @@ -1,12 +1,16 @@ using FluentAssertions; +using FluentValidation.TestHelper; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using NSubstitute; using NSubstitute.ExceptionExtensions; using Orbit.Application.Common; using Orbit.Application.Goals.Commands; +using Orbit.Application.Goals.Services; +using Orbit.Application.Goals.Validators; using Orbit.Domain.Common; using Orbit.Domain.Entities; +using Orbit.Domain.Enums; using Orbit.Domain.Interfaces; namespace Orbit.Application.Tests.Commands.Goals; @@ -14,6 +18,7 @@ namespace Orbit.Application.Tests.Commands.Goals; public class CreateGoalCommandHandlerTests { private readonly IGenericRepository _goalRepo = Substitute.For>(); + private readonly IGenericRepository _habitRepo = Substitute.For>(); private readonly IPayGateService _payGate = Substitute.For(); private readonly IUserDateService _userDateService = Substitute.For(); private readonly IGamificationService _gamificationService = Substitute.For(); @@ -27,7 +32,7 @@ public class CreateGoalCommandHandlerTests public CreateGoalCommandHandlerTests() { _handler = new CreateGoalCommandHandler( - _goalRepo, _payGate, _userDateService, _gamificationService, _unitOfWork, _cache, + _goalRepo, _habitRepo, _payGate, _userDateService, _gamificationService, _unitOfWork, _cache, Substitute.For>()); _payGate.CanAccessGoals(Arg.Any(), Arg.Any()) @@ -174,4 +179,112 @@ await _goalRepo.Received(1).AddAsync( Arg.Is(g => g.Deadline == Today), Arg.Any()); } + + [Fact] + public async Task CreateGoal_WithoutHabitIds_CreatesGoalWithNoLinks() + { + var command = new CreateGoalCommand(UserId, "Goal", null, 10, "units", null); + + var result = await _handler.Handle(command, CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + await _goalRepo.Received(1).AddAsync( + Arg.Is(goal => goal.Habits.Count == 0), + Arg.Any()); + await _habitRepo.DidNotReceiveWithAnyArgs().FindTrackedAsync(default!, default); + } + + [Fact] + public async Task CreateGoal_WithHabitIds_LinksAllOfThem() + { + var first = CreateHabit("First"); + var second = CreateHabit("Second"); + _habitRepo.FindTrackedAsync(Arg.Any>>(), Arg.Any()) + .Returns([first, second]); + var command = new CreateGoalCommand(UserId, "Goal", null, 10, "units", null, HabitIds: [first.Id, second.Id]); + + var result = await _handler.Handle(command, CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + await _goalRepo.Received(1).AddAsync( + Arg.Is(goal => goal.Habits.Count == 2 && goal.Habits.Contains(first) && goal.Habits.Contains(second)), + Arg.Any()); + await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task CreateGoal_WithForeignHabitId_FailsAndCreatesNoGoal() + { + _habitRepo.FindTrackedAsync(Arg.Any>>(), Arg.Any()) + .Returns([]); + var command = new CreateGoalCommand(UserId, "Goal", null, 10, "units", null, HabitIds: [Guid.NewGuid()]); + + var result = await _handler.Handle(command, CancellationToken.None); + + result.IsFailure.Should().BeTrue(); + result.ErrorCode.Should().Be(ErrorCodes.HabitNotFound); + await _goalRepo.DidNotReceiveWithAnyArgs().AddAsync(default!, default); + await _unitOfWork.DidNotReceiveWithAnyArgs().SaveChangesAsync(default); + } + + [Fact] + public async Task CreateGoal_WithTooManyHabitIds_FailsAndCreatesNoGoal() + { + var habitIds = Enumerable.Range(0, AppConstants.MaxHabitsPerGoal + 1).Select(_ => Guid.NewGuid()).ToList(); + var command = new CreateGoalCommand(UserId, "Goal", null, 10, "units", null, HabitIds: habitIds); + + var result = await _handler.Handle(command, CancellationToken.None); + + result.IsFailure.Should().BeTrue(); + result.ErrorCode.Should().Be(ErrorCodes.MaxHabitsPerGoal); + await _goalRepo.DidNotReceiveWithAnyArgs().AddAsync(default!, default); + await _unitOfWork.DidNotReceiveWithAnyArgs().SaveChangesAsync(default); + } + + [Fact] + public async Task CreateGoal_WithEmptyHabitIdList_BehavesAsAbsent() + { + var command = new CreateGoalCommand(UserId, "Goal", null, 10, "units", null, HabitIds: []); + + var result = await _handler.Handle(command, CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + await _goalRepo.Received(1).AddAsync( + Arg.Is(goal => goal.Habits.Count == 0), + Arg.Any()); + await _habitRepo.DidNotReceiveWithAnyArgs().FindTrackedAsync(default!, default); + } + + [Fact] + public async Task CreateGoal_StreakGoalWithHabit_UsesExistingPassiveSyncPath() + { + var habit = CreateHabit("Daily habit"); + _habitRepo.FindTrackedAsync(Arg.Any>>(), Arg.Any()) + .Returns([habit]); + Goal? createdGoal = null; + _goalRepo.AddAsync(Arg.Do(goal => createdGoal = goal), Arg.Any()) + .Returns(Task.CompletedTask); + var command = new CreateGoalCommand(UserId, "Streak", null, 7, "days", null, Type: GoalType.Streak, HabitIds: [habit.Id]); + + var result = await _handler.Handle(command, CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + createdGoal.Should().NotBeNull(); + createdGoal!.CurrentValue.Should().Be(GoalStreakSyncService.CalculateCurrentStreak(createdGoal, Today)); + GoalStreakSyncService.NeedsPassiveSync(createdGoal, Today).Should().BeTrue(); + } + + private static Habit CreateHabit(string title) => + Habit.Create(new HabitCreateParams(UserId, title, FrequencyUnit.Day, 1, DueDate: Today)).Value; + + [Fact] + public void Validate_HabitIdsOverLimit_HasError() + { + var habitIds = Enumerable.Range(0, AppConstants.MaxHabitsPerGoal + 1).Select(_ => Guid.NewGuid()).ToList(); + var command = new CreateGoalCommand(UserId, "Goal", null, 10, "units", null, HabitIds: habitIds); + + var result = new CreateGoalCommandValidator().TestValidate(command); + + result.ShouldHaveValidationErrorFor(x => x.HabitIds); + } } From 50dc37d513d1bc88e9f4ce4cb60354bdc7c32544 Mon Sep 17 00:00:00 2001 From: thomasluizon Date: Thu, 6 Aug 2026 23:39:46 -0300 Subject: [PATCH 3/4] chore(goals): regenerate the architecture map and move habit-link tests The drift check failed because architecture.html and architecture.json were not regenerated after CreateGoalCommand gained HabitIds. Habit-linking assertions move from CreateGoalToolTests to CreateGoalCommandHandlerTests, where the linking actually happens. Orbit.Application.Tests: 2984 passed, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) --- architecture.html | 2 +- architecture.json | 22 ++++++-- .../Chat/Tools/CreateGoalToolTests.cs | 41 +------------- .../Goals/CreateGoalCommandHandlerTests.cs | 55 +++++++++++++++++++ 4 files changed, 74 insertions(+), 46 deletions(-) diff --git a/architecture.html b/architecture.html index 2033cc4b..267761b6 100644 --- a/architecture.html +++ b/architecture.html @@ -47,7 +47,7 @@

Handlers with no endpoint

RequestHandler file

Entities

EntityDomain file
- +