diff --git a/src/Orbit.Api/Controllers/HabitsController.cs b/src/Orbit.Api/Controllers/HabitsController.cs index f51afbb8..27c17953 100644 --- a/src/Orbit.Api/Controllers/HabitsController.cs +++ b/src/Orbit.Api/Controllers/HabitsController.cs @@ -4,9 +4,7 @@ using Orbit.Api.Extensions; using Orbit.Application.Habits.Commands; using Orbit.Application.Habits.Queries; -using Orbit.Domain.Enums; using Orbit.Domain.Interfaces; -using Orbit.Domain.ValueObjects; #pragma warning disable CA1873 @@ -17,132 +15,6 @@ namespace Orbit.Api.Controllers; [Route("api/[controller]")] public partial class HabitsController(IMediator mediator, ILogger logger, IUserDateService userDateService) : ControllerBase { - public record CreateHabitRequest( - string Title, - string? Description, - FrequencyUnit? FrequencyUnit, - int? FrequencyQuantity, - IReadOnlyList? Days = null, - bool IsBadHabit = false, - IReadOnlyList? SubHabits = null, - DateOnly? DueDate = null, - TimeOnly? DueTime = null, - TimeOnly? DueEndTime = null, - bool ReminderEnabled = false, - IReadOnlyList? ReminderTimes = null, - IReadOnlyList? ScheduledReminders = null, - bool SlipAlertEnabled = false, - IReadOnlyList? TagIds = null, - IReadOnlyList? ChecklistItems = null, - bool IsGeneral = false, - DateOnly? EndDate = null, - bool IsFlexible = false, - IReadOnlyList? GoalIds = null, - string? Emoji = null); - - public record UpdateHabitRequest( - string Title, - string? Description, - FrequencyUnit? FrequencyUnit, - int? FrequencyQuantity, - IReadOnlyList? Days = null, - bool IsBadHabit = false, - DateOnly? DueDate = null, - TimeOnly? DueTime = null, - TimeOnly? DueEndTime = null, - bool? ReminderEnabled = null, - IReadOnlyList? ReminderTimes = null, - IReadOnlyList? ScheduledReminders = null, - bool? SlipAlertEnabled = null, - IReadOnlyList? ChecklistItems = null, - bool? IsGeneral = null, - DateOnly? EndDate = null, - bool? ClearEndDate = null, - bool? IsFlexible = null, - IReadOnlyList? GoalIds = null, - string? Emoji = null); - - public record UpdateChecklistRequest(IReadOnlyList ChecklistItems); - - public record LogHabitRequest(DateOnly? Date = null); - - public record SkipHabitRequest(DateOnly? Date = null); - - public record BulkCreateHabitsRequest( - IReadOnlyList Habits, - bool FromSyncReview = false); - - public record BulkHabitItemRequest( - string Title, - string? Description, - FrequencyUnit? FrequencyUnit, - int? FrequencyQuantity, - IReadOnlyList? Days = null, - bool IsBadHabit = false, - DateOnly? DueDate = null, - TimeOnly? DueTime = null, - TimeOnly? DueEndTime = null, - bool ReminderEnabled = false, - IReadOnlyList? ReminderTimes = null, - IReadOnlyList? ScheduledReminders = null, - IReadOnlyList? SubHabits = null, - bool IsGeneral = false, - DateOnly? EndDate = null, - bool IsFlexible = false, - IReadOnlyList? ChecklistItems = null, - string? GoogleEventId = null, - string? Emoji = null); - - public record BulkDeleteHabitsRequest(IReadOnlyList HabitIds); - - public record BulkLogHabitItem(Guid HabitId, DateOnly? Date = null); - public record BulkLogHabitsRequest(IReadOnlyList Items); - - public record BulkSkipHabitItem(Guid HabitId, DateOnly? Date = null); - public record BulkSkipHabitsRequest(IReadOnlyList Items); - - public record ReorderHabitsRequest(IReadOnlyList Positions); - - public record HabitPositionRequest(Guid HabitId, int Position); - - public record MoveHabitParentRequest(Guid? ParentId); - - public record GetHabitsFilterRequest - { - public DateOnly? DateFrom { get; init; } - public DateOnly? DateTo { get; init; } - public bool? IncludeOverdue { get; init; } - public string? Search { get; init; } - public string? FrequencyUnit { get; init; } - public bool? IsCompleted { get; init; } - public Guid[]? TagIds { get; init; } - public bool? IsGeneral { get; init; } - public int Page { get; init; } = 1; - public int PageSize { get; init; } = 50; - public bool? IncludeGeneral { get; init; } - } - - public record CreateSubHabitRequest( - string Title, - string? Description = null, - FrequencyUnit? FrequencyUnit = null, - int? FrequencyQuantity = null, - IReadOnlyList? Days = null, - bool IsBadHabit = false, - DateOnly? DueDate = null, - TimeOnly? DueTime = null, - TimeOnly? DueEndTime = null, - bool ReminderEnabled = false, - IReadOnlyList? ReminderTimes = null, - IReadOnlyList? ScheduledReminders = null, - bool SlipAlertEnabled = false, - IReadOnlyList? ChecklistItems = null, - IReadOnlyList? TagIds = null, - DateOnly? EndDate = null, - bool IsFlexible = false, - string? Emoji = null); - public record LinkGoalsRequest(List GoalIds); - [HttpGet("count")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] diff --git a/src/Orbit.Api/Controllers/HabitsControllerRequests.cs b/src/Orbit.Api/Controllers/HabitsControllerRequests.cs new file mode 100644 index 00000000..ced81a06 --- /dev/null +++ b/src/Orbit.Api/Controllers/HabitsControllerRequests.cs @@ -0,0 +1,134 @@ +using Orbit.Domain.Enums; +using Orbit.Domain.ValueObjects; + +namespace Orbit.Api.Controllers; + +public partial class HabitsController +{ + public record CreateHabitRequest( + string Title, + string? Description, + FrequencyUnit? FrequencyUnit, + int? FrequencyQuantity, + IReadOnlyList? Days = null, + bool IsBadHabit = false, + IReadOnlyList? SubHabits = null, + DateOnly? DueDate = null, + TimeOnly? DueTime = null, + TimeOnly? DueEndTime = null, + bool ReminderEnabled = false, + IReadOnlyList? ReminderTimes = null, + IReadOnlyList? ScheduledReminders = null, + bool SlipAlertEnabled = false, + IReadOnlyList? TagIds = null, + IReadOnlyList? ChecklistItems = null, + bool IsGeneral = false, + DateOnly? EndDate = null, + bool IsFlexible = false, + IReadOnlyList? GoalIds = null, + string? Emoji = null); + + public record UpdateHabitRequest( + string Title, + string? Description, + FrequencyUnit? FrequencyUnit, + int? FrequencyQuantity, + IReadOnlyList? Days = null, + bool IsBadHabit = false, + DateOnly? DueDate = null, + TimeOnly? DueTime = null, + TimeOnly? DueEndTime = null, + bool? ReminderEnabled = null, + IReadOnlyList? ReminderTimes = null, + IReadOnlyList? ScheduledReminders = null, + bool? SlipAlertEnabled = null, + IReadOnlyList? ChecklistItems = null, + bool? IsGeneral = null, + DateOnly? EndDate = null, + bool? ClearEndDate = null, + bool? IsFlexible = null, + IReadOnlyList? GoalIds = null, + string? Emoji = null); + + public record UpdateChecklistRequest(IReadOnlyList ChecklistItems); + + public record LogHabitRequest(DateOnly? Date = null); + + public record SkipHabitRequest(DateOnly? Date = null); + + public record BulkCreateHabitsRequest( + IReadOnlyList Habits, + bool FromSyncReview = false); + + public record BulkHabitItemRequest( + string Title, + string? Description, + FrequencyUnit? FrequencyUnit, + int? FrequencyQuantity, + IReadOnlyList? Days = null, + bool IsBadHabit = false, + DateOnly? DueDate = null, + TimeOnly? DueTime = null, + TimeOnly? DueEndTime = null, + bool ReminderEnabled = false, + IReadOnlyList? ReminderTimes = null, + IReadOnlyList? ScheduledReminders = null, + IReadOnlyList? SubHabits = null, + bool IsGeneral = false, + DateOnly? EndDate = null, + bool IsFlexible = false, + IReadOnlyList? ChecklistItems = null, + string? GoogleEventId = null, + string? Emoji = null); + + public record BulkDeleteHabitsRequest(IReadOnlyList HabitIds); + + public record BulkLogHabitItem(Guid HabitId, DateOnly? Date = null); + public record BulkLogHabitsRequest(IReadOnlyList Items); + + public record BulkSkipHabitItem(Guid HabitId, DateOnly? Date = null); + public record BulkSkipHabitsRequest(IReadOnlyList Items); + + public record ReorderHabitsRequest(IReadOnlyList Positions); + + public record HabitPositionRequest(Guid HabitId, int Position); + + public record MoveHabitParentRequest(Guid? ParentId); + + public record GetHabitsFilterRequest + { + public DateOnly? DateFrom { get; init; } + public DateOnly? DateTo { get; init; } + public bool? IncludeOverdue { get; init; } + public string? Search { get; init; } + public string? FrequencyUnit { get; init; } + public bool? IsCompleted { get; init; } + public Guid[]? TagIds { get; init; } + public bool? IsGeneral { get; init; } + public int Page { get; init; } = 1; + public int PageSize { get; init; } = 50; + public bool? IncludeGeneral { get; init; } + } + + public record CreateSubHabitRequest( + string Title, + string? Description = null, + FrequencyUnit? FrequencyUnit = null, + int? FrequencyQuantity = null, + IReadOnlyList? Days = null, + bool IsBadHabit = false, + DateOnly? DueDate = null, + TimeOnly? DueTime = null, + TimeOnly? DueEndTime = null, + bool ReminderEnabled = false, + IReadOnlyList? ReminderTimes = null, + IReadOnlyList? ScheduledReminders = null, + bool SlipAlertEnabled = false, + IReadOnlyList? ChecklistItems = null, + IReadOnlyList? TagIds = null, + DateOnly? EndDate = null, + bool IsFlexible = false, + string? Emoji = null); + + public record LinkGoalsRequest(List GoalIds); +} diff --git a/src/Orbit.Api/Controllers/SyncController.cs b/src/Orbit.Api/Controllers/SyncController.cs index b52bbf37..ca851d9d 100644 --- a/src/Orbit.Api/Controllers/SyncController.cs +++ b/src/Orbit.Api/Controllers/SyncController.cs @@ -311,83 +311,6 @@ await strategy.ExecuteAsync(async () => } } - private async Task ProcessMutation(Guid userId, SyncMutation mutation, CancellationToken ct) - { - switch (mutation.Entity.ToLowerInvariant()) - { - case "habit": - await ProcessHabitMutation(userId, mutation, ct); - break; - case "goal": - await ProcessGoalMutation(userId, mutation, ct); - break; - case "tag": - await ProcessTagMutation(userId, mutation, ct); - break; - case "notification": - await ProcessNotificationMutation(userId, mutation, ct); - break; - default: - throw new InvalidOperationException($"Unknown entity type: {mutation.Entity}"); - } - } - - private async Task ProcessHabitMutation(Guid userId, SyncMutation mutation, CancellationToken ct) - { - switch (mutation.Action.ToLowerInvariant()) - { - case "delete": - if (mutation.Id is null) throw new InvalidOperationException("Id is required for delete."); - var habit = await dbContext.Habits.FirstOrDefaultAsync(h => h.Id == mutation.Id && h.UserId == userId, ct); - if (habit is not null) habit.SoftDelete(); - break; - default: - throw new InvalidOperationException($"Unsupported action: {mutation.Action} for habit."); - } - } - - private async Task ProcessGoalMutation(Guid userId, SyncMutation mutation, CancellationToken ct) - { - switch (mutation.Action.ToLowerInvariant()) - { - case "delete": - if (mutation.Id is null) throw new InvalidOperationException("Id is required for delete."); - var goal = await dbContext.Goals.FirstOrDefaultAsync(g => g.Id == mutation.Id && g.UserId == userId, ct); - if (goal is not null) goal.SoftDelete(); - break; - default: - throw new InvalidOperationException($"Unsupported action: {mutation.Action} for goal."); - } - } - - private async Task ProcessTagMutation(Guid userId, SyncMutation mutation, CancellationToken ct) - { - switch (mutation.Action.ToLowerInvariant()) - { - case "delete": - if (mutation.Id is null) throw new InvalidOperationException("Id is required for delete."); - var tag = await dbContext.Tags.FirstOrDefaultAsync(t => t.Id == mutation.Id && t.UserId == userId, ct); - if (tag is not null) tag.SoftDelete(); - break; - default: - throw new InvalidOperationException($"Unsupported action: {mutation.Action} for tag."); - } - } - - private async Task ProcessNotificationMutation(Guid userId, SyncMutation mutation, CancellationToken ct) - { - switch (mutation.Action.ToLowerInvariant()) - { - case "read": - if (mutation.Id is null) throw new InvalidOperationException("Id is required for read."); - var notification = await dbContext.Notifications.FirstOrDefaultAsync(n => n.Id == mutation.Id && n.UserId == userId, ct); - if (notification is not null) notification.MarkAsRead(); - break; - default: - throw new InvalidOperationException($"Unsupported action: {mutation.Action} for notification."); - } - } - private static SyncHabitDto MapHabit(Habit habit) { return new SyncHabitDto( diff --git a/src/Orbit.Api/Controllers/SyncControllerMutations.cs b/src/Orbit.Api/Controllers/SyncControllerMutations.cs new file mode 100644 index 00000000..7453c773 --- /dev/null +++ b/src/Orbit.Api/Controllers/SyncControllerMutations.cs @@ -0,0 +1,60 @@ +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore; +using Orbit.Domain.Entities; + +namespace Orbit.Api.Controllers; + +public partial class SyncController +{ + private async Task ProcessMutation(Guid userId, SyncMutation mutation, CancellationToken ct) + { + switch (mutation.Entity.ToLowerInvariant()) + { + case "habit": + await ApplyEntityMutationAsync( + mutation, "habit", "delete", dbContext.Habits, + h => h.Id == mutation.Id && h.UserId == userId, + h => h.SoftDelete(), ct); + break; + case "goal": + await ApplyEntityMutationAsync( + mutation, "goal", "delete", dbContext.Goals, + g => g.Id == mutation.Id && g.UserId == userId, + g => g.SoftDelete(), ct); + break; + case "tag": + await ApplyEntityMutationAsync( + mutation, "tag", "delete", dbContext.Tags, + t => t.Id == mutation.Id && t.UserId == userId, + t => t.SoftDelete(), ct); + break; + case "notification": + await ApplyEntityMutationAsync( + mutation, "notification", "read", dbContext.Notifications, + n => n.Id == mutation.Id && n.UserId == userId, + n => n.MarkAsRead(), ct); + break; + default: + throw new InvalidOperationException($"Unknown entity type: {mutation.Entity}"); + } + } + + private async Task ApplyEntityMutationAsync( + SyncMutation mutation, + string entityNoun, + string supportedAction, + DbSet set, + Expression> ownedById, + Action mutate, + CancellationToken ct) where TEntity : class + { + if (mutation.Action.ToLowerInvariant() != supportedAction) + throw new InvalidOperationException($"Unsupported action: {mutation.Action} for {entityNoun}."); + + if (mutation.Id is null) + throw new InvalidOperationException($"Id is required for {supportedAction}."); + + var entity = await set.FirstOrDefaultAsync(ownedById, ct); + if (entity is not null) mutate(entity); + } +} diff --git a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.AiServices.cs b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.AiServices.cs new file mode 100644 index 00000000..541b34e1 --- /dev/null +++ b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.AiServices.cs @@ -0,0 +1,175 @@ +using Orbit.Application.Chat.FeatureExplanations; +using Orbit.Application.Chat.Tools; +using Orbit.Application.Chat.Tools.Implementations; +using Orbit.Application.Goals.Services; +using Orbit.Domain.Interfaces; +using Orbit.Infrastructure.AI; +using Orbit.Infrastructure.Configuration; +using Orbit.Infrastructure.Services; + +namespace Orbit.Api.Extensions; + +public static partial class ServiceCollectionExtensions +{ + private static void AddAiPlatformServices(WebApplicationBuilder builder) + { + builder.Services.Configure( + builder.Configuration.GetSection(AiSettings.SectionName)); + builder.Services.Configure( + builder.Configuration.GetSection(AgentPlatformSettings.SectionName)); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + } + + private static void AddAiChatTools(WebApplicationBuilder builder) + { + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + } + + private static void AddHabitCommandDependencies(WebApplicationBuilder builder) + { + builder.Services.AddScoped(sp => + new Orbit.Application.Habits.Commands.LogHabitRepositories( + sp.GetRequiredService>(), + sp.GetRequiredService>(), + sp.GetRequiredService>(), + sp.GetRequiredService>())); + builder.Services.AddScoped(sp => + new Orbit.Application.Habits.Commands.LogHabitServices( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService())); + builder.Services.AddScoped(sp => + new Orbit.Application.Habits.Commands.BulkLogServices( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService())); + + builder.Services.AddScoped(sp => + new Orbit.Application.Habits.Commands.CreateHabitRepositories( + sp.GetRequiredService>(), + sp.GetRequiredService>(), + sp.GetRequiredService>())); + } + + private static void AddCalendarCommandDependencies(WebApplicationBuilder builder) + { + builder.Services.AddScoped(sp => + new Orbit.Application.Calendar.Commands.CalendarAutoSyncDependencies( + sp.GetRequiredService>(), + sp.GetRequiredService>(), + sp.GetRequiredService>(), + sp.GetRequiredService>(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService())); + } + + private static void AddChatCommandDependencies(WebApplicationBuilder builder) + { + builder.Services.AddScoped(sp => + new Orbit.Application.Chat.Commands.ChatAiDependencies( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService())); + builder.Services.AddScoped(sp => + new Orbit.Application.Chat.Commands.ChatDataDependencies( + sp.GetRequiredService>(), + sp.GetRequiredService>(), + sp.GetRequiredService>(), + sp.GetRequiredService>(), + sp.GetRequiredService>(), + sp.GetRequiredService>(), + sp.GetRequiredService())); + builder.Services.AddScoped(sp => + new Orbit.Application.Chat.Commands.ChatExecutionDependencies( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService())); + } +} diff --git a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.BackgroundJobs.cs b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.BackgroundJobs.cs new file mode 100644 index 00000000..58f74ab9 --- /dev/null +++ b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.BackgroundJobs.cs @@ -0,0 +1,86 @@ +using Hangfire; +using Hangfire.PostgreSql; +using Orbit.Domain.Interfaces; +using Orbit.Infrastructure.BackgroundJobs; +using Orbit.Infrastructure.Configuration; +using Orbit.Infrastructure.Persistence; +using Orbit.Infrastructure.Services; + +namespace Orbit.Api.Extensions; + +public static partial class ServiceCollectionExtensions +{ + private static void AddBackgroundServices(WebApplicationBuilder builder) + { + builder.Services.AddScoped(); + + var useDurableQueue = builder.Configuration.GetSection(BackgroundJobSettings.SectionName) + .Get()?.UseDurableQueue ?? false; + + builder.Services.AddHostedService(); + + if (useDurableQueue) + AddDurableRecurringJobs(builder); + else + AddInProcessSchedulers(builder); + + builder.Services.AddHealthChecks() + .AddCheck("background-services"); + } + + private static void AddInProcessSchedulers(WebApplicationBuilder builder) + { + builder.Services.AddHostedService(); + builder.Services.AddHostedService(); + builder.Services.AddHostedService(); + builder.Services.AddHostedService(); + builder.Services.AddHostedService(); + builder.Services.AddHostedService(); + builder.Services.AddHostedService(); + builder.Services.AddHostedService(); + builder.Services.AddHostedService(); + builder.Services.AddHostedService(); + builder.Services.AddHostedService(); + } + + private static void AddDurableRecurringJobs(WebApplicationBuilder builder) + { + var connectionString = OrbitConnectionStringFactory.ForSession(builder.Configuration); + if (string.IsNullOrWhiteSpace(connectionString)) + throw new InvalidOperationException( + $"{BackgroundJobSettings.SectionName}:UseDurableQueue is true but no database connection string is configured."); + + builder.Services.AddHangfire(config => config + .SetDataCompatibilityLevel(CompatibilityLevel.Version_180) + .UseSimpleAssemblyNameTypeSerializer() + .UseRecommendedSerializerSettings() + .UsePostgreSqlStorage(postgres => postgres.UseNpgsqlConnection(connectionString))); + builder.Services.AddHangfireServer(options => + { + options.WorkerCount = 2; + options.SchedulePollingInterval = TimeSpan.FromMinutes(1); + }); + + builder.Services.AddSingleton(); + AddScheduledJob(builder); + AddScheduledJob(builder); + AddScheduledJob(builder); + AddScheduledJob(builder); + AddScheduledJob(builder); + AddScheduledJob(builder); + AddScheduledJob(builder); + AddScheduledJob(builder); + AddScheduledJob(builder); + AddScheduledJob(builder); + AddScheduledJob(builder); + + builder.Services.AddHostedService(); + } + + private static void AddScheduledJob(WebApplicationBuilder builder) + where TJob : class, IScheduledJob + { + builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); + } +} diff --git a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.Infrastructure.cs b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.Infrastructure.cs new file mode 100644 index 00000000..2f93268e --- /dev/null +++ b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.Infrastructure.cs @@ -0,0 +1,248 @@ +using Microsoft.Extensions.Options; +using Orbit.Api.Mcp.Tools; +using Orbit.Api.Middleware; +using Orbit.Api.OpenApi; +using Orbit.Application.Common; +using Orbit.Domain.Interfaces; +using Orbit.Infrastructure.Configuration; +using Orbit.Infrastructure.Services; + +namespace Orbit.Api.Extensions; + +public static partial class ServiceCollectionExtensions +{ + private static void AddEmailAndSupabaseClients(WebApplicationBuilder builder, TimeSpan httpTimeout) + { + builder.Services.AddHttpClient("Supabase", client => + { + client.BaseAddress = new Uri(builder.Configuration["Supabase:Url"]!); + client.DefaultRequestHeaders.Add("apikey", builder.Configuration["Supabase:AnonKey"]!); + client.Timeout = httpTimeout; + }); + + builder.Services.Configure( + builder.Configuration.GetSection(SupabaseStorageSettings.SectionName)); + + builder.Services.AddHttpClient(SupabaseObjectStorageService.HttpClientName, client => + { + // Secret keys use the apikey header only — on Authorization: Bearer the gateway parses them as a JWT and rejects the request: https://supabase.com/docs/guides/getting-started/migrating-to-new-api-keys + var secretKey = builder.Configuration["Supabase:SecretKey"]!; + client.BaseAddress = new Uri(builder.Configuration["Supabase:Url"]!); + client.DefaultRequestHeaders.Add("apikey", secretKey); + client.Timeout = httpTimeout; + }); + + builder.Services.AddScoped(); + + builder.Services.Configure( + builder.Configuration.GetSection(ResendSettings.SectionName)); + +#pragma warning disable S1075 // Resend API base URL is a stable, well-known endpoint + builder.Services.AddHttpClient("Resend", client => + { + client.BaseAddress = new Uri("https://api.resend.com"); +#pragma warning restore S1075 + client.DefaultRequestHeaders.Add("Authorization", $"Bearer {builder.Configuration["Resend:ApiKey"]}"); + client.Timeout = httpTimeout; + }); + + builder.Services.AddScoped(); + } + + private static void AddStripeBilling(WebApplicationBuilder builder) + { + builder.Services.Configure( + builder.Configuration.GetSection(StripeSettings.SectionName)); + var stripeKey = builder.Configuration.GetSection(StripeSettings.SectionName).Get()?.SecretKey; + if (!string.IsNullOrEmpty(stripeKey)) + { + Stripe.StripeConfiguration.ApiKey = stripeKey; + } + + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + } + + private static void AddGooglePlayBilling(WebApplicationBuilder builder) + { + builder.Services.Configure( + builder.Configuration.GetSection(GooglePlaySettings.SectionName)); + builder.Services.AddSingleton(sp => + { + var googlePlaySettings = sp.GetRequiredService>().Value; + var credential = Google.Apis.Auth.OAuth2.CredentialFactory + .FromJson(googlePlaySettings.ServiceAccountJson) + .ToGoogleCredential() + .CreateScoped(Google.Apis.AndroidPublisher.v3.AndroidPublisherService.Scope.Androidpublisher); + return new Google.Apis.AndroidPublisher.v3.AndroidPublisherService( + new Google.Apis.Services.BaseClientService.Initializer + { + HttpClientInitializer = credential, + ApplicationName = "Orbit", + }); + }); + builder.Services.AddScoped(); + builder.Services.AddSingleton(); + builder.Services.AddScoped(); + } + + private static void AddPushAndReferralServices(WebApplicationBuilder builder, TimeSpan httpTimeout) + { + builder.Services.Configure( + builder.Configuration.GetSection(VapidSettings.SectionName)); + builder.Services.AddHttpClient() + .ConfigureHttpClient(c => c.Timeout = httpTimeout); + builder.Services.AddScoped(); + builder.Services.AddScoped(sp => + new Orbit.Application.Referrals.Commands.ReferralRepositories( + sp.GetRequiredService>(), + sp.GetRequiredService>(), + sp.GetRequiredService>(), + sp.GetRequiredService>(), + sp.GetRequiredService>())); + } + + private static void AddOrbitDistributedCache(WebApplicationBuilder builder) + { + var redisSettings = builder.Configuration.GetSection(RedisCacheSettings.SectionName).Get() + ?? new RedisCacheSettings(); + + if (!redisSettings.Enabled) + { + builder.Services.AddDistributedMemoryCache(); + return; + } + + if (string.IsNullOrWhiteSpace(redisSettings.ConnectionString)) + throw new InvalidOperationException( + $"{RedisCacheSettings.SectionName}:Enabled is true but {RedisCacheSettings.SectionName}:ConnectionString is not configured."); + + builder.Services.AddStackExchangeRedisCache(options => + { + options.Configuration = redisSettings.ConnectionString; + options.InstanceName = redisSettings.InstanceName; + }); + } + + private static void AddCorsPolicies(WebApplicationBuilder builder) + { + var firstPartyOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get() + ?? ["http://localhost:3000"]; + var thirdPartyOrigins = builder.Configuration.GetSection("Cors:ThirdPartyOrigins").Get() + ?? []; + builder.Services.AddCors(options => + { + options.AddDefaultPolicy(policy => + { + policy.WithOrigins(firstPartyOrigins) + .WithHeaders("Authorization", "Content-Type", "Mcp-Session-Id") + .WithMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS") + .AllowCredentials(); + }); + if (thirdPartyOrigins.Length > 0) + { + options.AddPolicy("ThirdParty", policy => + { + policy.WithOrigins(thirdPartyOrigins) + .WithHeaders("Authorization", "Content-Type", "Mcp-Session-Id") + .WithMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"); + }); + } + }); + } + + private static void AddCookieAndKestrelLimits(WebApplicationBuilder builder) + { + builder.Services.Configure(options => + { + options.HttpOnly = Microsoft.AspNetCore.CookiePolicy.HttpOnlyPolicy.Always; + options.Secure = CookieSecurePolicy.Always; + options.MinimumSameSitePolicy = SameSiteMode.Strict; + }); + + builder.WebHost.ConfigureKestrel(options => + { + options.Limits.MaxRequestBodySize = 10 * 1024 * 1024; + }); + } + + private static void AddMcpToolServer(WebApplicationBuilder builder) + { + builder.Services.AddMcpServer() + .WithHttpTransport() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools(); + } + + private static void AddApiPipeline(WebApplicationBuilder builder) + { + builder.Services.AddControllers() + .AddJsonOptions(options => + { + options.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter()); + }); + + builder.Services.AddExceptionHandler(); + builder.Services.AddExceptionHandler(); + builder.Services.AddExceptionHandler(); + builder.Services.AddProblemDetails(options => + { + options.CustomizeProblemDetails = ctx => + { + ctx.ProblemDetails.Extensions.Remove("exception"); + ctx.ProblemDetails.Detail = null; + }; + }); + + builder.Services.AddOpenApi(options => + { + options.AddDocumentTransformer(); + }); + } + + private static void InitializeFirebase(ConfigurationManager configuration) + { + var firebaseCredJson = configuration["Firebase:CredentialsJson"]; + if (!string.IsNullOrEmpty(firebaseCredJson)) + { + FirebaseAdmin.FirebaseApp.Create(new FirebaseAdmin.AppOptions + { + Credential = Google.Apis.Auth.OAuth2.CredentialFactory + .FromJson(firebaseCredJson) + .ToGoogleCredential() + }); + return; + } + + var firebaseCredPath = configuration["Firebase:CredentialsPath"]; + if (!string.IsNullOrEmpty(firebaseCredPath) && File.Exists(firebaseCredPath)) + { + FirebaseAdmin.FirebaseApp.Create(new FirebaseAdmin.AppOptions + { + Credential = Google.Apis.Auth.OAuth2.CredentialFactory + .FromFile(firebaseCredPath) + .ToGoogleCredential() + }); + } + } +} diff --git a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs index 0191cc78..d664402a 100644 --- a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs +++ b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs @@ -1,40 +1,27 @@ using System.Text; using FluentValidation; -using Hangfire; -using Hangfire.PostgreSql; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using Sentry; using Sentry.AspNetCore; using Orbit.Api.Authentication; -using Orbit.Api.Mcp.Tools; -using Orbit.Api.Middleware; using Orbit.Api.OAuth; -using Orbit.Api.OpenApi; -using Orbit.Api.RateLimiting; using Orbit.Application.Behaviors; -using Orbit.Application.Chat.FeatureExplanations; -using Orbit.Application.Chat.Tools; -using Orbit.Application.Chat.Tools.Implementations; using Orbit.Application.Common; using Orbit.Application.Gamification.Services; using Orbit.Application.Goals.Services; using Orbit.Application.Habits.Validators; using Orbit.Domain.Interfaces; -using Orbit.Infrastructure.AI; -using Orbit.Infrastructure.BackgroundJobs; using Orbit.Infrastructure.Configuration; using Orbit.Infrastructure.Persistence; using Orbit.Infrastructure.Services; using Orbit.Infrastructure.Services.Calendar; -using Scalar.AspNetCore; namespace Orbit.Api.Extensions; -public static class ServiceCollectionExtensions +public static partial class ServiceCollectionExtensions { public static WebApplicationBuilder ValidateOrbitSecuritySettings(this WebApplicationBuilder builder) { @@ -154,168 +141,6 @@ public static WebApplicationBuilder AddOrbitAiServices(this WebApplicationBuilde return builder; } - private static void AddAiPlatformServices(WebApplicationBuilder builder) - { - builder.Services.Configure( - builder.Configuration.GetSection(AiSettings.SectionName)); - builder.Services.Configure( - builder.Configuration.GetSection(AgentPlatformSettings.SectionName)); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - } - - private static void AddAiChatTools(WebApplicationBuilder builder) - { - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - } - - private static void AddHabitCommandDependencies(WebApplicationBuilder builder) - { - builder.Services.AddScoped(sp => - new Orbit.Application.Habits.Commands.LogHabitRepositories( - sp.GetRequiredService>(), - sp.GetRequiredService>(), - sp.GetRequiredService>(), - sp.GetRequiredService>())); - builder.Services.AddScoped(sp => - new Orbit.Application.Habits.Commands.LogHabitServices( - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService())); - builder.Services.AddScoped(sp => - new Orbit.Application.Habits.Commands.BulkLogServices( - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService())); - - builder.Services.AddScoped(sp => - new Orbit.Application.Habits.Commands.CreateHabitRepositories( - sp.GetRequiredService>(), - sp.GetRequiredService>(), - sp.GetRequiredService>())); - } - - private static void AddCalendarCommandDependencies(WebApplicationBuilder builder) - { - builder.Services.AddScoped(sp => - new Orbit.Application.Calendar.Commands.CalendarAutoSyncDependencies( - sp.GetRequiredService>(), - sp.GetRequiredService>(), - sp.GetRequiredService>(), - sp.GetRequiredService>(), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService())); - } - - private static void AddChatCommandDependencies(WebApplicationBuilder builder) - { - builder.Services.AddScoped(sp => - new Orbit.Application.Chat.Commands.ChatAiDependencies( - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService())); - builder.Services.AddScoped(sp => - new Orbit.Application.Chat.Commands.ChatDataDependencies( - sp.GetRequiredService>(), - sp.GetRequiredService>(), - sp.GetRequiredService>(), - sp.GetRequiredService>(), - sp.GetRequiredService>(), - sp.GetRequiredService>(), - sp.GetRequiredService())); - builder.Services.AddScoped(sp => - new Orbit.Application.Chat.Commands.ChatExecutionDependencies( - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService())); - } - public static WebApplicationBuilder AddOrbitInfrastructure(this WebApplicationBuilder builder) { builder.Services.Configure( @@ -365,289 +190,6 @@ public static WebApplicationBuilder AddOrbitInfrastructure(this WebApplicationBu return builder; } - private static void AddEmailAndSupabaseClients(WebApplicationBuilder builder, TimeSpan httpTimeout) - { - builder.Services.AddHttpClient("Supabase", client => - { - client.BaseAddress = new Uri(builder.Configuration["Supabase:Url"]!); - client.DefaultRequestHeaders.Add("apikey", builder.Configuration["Supabase:AnonKey"]!); - client.Timeout = httpTimeout; - }); - - builder.Services.Configure( - builder.Configuration.GetSection(SupabaseStorageSettings.SectionName)); - - builder.Services.AddHttpClient(SupabaseObjectStorageService.HttpClientName, client => - { - // Secret keys use the apikey header only — on Authorization: Bearer the gateway parses them as a JWT and rejects the request: https://supabase.com/docs/guides/getting-started/migrating-to-new-api-keys - var secretKey = builder.Configuration["Supabase:SecretKey"]!; - client.BaseAddress = new Uri(builder.Configuration["Supabase:Url"]!); - client.DefaultRequestHeaders.Add("apikey", secretKey); - client.Timeout = httpTimeout; - }); - - builder.Services.AddScoped(); - - builder.Services.Configure( - builder.Configuration.GetSection(ResendSettings.SectionName)); - -#pragma warning disable S1075 // Resend API base URL is a stable, well-known endpoint - builder.Services.AddHttpClient("Resend", client => - { - client.BaseAddress = new Uri("https://api.resend.com"); -#pragma warning restore S1075 - client.DefaultRequestHeaders.Add("Authorization", $"Bearer {builder.Configuration["Resend:ApiKey"]}"); - client.Timeout = httpTimeout; - }); - - builder.Services.AddScoped(); - } - - private static void AddStripeBilling(WebApplicationBuilder builder) - { - builder.Services.Configure( - builder.Configuration.GetSection(StripeSettings.SectionName)); - var stripeKey = builder.Configuration.GetSection(StripeSettings.SectionName).Get()?.SecretKey; - if (!string.IsNullOrEmpty(stripeKey)) - { - Stripe.StripeConfiguration.ApiKey = stripeKey; - } - - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - } - - private static void AddGooglePlayBilling(WebApplicationBuilder builder) - { - builder.Services.Configure( - builder.Configuration.GetSection(GooglePlaySettings.SectionName)); - builder.Services.AddSingleton(sp => - { - var googlePlaySettings = sp.GetRequiredService>().Value; - var credential = Google.Apis.Auth.OAuth2.CredentialFactory - .FromJson(googlePlaySettings.ServiceAccountJson) - .ToGoogleCredential() - .CreateScoped(Google.Apis.AndroidPublisher.v3.AndroidPublisherService.Scope.Androidpublisher); - return new Google.Apis.AndroidPublisher.v3.AndroidPublisherService( - new Google.Apis.Services.BaseClientService.Initializer - { - HttpClientInitializer = credential, - ApplicationName = "Orbit", - }); - }); - builder.Services.AddScoped(); - builder.Services.AddSingleton(); - builder.Services.AddScoped(); - } - - private static void AddPushAndReferralServices(WebApplicationBuilder builder, TimeSpan httpTimeout) - { - builder.Services.Configure( - builder.Configuration.GetSection(VapidSettings.SectionName)); - builder.Services.AddHttpClient() - .ConfigureHttpClient(c => c.Timeout = httpTimeout); - builder.Services.AddScoped(); - builder.Services.AddScoped(sp => - new Orbit.Application.Referrals.Commands.ReferralRepositories( - sp.GetRequiredService>(), - sp.GetRequiredService>(), - sp.GetRequiredService>(), - sp.GetRequiredService>(), - sp.GetRequiredService>())); - } - - private static void AddBackgroundServices(WebApplicationBuilder builder) - { - builder.Services.AddScoped(); - - var useDurableQueue = builder.Configuration.GetSection(BackgroundJobSettings.SectionName) - .Get()?.UseDurableQueue ?? false; - - builder.Services.AddHostedService(); - - if (useDurableQueue) - AddDurableRecurringJobs(builder); - else - AddInProcessSchedulers(builder); - - builder.Services.AddHealthChecks() - .AddCheck("background-services"); - } - - private static void AddInProcessSchedulers(WebApplicationBuilder builder) - { - builder.Services.AddHostedService(); - builder.Services.AddHostedService(); - builder.Services.AddHostedService(); - builder.Services.AddHostedService(); - builder.Services.AddHostedService(); - builder.Services.AddHostedService(); - builder.Services.AddHostedService(); - builder.Services.AddHostedService(); - builder.Services.AddHostedService(); - builder.Services.AddHostedService(); - builder.Services.AddHostedService(); - } - - private static void AddDurableRecurringJobs(WebApplicationBuilder builder) - { - var connectionString = OrbitConnectionStringFactory.ForSession(builder.Configuration); - if (string.IsNullOrWhiteSpace(connectionString)) - throw new InvalidOperationException( - $"{BackgroundJobSettings.SectionName}:UseDurableQueue is true but no database connection string is configured."); - - builder.Services.AddHangfire(config => config - .SetDataCompatibilityLevel(CompatibilityLevel.Version_180) - .UseSimpleAssemblyNameTypeSerializer() - .UseRecommendedSerializerSettings() - .UsePostgreSqlStorage(postgres => postgres.UseNpgsqlConnection(connectionString))); - builder.Services.AddHangfireServer(options => - { - options.WorkerCount = 2; - options.SchedulePollingInterval = TimeSpan.FromMinutes(1); - }); - - builder.Services.AddSingleton(); - AddScheduledJob(builder); - AddScheduledJob(builder); - AddScheduledJob(builder); - AddScheduledJob(builder); - AddScheduledJob(builder); - AddScheduledJob(builder); - AddScheduledJob(builder); - AddScheduledJob(builder); - AddScheduledJob(builder); - AddScheduledJob(builder); - AddScheduledJob(builder); - - builder.Services.AddHostedService(); - } - - private static void AddScheduledJob(WebApplicationBuilder builder) - where TJob : class, IScheduledJob - { - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - } - - private static void AddOrbitDistributedCache(WebApplicationBuilder builder) - { - var redisSettings = builder.Configuration.GetSection(RedisCacheSettings.SectionName).Get() - ?? new RedisCacheSettings(); - - if (!redisSettings.Enabled) - { - builder.Services.AddDistributedMemoryCache(); - return; - } - - if (string.IsNullOrWhiteSpace(redisSettings.ConnectionString)) - throw new InvalidOperationException( - $"{RedisCacheSettings.SectionName}:Enabled is true but {RedisCacheSettings.SectionName}:ConnectionString is not configured."); - - builder.Services.AddStackExchangeRedisCache(options => - { - options.Configuration = redisSettings.ConnectionString; - options.InstanceName = redisSettings.InstanceName; - }); - } - - private static void AddCorsPolicies(WebApplicationBuilder builder) - { - var firstPartyOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get() - ?? ["http://localhost:3000"]; - var thirdPartyOrigins = builder.Configuration.GetSection("Cors:ThirdPartyOrigins").Get() - ?? []; - builder.Services.AddCors(options => - { - options.AddDefaultPolicy(policy => - { - policy.WithOrigins(firstPartyOrigins) - .WithHeaders("Authorization", "Content-Type", "Mcp-Session-Id") - .WithMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS") - .AllowCredentials(); - }); - if (thirdPartyOrigins.Length > 0) - { - options.AddPolicy("ThirdParty", policy => - { - policy.WithOrigins(thirdPartyOrigins) - .WithHeaders("Authorization", "Content-Type", "Mcp-Session-Id") - .WithMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"); - }); - } - }); - } - - private static void AddCookieAndKestrelLimits(WebApplicationBuilder builder) - { - builder.Services.Configure(options => - { - options.HttpOnly = Microsoft.AspNetCore.CookiePolicy.HttpOnlyPolicy.Always; - options.Secure = CookieSecurePolicy.Always; - options.MinimumSameSitePolicy = SameSiteMode.Strict; - }); - - builder.WebHost.ConfigureKestrel(options => - { - options.Limits.MaxRequestBodySize = 10 * 1024 * 1024; - }); - } - - private static void AddMcpToolServer(WebApplicationBuilder builder) - { - builder.Services.AddMcpServer() - .WithHttpTransport() - .WithTools() - .WithTools() - .WithTools() - .WithTools() - .WithTools() - .WithTools() - .WithTools() - .WithTools() - .WithTools() - .WithTools() - .WithTools() - .WithTools() - .WithTools() - .WithTools() - .WithTools(); - } - - private static void AddApiPipeline(WebApplicationBuilder builder) - { - builder.Services.AddControllers() - .AddJsonOptions(options => - { - options.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter()); - }); - - builder.Services.AddExceptionHandler(); - builder.Services.AddExceptionHandler(); - builder.Services.AddExceptionHandler(); - builder.Services.AddProblemDetails(options => - { - options.CustomizeProblemDetails = ctx => - { - ctx.ProblemDetails.Extensions.Remove("exception"); - ctx.ProblemDetails.Detail = null; - }; - }); - - builder.Services.AddOpenApi(options => - { - options.AddDocumentTransformer(); - }); - } - public static WebApplicationBuilder AddOrbitRateLimiting(this WebApplicationBuilder builder) { builder.Services.AddScoped(); @@ -694,30 +236,4 @@ private static SentryEvent ScrubSensitiveData(SentryEvent sentryEvent, SentryHin return sentryEvent; } - - private static void InitializeFirebase(ConfigurationManager configuration) - { - var firebaseCredJson = configuration["Firebase:CredentialsJson"]; - if (!string.IsNullOrEmpty(firebaseCredJson)) - { - FirebaseAdmin.FirebaseApp.Create(new FirebaseAdmin.AppOptions - { - Credential = Google.Apis.Auth.OAuth2.CredentialFactory - .FromJson(firebaseCredJson) - .ToGoogleCredential() - }); - return; - } - - var firebaseCredPath = configuration["Firebase:CredentialsPath"]; - if (!string.IsNullOrEmpty(firebaseCredPath) && File.Exists(firebaseCredPath)) - { - FirebaseAdmin.FirebaseApp.Create(new FirebaseAdmin.AppOptions - { - Credential = Google.Apis.Auth.OAuth2.CredentialFactory - .FromFile(firebaseCredPath) - .ToGoogleCredential() - }); - } - } } diff --git a/src/Orbit.Api/Mcp/Tools/GoalTools.cs b/src/Orbit.Api/Mcp/Tools/GoalTools.cs index dd19ffc5..dd0fe95a 100644 --- a/src/Orbit.Api/Mcp/Tools/GoalTools.cs +++ b/src/Orbit.Api/Mcp/Tools/GoalTools.cs @@ -19,14 +19,13 @@ namespace Orbit.Api.Mcp.Tools; [McpServerToolType] public class GoalTools(IMediator mediator, McpExecutorBridge executorBridge) { - private static readonly System.Text.Json.JsonSerializerOptions CaseInsensitiveJsonOptions = new() { PropertyNameCaseInsensitive = true }; [McpServerTool(Name = "list_goals"), Description("List all goals for the authenticated user.")] public async Task ListGoals( ClaimsPrincipal user, [Description("Filter by status: Active, Completed, or Abandoned")] string? status = null, CancellationToken cancellationToken = default) { - var userId = GetUserId(user); + var userId = McpToolHelpers.GetUserId(user); GoalStatus? statusFilter = status is not null ? Enum.Parse(status, true) : null; @@ -83,7 +82,7 @@ public async Task GetGoal( [Description("The goal ID (GUID)")] string goalId, CancellationToken cancellationToken = default) { - var userId = GetUserId(user); + var userId = McpToolHelpers.GetUserId(user); var query = new GetGoalByIdQuery(userId, McpInputParser.ParseGuid(goalId, "goalId")); var result = await mediator.Send(query, cancellationToken); @@ -185,10 +184,7 @@ public async Task ReorderGoals( [Description("JSON array of objects with 'id' (GUID) and 'position' (int)")] string positionsJson, CancellationToken cancellationToken = default) { - var items = System.Text.Json.JsonSerializer.Deserialize>( - positionsJson, - CaseInsensitiveJsonOptions) - ?? []; + var items = McpToolHelpers.DeserializeJson>(positionsJson) ?? []; var result = await executorBridge.ExecuteAsync(user, "reorder_goals", new { @@ -223,7 +219,7 @@ public async Task GetGoalMetrics( [Description("The goal ID (GUID)")] string goalId, CancellationToken cancellationToken = default) { - var userId = GetUserId(user); + var userId = McpToolHelpers.GetUserId(user); var query = new GetGoalMetricsQuery(userId, McpInputParser.ParseGuid(goalId, "goalId")); var result = await mediator.Send(query, cancellationToken); @@ -254,7 +250,7 @@ public async Task GetGoalReview( [Description("Language code (en, pt-BR)")] string language = "en", CancellationToken cancellationToken = default) { - var userId = GetUserId(user); + var userId = McpToolHelpers.GetUserId(user); var query = new GetGoalReviewQuery(userId, language); var result = await mediator.Send(query, cancellationToken); @@ -264,15 +260,4 @@ public async Task GetGoalReview( var r = result.Value; return $"Goal Review{(r.FromCache ? " (cached)" : "")}:\n{r.Review}"; } - - private static Guid GetUserId(ClaimsPrincipal user) - { - var claim = user.FindFirst(ClaimTypes.NameIdentifier)?.Value - ?? throw new UnauthorizedAccessException("User ID not found in token"); - if (!Guid.TryParse(claim, out var userId)) - throw new UnauthorizedAccessException("User ID claim is not a valid GUID"); - return userId; - } - - private sealed record GoalPositionDto(string Id, int Position); } diff --git a/src/Orbit.Api/Mcp/Tools/HabitTools.cs b/src/Orbit.Api/Mcp/Tools/HabitTools.cs index 80968491..fa744470 100644 --- a/src/Orbit.Api/Mcp/Tools/HabitTools.cs +++ b/src/Orbit.Api/Mcp/Tools/HabitTools.cs @@ -6,7 +6,6 @@ using Orbit.Application.Common; using Orbit.Application.Habits.Commands; using Orbit.Application.Habits.Queries; -using Orbit.Domain.Enums; using Orbit.Domain.Interfaces; using Orbit.Domain.ValueObjects; @@ -40,7 +39,7 @@ public async Task ListHabits( [Description("Page size (default 50)")] int pageSize = 50, CancellationToken cancellationToken = default) { - var userId = GetUserId(user); + var userId = McpToolHelpers.GetUserId(user); var query = new GetHabitScheduleQuery( userId, McpInputParser.ParseDate(dateFrom, "dateFrom"), @@ -61,10 +60,10 @@ public async Task ListHabits( var lines = new List(); foreach (var h in items) { - lines.Add(FormatHabitLine(new HabitLineData(h.Id, h.Title, h.FrequencyUnit, h.FrequencyQuantity, + lines.Add(McpToolHelpers.FormatHabitLine(new McpToolHelpers.HabitLineData(h.Id, h.Title, h.FrequencyUnit, h.FrequencyQuantity, h.DueTime, h.IsCompleted, h.IsOverdue, h.IsBadHabit, h.IsGeneral, h.IsFlexible, h.ChecklistItems, h.Tags), indent: 0)); - AppendChildren(lines, h.Children, indent: 1); + McpToolHelpers.AppendChildren(lines, h.Children, indent: 1); } return $"Habits (page {result.Value.Page}/{result.Value.TotalPages}, total: {result.Value.TotalCount}):\n" + @@ -77,7 +76,7 @@ public async Task GetHabit( [Description(HabitIdDescription)] string habitId, CancellationToken cancellationToken = default) { - var userId = GetUserId(user); + var userId = McpToolHelpers.GetUserId(user); var query = new GetHabitByIdQuery(userId, McpInputParser.ParseGuid(habitId, "habitId")); var result = await mediator.Send(query, cancellationToken); @@ -201,7 +200,7 @@ public async Task GetHabitMetrics( [Description(HabitIdDescription)] string habitId, CancellationToken cancellationToken = default) { - var userId = GetUserId(user); + var userId = McpToolHelpers.GetUserId(user); var query = new GetHabitMetricsQuery(userId, McpInputParser.ParseGuid(habitId, "habitId")); var result = await mediator.Send(query, cancellationToken); @@ -241,7 +240,7 @@ public async Task UpdateChecklist( [Description("JSON array of checklist items, each with 'text' (string) and 'isChecked' (boolean)")] string checklistItemsJson, CancellationToken cancellationToken = default) { - var items = DeserializeJson>(checklistItemsJson) ?? []; + var items = McpToolHelpers.DeserializeJson>(checklistItemsJson) ?? []; var result = await executorBridge.ExecuteAsync(user, "update_checklist", new { @@ -258,7 +257,7 @@ public async Task GetHabitLogs( [Description(HabitIdDescription)] string habitId, CancellationToken cancellationToken = default) { - var userId = GetUserId(user); + var userId = McpToolHelpers.GetUserId(user); var query = new GetHabitLogsQuery(userId, McpInputParser.ParseGuid(habitId, "habitId")); var result = await mediator.Send(query, cancellationToken); @@ -283,7 +282,7 @@ public async Task GetAllHabitLogs( [Description(DateToDescription)] string dateTo, CancellationToken cancellationToken = default) { - var userId = GetUserId(user); + var userId = McpToolHelpers.GetUserId(user); var query = new GetAllHabitLogsQuery(userId, McpInputParser.ParseDate(dateFrom, "dateFrom"), McpInputParser.ParseDate(dateTo, "dateTo")); var result = await mediator.Send(query, cancellationToken); @@ -354,11 +353,11 @@ public async Task BulkCreateHabits( [Description("Confirmation token returned by confirm_agent_operation_v2 (required: bulk create is a destructive batch operation)")] string? confirmationToken = null, CancellationToken cancellationToken = default) { - var parsedHabits = DeserializeJson>(habitsJson) ?? []; + var parsedHabits = McpToolHelpers.DeserializeJson>(habitsJson) ?? []; var result = await executorBridge.ExecuteAsync(user, "bulk_create_habits", new { - habits = parsedHabits.Select(ToBulkHabitArgs) + habits = parsedHabits.Select(McpToolHelpers.ToBulkHabitArgs) }, confirmationToken, cancellationToken); if (!result.Succeeded) @@ -384,7 +383,7 @@ public async Task BulkDeleteHabits( [Description("Confirmation token returned by confirm_agent_operation_v2 (required: bulk delete is destructive)")] string? confirmationToken = null, CancellationToken cancellationToken = default) { - var ids = ParseGuidCsv(habitIds); + var ids = McpToolHelpers.ParseGuidCsv(habitIds); var result = await executorBridge.ExecuteAsync(user, "bulk_delete_habits", new { @@ -408,7 +407,7 @@ public async Task BulkLogHabits( [Description("Date to log for in YYYY-MM-DD format (defaults to today)")] string? date = null, CancellationToken cancellationToken = default) { - var ids = ParseGuidCsv(habitIds); + var ids = McpToolHelpers.ParseGuidCsv(habitIds); var result = await executorBridge.ExecuteAsync(user, "bulk_log_habits", new { @@ -428,7 +427,7 @@ public async Task BulkSkipHabits( [Description("Date to skip in YYYY-MM-DD format (defaults to today)")] string? date = null, CancellationToken cancellationToken = default) { - var ids = ParseGuidCsv(habitIds); + var ids = McpToolHelpers.ParseGuidCsv(habitIds); var result = await executorBridge.ExecuteAsync(user, "bulk_skip_habits", new { @@ -447,7 +446,7 @@ public async Task ReorderHabits( [Description("JSON array of objects with 'habitId' (GUID) and 'position' (int)")] string positionsJson, CancellationToken cancellationToken = default) { - var positions = DeserializeJson>(positionsJson) ?? []; + var positions = McpToolHelpers.DeserializeJson>(positionsJson) ?? []; var result = await executorBridge.ExecuteAsync(user, "reorder_habits", new { @@ -485,7 +484,7 @@ public async Task LinkGoalsToHabit( [Description("Comma-separated goal IDs (GUIDs)")] string goalIds, CancellationToken cancellationToken = default) { - var ids = ParseGuidCsv(goalIds); + var ids = McpToolHelpers.ParseGuidCsv(goalIds); var result = await executorBridge.ExecuteAsync(user, "link_goals_to_habit", new { @@ -504,7 +503,7 @@ public async Task GetDailySummary( [Description("Language code (en, pt-BR)")] string language = "en", CancellationToken cancellationToken = default) { - var userId = GetUserId(user); + var userId = McpToolHelpers.GetUserId(user); var query = new GetDailySummaryQuery( userId, McpInputParser.ParseDate(dateFrom, "dateFrom"), @@ -526,7 +525,7 @@ public async Task GetRetrospective( [Description("Language code (en, pt-BR)")] string language = "en", CancellationToken cancellationToken = default) { - var userId = GetUserId(user); + var userId = McpToolHelpers.GetUserId(user); var today = await userDateService.GetUserTodayAsync(userId, cancellationToken); var weekStartDay = await userDateService.GetUserWeekStartDayAsync(userId, cancellationToken); var (dateFrom, dateTo) = RetrospectivePeriodRange.Resolve(period, today, weekStartDay); @@ -544,86 +543,4 @@ public async Task GetRetrospective( new[] { n.Highlights, n.Missed, n.Trends, n.Suggestion }.Where(s => !string.IsNullOrWhiteSpace(s))); return $"Retrospective ({period}){(r.FromCache ? " (cached)" : "")}:\n{narrativeText}"; } - - private static Guid GetUserId(ClaimsPrincipal user) - { - var claim = user.FindFirst(ClaimTypes.NameIdentifier)?.Value - ?? throw new UnauthorizedAccessException("User ID not found in token"); - if (!Guid.TryParse(claim, out var userId)) - throw new UnauthorizedAccessException("User ID claim is not a valid GUID"); - return userId; - } - - private static List ParseGuidCsv(string value) => - value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Select(Guid.Parse) - .ToList(); - - private static readonly System.Text.Json.JsonSerializerOptions CaseInsensitiveJsonOptions = - new() { PropertyNameCaseInsensitive = true }; - - private static T? DeserializeJson(string json) => - System.Text.Json.JsonSerializer.Deserialize(json, CaseInsensitiveJsonOptions); - - private static object ToBulkHabitArgs(BulkHabitItemDto dto) => new - { - title = dto.Title, - description = dto.Description, - frequency_unit = dto.FrequencyUnit, - frequency_quantity = dto.FrequencyQuantity, - is_bad_habit = dto.IsBadHabit, - due_date = dto.DueDate, - due_time = dto.DueTime, - is_general = dto.IsGeneral, - is_flexible = dto.IsFlexible, - sub_habits = dto.SubHabits?.Select(ToBulkHabitArgs) - }; - - private sealed record BulkHabitItemDto( - string Title, - string? Description = null, - string? FrequencyUnit = null, - int? FrequencyQuantity = null, - bool IsBadHabit = false, - string? DueDate = null, - string? DueTime = null, - bool IsGeneral = false, - bool IsFlexible = false, - List? SubHabits = null); - - private sealed record HabitPositionDto(string HabitId, int Position); - - private sealed record HabitLineData( - Guid Id, string Title, FrequencyUnit? FreqUnit, int? FreqQty, - TimeOnly? DueTime, bool IsCompleted, bool IsOverdue, bool IsBadHabit, - bool IsGeneral, bool IsFlexible, - IReadOnlyList Checklist, IReadOnlyList Tags); - - private static string FormatHabitLine(HabitLineData data, int indent) - { - var prefix = new string(' ', indent * 2) + "- "; - var line = $"{prefix}[{(data.IsCompleted ? "x" : " ")}] {data.Title} (id: {data.Id})"; - if (data.FreqUnit is not null) line += $" | {data.FreqQty}x/{data.FreqUnit}"; - else if (!data.IsGeneral) line += " | one-time"; - if (data.IsGeneral) line += " | general"; - if (data.IsFlexible) line += " | flexible"; - if (data.DueTime is not null) line += $" | at {data.DueTime:HH:mm}"; - if (data.IsOverdue) line += " | OVERDUE"; - if (data.IsBadHabit) line += " | bad habit"; - if (data.Checklist.Count > 0) line += $" | checklist: {data.Checklist.Count(i => i.IsChecked)}/{data.Checklist.Count}"; - if (data.Tags.Count > 0) line += $" | tags: {string.Join(", ", data.Tags.Select(t => t.Name))}"; - return line; - } - - private static void AppendChildren(List lines, IReadOnlyList children, int indent) - { - foreach (var c in children) - { - lines.Add(FormatHabitLine(new HabitLineData(c.Id, c.Title, c.FrequencyUnit, c.FrequencyQuantity, - c.DueTime, c.IsCompleted, false, c.IsBadHabit, c.IsGeneral, c.IsFlexible, - c.ChecklistItems, c.Tags), indent)); - if (c.Children.Count > 0) - AppendChildren(lines, c.Children, indent + 1); - } - } } diff --git a/src/Orbit.Api/Mcp/Tools/McpToolHelpers.cs b/src/Orbit.Api/Mcp/Tools/McpToolHelpers.cs new file mode 100644 index 00000000..7d423022 --- /dev/null +++ b/src/Orbit.Api/Mcp/Tools/McpToolHelpers.cs @@ -0,0 +1,100 @@ +using System.Security.Claims; +using System.Text.Json; +using Orbit.Application.Habits.Queries; +using Orbit.Domain.Enums; +using Orbit.Domain.ValueObjects; + +namespace Orbit.Api.Mcp.Tools; + +/// +/// Pure formatting, parsing, and snake_case argument-mapping helpers shared by the MCP habit and +/// goal toolsets. No injected or instance state — every member is deterministic over its inputs and +/// returns the exact shapes the backing IAiTool schemas and the legacy MCP string contract +/// expect. +/// +internal static class McpToolHelpers +{ + private static readonly JsonSerializerOptions CaseInsensitiveJsonOptions = + new() { PropertyNameCaseInsensitive = true }; + + public static Guid GetUserId(ClaimsPrincipal user) + { + var claim = user.FindFirst(ClaimTypes.NameIdentifier)?.Value + ?? throw new UnauthorizedAccessException("User ID not found in token"); + if (!Guid.TryParse(claim, out var userId)) + throw new UnauthorizedAccessException("User ID claim is not a valid GUID"); + return userId; + } + + public static List ParseGuidCsv(string value) => + value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(Guid.Parse) + .ToList(); + + public static T? DeserializeJson(string json) => + JsonSerializer.Deserialize(json, CaseInsensitiveJsonOptions); + + public static object ToBulkHabitArgs(BulkHabitItemDto dto) => new + { + title = dto.Title, + description = dto.Description, + frequency_unit = dto.FrequencyUnit, + frequency_quantity = dto.FrequencyQuantity, + is_bad_habit = dto.IsBadHabit, + due_date = dto.DueDate, + due_time = dto.DueTime, + is_general = dto.IsGeneral, + is_flexible = dto.IsFlexible, + sub_habits = dto.SubHabits?.Select(ToBulkHabitArgs) + }; + + public static string FormatHabitLine(HabitLineData data, int indent) + { + var prefix = new string(' ', indent * 2) + "- "; + var line = $"{prefix}[{(data.IsCompleted ? "x" : " ")}] {data.Title} (id: {data.Id})"; + if (data.FreqUnit is not null) line += $" | {data.FreqQty}x/{data.FreqUnit}"; + else if (!data.IsGeneral) line += " | one-time"; + if (data.IsGeneral) line += " | general"; + if (data.IsFlexible) line += " | flexible"; + if (data.DueTime is not null) line += $" | at {data.DueTime:HH:mm}"; + if (data.IsOverdue) line += " | OVERDUE"; + if (data.IsBadHabit) line += " | bad habit"; + if (data.Checklist.Count > 0) line += $" | checklist: {data.Checklist.Count(i => i.IsChecked)}/{data.Checklist.Count}"; + if (data.Tags.Count > 0) line += $" | tags: {string.Join(", ", data.Tags.Select(t => t.Name))}"; + return line; + } + + public static void AppendChildren(List lines, IReadOnlyList children, int indent) + { + foreach (var c in children) + { + lines.Add(FormatHabitLine(new HabitLineData(c.Id, c.Title, c.FrequencyUnit, c.FrequencyQuantity, + c.DueTime, c.IsCompleted, false, c.IsBadHabit, c.IsGeneral, c.IsFlexible, + c.ChecklistItems, c.Tags), indent)); + if (c.Children.Count > 0) + AppendChildren(lines, c.Children, indent + 1); + } + } + + public sealed record BulkHabitItemDto( + string Title, + string? Description = null, + string? FrequencyUnit = null, + int? FrequencyQuantity = null, + bool IsBadHabit = false, + string? DueDate = null, + string? DueTime = null, + bool IsGeneral = false, + bool IsFlexible = false, + List? SubHabits = null); + + public sealed record HabitPositionDto(string HabitId, int Position); + + public sealed record GoalPositionDto(string Id, int Position); + + public sealed record HabitLineData( + Guid Id, string Title, FrequencyUnit? FreqUnit, int? FreqQty, + TimeOnly? DueTime, bool IsCompleted, bool IsOverdue, bool IsBadHabit, + bool IsGeneral, bool IsFlexible, + IReadOnlyList Checklist, IReadOnlyList Tags); +} diff --git a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Accumulator.cs b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Accumulator.cs new file mode 100644 index 00000000..69a0fd8b --- /dev/null +++ b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Accumulator.cs @@ -0,0 +1,106 @@ +using System.Text.Json; +using Orbit.Domain.Enums; +using Orbit.Domain.Models; +using Orbit.Domain.ValueObjects; + +namespace Orbit.Application.Chat.Commands; + +public partial class ProcessUserChatCommandHandler +{ + internal sealed class ToolExecutionAccumulator + { + private readonly List _relatedSurfaces = []; + private readonly HashSet _seenRelatedSurfaces = new(StringComparer.Ordinal); + private readonly HashSet _calledToolNames = new(StringComparer.Ordinal); + + public List ActionResults { get; } = []; + public List OperationResults { get; } = []; + public List PendingOperations { get; } = []; + public List PolicyDenials { get; } = []; + + /// + /// Distinct names of every tool invoked this turn, used to decide whether the turn is safe to + /// serve from the shared FAQ cache (only static, user-data-free tools may have run). + /// + public IReadOnlyCollection CalledToolNames => _calledToolNames; + + /// + /// App surface IDs (e.g. "today", "gamification") surfaced by read-only tools such as + /// describe_feature, deduplicated in first-seen order. The client maps these to deep links. + /// + public IReadOnlyList RelatedSurfaces => _relatedSurfaces; + + public void Add( + string toolName, + ActionResult? actionResult, + AgentOperationResult? operationResult, + AgentPolicyDenial? policyDenial, + PendingAgentOperation? pendingOperation) + { + _calledToolNames.Add(toolName); + + if (actionResult is not null) + ActionResults.Add(actionResult); + + if (operationResult is not null) + { + OperationResults.Add(operationResult); + CollectRelatedSurfaces(operationResult); + } + + if (policyDenial is not null) + PolicyDenials.Add(policyDenial); + + if (pendingOperation is not null) + PendingOperations.Add(pendingOperation); + } + + private void CollectRelatedSurfaces(AgentOperationResult operationResult) + { + if (operationResult.Status != AgentOperationStatus.Succeeded) + return; + + foreach (var surface in ExtractRelatedSurfaces(operationResult.Payload)) + { + if (_seenRelatedSurfaces.Add(surface)) + _relatedSurfaces.Add(surface); + } + } + } + + /// + /// Reads the optional "related_surfaces" string array from a tool's anonymous payload + /// (e.g. describe_feature) by round-tripping it through JSON. Returns an empty sequence + /// when the payload is null, not an object, or carries no usable surface IDs. + /// + private static IEnumerable ExtractRelatedSurfaces(object? payload) + { + if (payload is null) + return []; + + JsonElement element; + try + { + element = JsonSerializer.SerializeToElement(payload); + } + catch (NotSupportedException) + { + return []; + } + + if (element.ValueKind != JsonValueKind.Object + || !element.TryGetProperty("related_surfaces", out var surfaces) + || surfaces.ValueKind != JsonValueKind.Array) + { + return []; + } + + return surfaces + .EnumerateArray() + .Where(item => item.ValueKind == JsonValueKind.String) + .Select(item => item.GetString()) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value!) + .ToList(); + } +} diff --git a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Ai.cs b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Ai.cs new file mode 100644 index 00000000..d6cda278 --- /dev/null +++ b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Ai.cs @@ -0,0 +1,170 @@ +using System.Text.Json; +using Orbit.Application.Chat.Models; +using Orbit.Application.Common; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Enums; +using Orbit.Domain.Interfaces; +using Orbit.Domain.Models; + +namespace Orbit.Application.Chat.Commands; + +public partial class ProcessUserChatCommandHandler +{ + private async Task> RequestInitialAiResponseAsync( + ProcessUserChatCommand request, + ChatContext context, + Func? aiStreamSink, + bool skipTools, + CancellationToken cancellationToken) + { + var promptRequest = new PromptBuildRequest( + context.PromptHabits, context.UserFacts, + HasImage: request.ImageData is not null, + UserTags: context.UserTags, UserToday: context.UserToday, ActiveGoals: context.ActiveGoals); + var agentSnapshot = BuildAgentContextSnapshot( + context.User, + request.ClientContext, + context.EnabledFeatureFlags, + context.UserTags, + context.ChecklistTemplates, + context.ActiveHabits, + context.ActiveGoals, + context.HasProAccess); + + var systemPrompt = string.Join( + Environment.NewLine, + ai.PromptBuilder.BuildStatic(promptRequest), + ai.CatalogService.BuildStaticSupplement(), + ai.PromptBuilder.BuildDynamic(promptRequest), + ai.CatalogService.BuildDynamicSupplement(agentSnapshot)); + + if (request.ClientContext?.SupportsHabitListCard == true) + systemPrompt = string.Join(Environment.NewLine, systemPrompt, HabitListCardBuilder.PromptInstruction); + + if (request.ClientContext?.SupportsGoalListCard == true) + systemPrompt = string.Join(Environment.NewLine, systemPrompt, GoalListCardBuilder.PromptInstruction); + + var activeToolNames = ChatToolGroups.ResolveActiveToolNames( + ai.ToolRegistry.GetAll().Select(t => t.Name), + BuildConversationText(request)); + + var toolDeclarations = skipTools + ? new List() + : ai.ToolRegistry.GetAll() + .Where(t => activeToolNames.Contains(t.Name)) + .OrderBy(t => t.Name, StringComparer.Ordinal) + .Select(t => (object)new + { + name = t.Name, + description = t.Description, + parameters = t.GetParameterSchema() + }) + .ToList(); + + LogCallingAiIntentService(logger, toolDeclarations.Count); + + return await ai.IntentService.SendWithToolsAsync( + request.Message, + systemPrompt, + toolDeclarations, + request.UserId, + request.ImageData, + request.ImageMimeType, + request.History, + aiStreamSink, + cancellationToken); + } + + private static string BuildConversationText(ProcessUserChatCommand request) + { + if (request.History is not { Count: > 0 }) + return request.Message; + + return request.Message + " " + string.Join(" ", request.History.Select(message => message.Content)); + } + + private static AgentContextSnapshot BuildAgentContextSnapshot( + User? user, + AgentClientContext? clientContext, + IReadOnlyList featureFlags, + IReadOnlyCollection userTags, + IReadOnlyCollection checklistTemplates, + IReadOnlyCollection activeHabits, + IReadOnlyCollection activeGoals, + bool hasProAccess) + { + return new AgentContextSnapshot( + hasProAccess ? "pro" : "free", + user?.Language, + user?.TimeZone, + hasProAccess && (user?.AiMemoryEnabled ?? true), + hasProAccess && (user?.AiSummaryEnabled ?? true), + user?.WeekStartDay ?? 1, + user?.ThemePreference, + hasProAccess ? user?.ColorScheme : null, + hasProAccess && user?.GoogleAccessToken is not null, + hasProAccess && (user?.GoogleCalendarAutoSyncEnabled ?? false), + hasProAccess + ? (user?.GoogleCalendarAutoSyncStatus ?? GoogleCalendarAutoSyncStatus.Idle).ToString() + : "Locked", + featureFlags, + userTags + .Select(tag => tag.Name) + .OrderBy(name => name, StringComparer.OrdinalIgnoreCase) + .Take(12) + .ToList(), + checklistTemplates + .Select(template => template.Name) + .OrderBy(name => name, StringComparer.OrdinalIgnoreCase) + .Take(10) + .ToList(), + activeHabits + .OrderByDescending(habit => habit.UpdatedAtUtc) + .Select(habit => habit.Title) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(8) + .ToList(), + hasProAccess + ? activeGoals + .OrderByDescending(goal => goal.UpdatedAtUtc) + .Select(goal => goal.Title) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(8) + .ToList() + : [], + ClientContext: clientContext); + } + + private static Func? BuildAiStreamSink(Func? streamSink) + { + if (streamSink is null) + return null; + + return aiEvent => streamSink(aiEvent.Kind == AiStreamEventKind.Delta + ? ChatStreamEvent.Delta(aiEvent.Text ?? "") + : ChatStreamEvent.Reset()); + } + + /// + /// Strips a JSON wrapper from the AI response text, extracting the "aiMessage" property + /// if the model returned a raw JSON object instead of using function calling. + /// + private static string? StripJsonWrapper(string? text) + { + if (text is null || !text.TrimStart().StartsWith('{')) + return text; + + try + { + using var doc = JsonDocument.Parse(text); + if (doc.RootElement.TryGetProperty("aiMessage", out var msgEl)) + return msgEl.GetString(); + } + catch (JsonException) + { + } + + return text; + } +} diff --git a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Context.cs b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Context.cs new file mode 100644 index 00000000..dd9e6000 --- /dev/null +++ b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Context.cs @@ -0,0 +1,126 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Orbit.Application.Common; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Enums; + +namespace Orbit.Application.Chat.Commands; + +public partial class ProcessUserChatCommandHandler +{ + private async Task> LoadChatContextAsync( + ProcessUserChatCommand request, + CancellationToken cancellationToken) + { + LogFetchingContext(logger); + var dbStopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var userHabits = await data.HabitRepository.FindAsync( + h => h.UserId == request.UserId, + q => q, + cancellationToken); + var activeHabits = userHabits.Where(habit => !habit.IsCompleted).ToList(); + var promptHabits = BuildPromptHabitIndex(userHabits); + var user = await data.UserRepository.GetByIdAsync(request.UserId, cancellationToken); + var hasProAccess = user?.HasProAccess ?? false; + var aiMemoryEnabled = user is { HasProAccess: true, AiMemoryEnabled: true }; + var userToday = await execution.UserDateService.GetUserTodayAsync(request.UserId, cancellationToken); + + IReadOnlyList activeGoals = []; + if (hasProAccess) + { + var freshStreakValues = await execution.StreakGoalReadSyncer.ComputeFreshValuesAsync(request.UserId, userToday, cancellationToken); + var loadedGoals = await data.GoalRepository.FindAsync( + g => g.UserId == request.UserId && g.Status == GoalStatus.Active, + q => q.Include(g => g.Habits), + cancellationToken); + foreach (var goal in loadedGoals) + { + if (freshStreakValues.TryGetValue(goal.Id, out var fresh)) + goal.SyncStreakProgress(fresh, allowCompletion: false); + } + activeGoals = loadedGoals; + } + + var messageGate = await execution.PayGateService.CanSendAiMessage(request.UserId, cancellationToken); + if (messageGate.IsFailure) + return messageGate.PropagateError(); + + IReadOnlyList userFacts = []; + if (aiMemoryEnabled) + { + userFacts = await data.UserFactRepository.FindAsync( + f => f.UserId == request.UserId, + cancellationToken); + } + + var userTags = await data.TagRepository.FindAsync( + t => t.UserId == request.UserId, + cancellationToken); + + var checklistTemplates = await data.ChecklistTemplateRepository.FindAsync( + template => template.UserId == request.UserId, + cancellationToken); + + var enabledFeatureFlags = await data.FeatureFlagService.GetEnabledKeysForUserAsync( + request.UserId, + cancellationToken); + + dbStopwatch.Stop(); + LogContextLoaded(logger, dbStopwatch.ElapsedMilliseconds, activeHabits.Count, userFacts.Count); + + return Result.Success(new ChatContext( + activeHabits, + promptHabits, + user, + hasProAccess, + aiMemoryEnabled, + activeGoals, + userFacts, + userTags, + checklistTemplates, + enabledFeatureFlags, + userToday, + dbStopwatch.ElapsedMilliseconds)); + } + + private static List BuildPromptHabitIndex(IReadOnlyCollection userHabits) + { + if (userHabits.Count == 0) + return []; + + var habitsById = userHabits.ToDictionary(habit => habit.Id); + var indexedHabitIds = new HashSet(); + + foreach (var habit in userHabits.Where(habit => !habit.IsCompleted)) + { + var current = habit; + + while (indexedHabitIds.Add(current.Id) && + current.ParentHabitId is Guid parentId && + habitsById.TryGetValue(parentId, out var parent)) + { + current = parent; + } + } + + return userHabits + .Where(habit => indexedHabitIds.Contains(habit.Id)) + .ToList(); + } + + private sealed record ChatContext( + List ActiveHabits, + List PromptHabits, + User? User, + bool HasProAccess, + bool AiMemoryEnabled, + IReadOnlyList ActiveGoals, + IReadOnlyList UserFacts, + IReadOnlyList UserTags, + IReadOnlyList ChecklistTemplates, + IReadOnlyList EnabledFeatureFlags, + DateOnly UserToday, + long ContextLoadMilliseconds); +} diff --git a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Persistence.cs b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Persistence.cs new file mode 100644 index 00000000..f6a3e9d4 --- /dev/null +++ b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Persistence.cs @@ -0,0 +1,101 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Orbit.Application.Common; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Chat.Commands; + +public partial class ProcessUserChatCommandHandler +{ + private async Task PersistExecutionResultsAsync( + Guid userId, + IReadOnlyList actionResults, + CancellationToken cancellationToken) + { + await execution.UnitOfWork.SaveChangesAsync(cancellationToken); + if (RequiresStreakRecalculation(actionResults)) + { + await ConcurrencyRetry.SaveWithRetryAsync( + execution.UnitOfWork, + ct => execution.UserStreakService.RecalculateAsync(userId, ct), + cancellationToken); + } + } + + private static bool RequiresStreakRecalculation(IEnumerable actionResults) + { + return actionResults.Any(action => action.Status == ActionStatus.Success && action.Type is "LogHabit" or "BulkLogHabits" or "DeleteHabit"); + } + + /// + /// Fires off background work for fact extraction and AI message counter increment. + /// Runs in a separate DI scope so it doesn't block the response. + /// + private void RunBackgroundPostResponseWork( + Guid userId, + string userMessage, + string? aiMessage, + bool shouldExtractFacts, + IReadOnlyList existingFacts) + { + _ = Task.Run(async () => + { + try + { + using var scope = execution.ServiceScopeFactory.CreateScope(); + var bgUnitOfWork = scope.ServiceProvider.GetRequiredService(); + var bgUserRepo = scope.ServiceProvider.GetRequiredService>(); + var bgLogger = scope.ServiceProvider.GetRequiredService>(); + + if (shouldExtractFacts) + await SubmitFactExtractionBatchAsync(scope, userId, userMessage, aiMessage, existingFacts); + + await IncrementAiMessageCountAsync(bgUserRepo, bgUnitOfWork, userId, bgLogger); + } + catch (Exception ex) + { + LogBackgroundPostResponseFailed(logger, ex); + } + }, CancellationToken.None); + } + + private static async Task SubmitFactExtractionBatchAsync( + IServiceScope scope, + Guid userId, + string userMessage, + string? aiMessage, + IReadOnlyList existingFacts) + { + var bgFactService = scope.ServiceProvider.GetRequiredService(); + await bgFactService.SubmitBatchAsync(userMessage: userMessage, aiResponse: aiMessage, + existingFacts: existingFacts, userId: userId, cancellationToken: CancellationToken.None); + } + + private static async Task IncrementAiMessageCountAsync( + IGenericRepository bgUserRepo, + IUnitOfWork bgUnitOfWork, + Guid userId, + ILogger bgLogger) + { + try + { + await ConcurrencyRetry.ExecuteAsync( + bgUserRepo, + bgUnitOfWork, + ct => bgUserRepo.FindOneTrackedAsync(u => u.Id == userId, cancellationToken: ct), + user => + { + user.IncrementAiMessageCount(); + return Task.FromResult(Result.Success()); + }, + ErrorMessages.UserNotFound, + CancellationToken.None); + } + catch (Exception ex) + { + LogBackgroundMessageCounterFailed(bgLogger, ex); + } + } +} diff --git a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.ToolResults.cs b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.ToolResults.cs new file mode 100644 index 00000000..4e104de8 --- /dev/null +++ b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.ToolResults.cs @@ -0,0 +1,186 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; +using Orbit.Application.Chat.Tools; +using Orbit.Domain.Enums; +using Orbit.Domain.Models; +using Orbit.Domain.ValueObjects; + +namespace Orbit.Application.Chat.Commands; + +public partial class ProcessUserChatCommandHandler +{ + /// + /// Builds the frontend-facing ActionResult from a tool execution result. + /// Returns null for read-only tools (they don't produce action chips). + /// + private static ActionResult? BuildActionResult(AiToolCall call, IAiTool tool, ToolResult result) + { + if (tool.IsReadOnly) + return null; + + if (!result.Success) + { + return new ActionResult( + ToolNameToPascalCase(call.Name), + ActionStatus.Failed, + EntityName: result.EntityName, + Error: result.Error); + } + + if (call.Name == "suggest_breakdown") + { + return new ActionResult( + ToolNameToPascalCase(call.Name), + ActionStatus.Suggestion, + EntityName: result.EntityName, + SuggestedSubHabits: ExtractSuggestedSubHabits(call.Args)); + } + + return new ActionResult( + ToolNameToPascalCase(call.Name), + ActionStatus.Success, + result.EntityId is not null ? Guid.Parse(result.EntityId) : null, + result.EntityName); + } + + private static string BuildOperationSummary(AiToolCall call) + { + return $"{ToolNameToPascalCase(call.Name)} requested via chat"; + } + + private static AiToolCallResult BuildToolCallResult(AiToolCall call, AgentOperationResult operationResult) + { + return new AiToolCallResult( + call.Name, + call.Id, + operationResult.Status == AgentOperationStatus.Succeeded, + operationResult.TargetId, + operationResult.TargetName, + BuildToolError(operationResult), + operationResult.Payload); + } + + private static string? BuildToolError(AgentOperationResult operationResult) + { + return operationResult.Status switch + { + AgentOperationStatus.Denied => $"Policy denied: {operationResult.PolicyReason}", + AgentOperationStatus.UnsupportedByPolicy => "Operation is unsupported by policy.", + AgentOperationStatus.PendingConfirmation => "Confirmation required before this action can run.", + AgentOperationStatus.Failed => string.Equals(operationResult.PolicyReason, "unexpected_error", StringComparison.Ordinal) + ? "An unexpected error occurred." + : operationResult.PolicyReason, + _ => null + }; + } + + private static ToolResult ToToolResult(AgentOperationResult operationResult) + { + return new ToolResult( + operationResult.Status == AgentOperationStatus.Succeeded, + operationResult.TargetId, + operationResult.TargetName, + BuildToolError(operationResult), + operationResult.Payload); + } + + /// + /// Converts snake_case tool names to PascalCase for backward compatibility with the frontend. + /// e.g., "log_habit" -> "LogHabit", "create_sub_habit" -> "CreateSubHabit" + /// + private static string ToolNameToPascalCase(string toolName) + { + var parts = toolName.Split('_'); + return string.Concat(parts.Select(p => + string.IsNullOrEmpty(p) ? p : char.ToUpper(p[0], CultureInfo.InvariantCulture) + p[1..])); + } + + /// + /// Returns a copy of the send_support_request args with the correlation id appended to + /// the message body as a "[trace: {id}]" line, so emailed support tickets carry the trace. + /// The append respects the support Message length cap; if there is no string message the + /// args are returned unchanged. + /// + private static JsonElement AppendSupportTrace(JsonElement args, string correlationId) + { + var node = JsonNode.Parse(args.GetRawText()); + if (node is not JsonObject argsObject || argsObject["message"] is not JsonValue messageValue + || !messageValue.TryGetValue(out string? message) || message is null) + { + return args; + } + + var suffix = $"\n\n[trace: {correlationId}]"; + var available = MaxSupportMessageLength - suffix.Length; + var trimmedMessage = message.Length > available ? message[..Math.Max(0, available)] : message; + argsObject["message"] = trimmedMessage + suffix; + + return JsonSerializer.Deserialize(argsObject.ToJsonString()); + } + + /// + /// Extracts suggested sub-habits from the suggest_breakdown tool call args + /// for backward-compatible ActionResult.SuggestedSubHabits. + /// + private static List? ExtractSuggestedSubHabits(JsonElement args) + { + if (!args.TryGetProperty("suggested_sub_habits", out var subHabitsEl) || + subHabitsEl.ValueKind != JsonValueKind.Array) + return null; + + var suggestions = new List(); + foreach (var item in subHabitsEl.EnumerateArray()) + suggestions.Add(ParseSingleSubHabit(item)); + + return suggestions.Count > 0 ? suggestions : null; + } + + private static AiAction ParseSingleSubHabit(JsonElement item) + { + return new AiAction + { + Type = AiActionType.SuggestBreakdown, + Title = GetStringProperty(item, "title"), + Description = GetStringProperty(item, "description"), + FrequencyUnit = GetEnumProperty(item, "frequency_unit"), + FrequencyQuantity = GetIntProperty(item, "frequency_quantity"), + Days = GetDaysProperty(item) + }; + } + + private static string? GetStringProperty(JsonElement element, string propertyName) + { + return element.TryGetProperty(propertyName, out var el) && el.ValueKind == JsonValueKind.String + ? el.GetString() : null; + } + + private static TEnum? GetEnumProperty(JsonElement element, string propertyName) where TEnum : struct, Enum + { + return element.TryGetProperty(propertyName, out var el) && el.ValueKind == JsonValueKind.String + && Enum.TryParse(el.GetString(), true, out var value) + ? value : null; + } + + private static int? GetIntProperty(JsonElement element, string propertyName) + { + return element.TryGetProperty(propertyName, out var el) && el.ValueKind == JsonValueKind.Number + ? el.GetInt32() : null; + } + + private static List? GetDaysProperty(JsonElement item) + { + if (!item.TryGetProperty("days", out var daysEl) || daysEl.ValueKind != JsonValueKind.Array) + return null; + + var days = new List(); + foreach (var dayEl in daysEl.EnumerateArray()) + { + if (dayEl.ValueKind == JsonValueKind.String && + Enum.TryParse(dayEl.GetString(), true, out var dow)) + days.Add(dow); + } + + return days; + } +} diff --git a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Tools.cs b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Tools.cs new file mode 100644 index 00000000..0d70dff6 --- /dev/null +++ b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Tools.cs @@ -0,0 +1,331 @@ +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Orbit.Application.Chat.Models; +using Orbit.Application.Chat.Tools; +using Orbit.Application.Common; +using Orbit.Domain.Interfaces; +using Orbit.Domain.Models; + +namespace Orbit.Application.Chat.Commands; + +public partial class ProcessUserChatCommandHandler +{ + private async Task<(AiResponse FinalResponse, int Iterations)> RunToolCallLoopAsync( + AiResponse initialResponse, + ProcessUserChatCommand request, + ToolExecutionAccumulator executionResults, + Func? aiStreamSink, + CancellationToken cancellationToken) + { + var aiResponse = initialResponse; + var iteration = 0; + + while (aiResponse.HasToolCalls && iteration < MaxToolIterations) + { + iteration++; + if (request.StreamSink is not null) + await request.StreamSink(ChatStreamEvent.Round(iteration)); + + var continueResponse = await ProcessToolCallsAsync( + aiResponse, + request, + executionResults, + iteration, + aiStreamSink, + cancellationToken); + + if (continueResponse is null) + break; + + aiResponse = continueResponse; + } + + return (aiResponse, iteration); + } + + /// + /// Processes one iteration of AI tool calls: orders them, executes each, and sends results + /// back to the AI. Returns the next AI response, or null if continuation failed. + /// + private async Task ProcessToolCallsAsync( + AiResponse aiResponse, + ProcessUserChatCommand request, + ToolExecutionAccumulator executionResults, + int iteration, + Func? aiStreamSink, + CancellationToken cancellationToken) + { + LogToolCallingIteration(logger, iteration, aiResponse.ToolCalls!.Count); + + var orderedCalls = aiResponse.ToolCalls! + .OrderBy(c => ai.ToolRegistry.GetTool(c.Name)?.Order ?? int.MaxValue) + .ToList(); + + var outcomesByCallId = await ExecuteToolCallsAsync(orderedCalls, request, cancellationToken); + + var toolResults = new List(orderedCalls.Count); + foreach (var call in orderedCalls) + { + var outcome = outcomesByCallId[call.Id]; + toolResults.Add(outcome.ToolResult); + executionResults.Add(call.Name, outcome.ActionResult, outcome.OperationResult, outcome.PolicyDenial, outcome.PendingOperation); + } + + var continueResult = await ai.IntentService.ContinueWithToolResultsAsync(aiResponse.ConversationContext!, toolResults, aiStreamSink, cancellationToken); + if (continueResult.IsFailure) + { + LogContinueWithToolResultsFailed(logger, continueResult.Error); + return null; + } + + return continueResult.Value; + } + + /// + /// Executes a round's tool calls, dispatching the read-only subset concurrently (each on its + /// own DI scope for DbContext isolation) and the write subset sequentially on the ambient + /// scope in Order. Returns every outcome keyed by tool-call id so the caller can + /// reassemble results deterministically, independent of task-completion timing. + /// + private async Task> ExecuteToolCallsAsync( + IReadOnlyList orderedCalls, + ProcessUserChatCommand request, + CancellationToken cancellationToken) + { + var readOnlyCalls = orderedCalls + .Where(call => ai.ToolRegistry.GetTool(call.Name)?.IsReadOnly == true) + .ToList(); + var writeCalls = orderedCalls + .Where(call => ai.ToolRegistry.GetTool(call.Name)?.IsReadOnly != true) + .ToList(); + + var readOnlyTasks = readOnlyCalls + .Select(call => ExecuteReadOnlyToolCallOnIsolatedScopeAsync(call, request, cancellationToken)) + .ToList(); + var readOnlyOutcomes = await Task.WhenAll(readOnlyTasks); + + var outcomesByCallId = new Dictionary(orderedCalls.Count, StringComparer.Ordinal); + for (var index = 0; index < readOnlyCalls.Count; index++) + outcomesByCallId[readOnlyCalls[index].Id] = readOnlyOutcomes[index]; + + foreach (var call in writeCalls) + { + outcomesByCallId[call.Id] = await ExecuteSingleToolCallAsync( + call, request, execution.OperationExecutor, execution.PendingClarificationStore, cancellationToken); + } + + return outcomesByCallId; + } + + private async Task ExecuteReadOnlyToolCallOnIsolatedScopeAsync( + AiToolCall call, + ProcessUserChatCommand request, + CancellationToken cancellationToken) + { + using var scope = execution.ServiceScopeFactory.CreateScope(); + var scopedExecutor = scope.ServiceProvider.GetRequiredService(); + var scopedClarificationStore = scope.ServiceProvider.GetRequiredService(); + + return await ExecuteSingleToolCallAsync( + call, request, scopedExecutor, scopedClarificationStore, cancellationToken); + } + + /// + /// Executes a single tool call: resolves the tool, runs it, and produces both a result + /// for the AI and an optional action result for the frontend. + /// + private async Task ExecuteSingleToolCallAsync( + AiToolCall call, + ProcessUserChatCommand request, + IAgentOperationExecutor operationExecutor, + IPendingClarificationStore clarificationStore, + CancellationToken cancellationToken) + { + var tool = ai.ToolRegistry.GetTool(call.Name); + if (tool is null) + { + LogUnknownToolRequested(logger, call.Name); + return UnknownToolOutcome(call); + } + + var capability = ai.CatalogService.GetCapabilityByChatTool(call.Name); + if (capability is null) + return UnsupportedByPolicyOutcome(call, tool); + + var executionResponse = await DispatchToolCallAsync(call, request, operationExecutor, cancellationToken); + var operationResult = executionResponse.Operation; + var toolResult = BuildToolCallResult(call, operationResult); + LogToolCallOutcome(call, operationResult); + + if (operationResult.Status == AgentOperationStatus.Succeeded + && operationResult.Payload is NeedsClarificationPayload payload) + { + return await StashClarificationAsync(call, request, clarificationStore, toolResult, operationResult, executionResponse, payload, cancellationToken); + } + + return operationResult.Status switch + { + AgentOperationStatus.PendingConfirmation => new ToolCallOutcome( + new AiToolCallResult(call.Name, call.Id, false, null, null, "Confirmation required before this action can run."), + null, + null, + executionResponse.PolicyDenial, + executionResponse.PendingOperation), + AgentOperationStatus.Denied or AgentOperationStatus.UnsupportedByPolicy => new ToolCallOutcome( + toolResult, + tool.IsReadOnly ? null : new ActionResult(ToolNameToPascalCase(call.Name), ActionStatus.Failed, Error: toolResult.Error), + operationResult, + executionResponse.PolicyDenial, + null), + _ => new ToolCallOutcome( + toolResult, + BuildActionResult(call, tool, ToToolResult(operationResult)), + operationResult, + executionResponse.PolicyDenial, + executionResponse.PendingOperation) + }; + } + + private static ToolCallOutcome UnknownToolOutcome(AiToolCall call) + { + return new ToolCallOutcome( + new AiToolCallResult(call.Name, call.Id, false, null, null, $"Unknown tool: {call.Name}"), + new ActionResult(ToolNameToPascalCase(call.Name), ActionStatus.Failed, Error: $"Unknown tool: {call.Name}"), + new AgentOperationResult( + call.Name, + call.Name, + AgentRiskClass.Low, + AgentConfirmationRequirement.None, + AgentOperationStatus.UnsupportedByPolicy, + PolicyReason: UnsupportedByPolicyReason), + new AgentPolicyDenial( + call.Name, + call.Name, + AgentRiskClass.Low, + AgentConfirmationRequirement.None, + UnsupportedByPolicyReason), + null); + } + + private static ToolCallOutcome UnsupportedByPolicyOutcome(AiToolCall call, IAiTool tool) + { + return new ToolCallOutcome( + new AiToolCallResult(call.Name, call.Id, false, null, null, "Operation is unsupported by policy."), + tool.IsReadOnly ? null : new ActionResult(ToolNameToPascalCase(call.Name), ActionStatus.Failed, Error: "Operation is unsupported by policy."), + new AgentOperationResult( + call.Name, + call.Name, + AgentRiskClass.Low, + AgentConfirmationRequirement.None, + AgentOperationStatus.UnsupportedByPolicy, + Summary: BuildOperationSummary(call), + PolicyReason: UnsupportedByPolicyReason), + new AgentPolicyDenial( + call.Name, + call.Name, + AgentRiskClass.Low, + AgentConfirmationRequirement.None, + UnsupportedByPolicyReason), + null); + } + + private static async Task DispatchToolCallAsync( + AiToolCall call, + ProcessUserChatCommand request, + IAgentOperationExecutor operationExecutor, + CancellationToken cancellationToken) + { + var dispatchArgs = call.Name == "send_support_request" && !string.IsNullOrWhiteSpace(request.CorrelationId) + ? AppendSupportTrace(call.Args, request.CorrelationId) + : call.Args; + + return await operationExecutor.ExecuteAsync(new AgentExecuteOperationRequest( + request.UserId, + call.Name, + dispatchArgs, + AgentExecutionSurface.Chat, + request.AuthMethod, + request.GrantedScopes, + request.IsReadOnlyCredential, + request.ConfirmationToken, + request.CorrelationId), cancellationToken); + } + + private void LogToolCallOutcome(AiToolCall call, AgentOperationResult operationResult) + { + var isClarification = operationResult.Payload is NeedsClarificationPayload; + + if (operationResult.Status == AgentOperationStatus.Succeeded && !isClarification) + { + LogToolSucceeded(logger, call.Name, operationResult.TargetName); + } + else if (operationResult.Status is AgentOperationStatus.Failed or AgentOperationStatus.Denied) + { + LogToolFailed(logger, call.Name, operationResult.PolicyReason); + if (isClarification) + LogClarificationDroppedOnFailedTool(logger, call.Name, operationResult.PolicyReason); + } + } + + private async Task StashClarificationAsync( + AiToolCall call, + ProcessUserChatCommand request, + IPendingClarificationStore clarificationStore, + AiToolCallResult toolResult, + AgentOperationResult operationResult, + AgentExecuteOperationResponse executionResponse, + NeedsClarificationPayload payload, + CancellationToken cancellationToken) + { + var quickActionsJson = payload.QuickActions is null + ? "[]" + : JsonSerializer.Serialize(payload.QuickActions); + + var partialArgsJson = call.Args.GetRawText(); + if (partialArgsJson.Length > AppConstants.MaxClarificationArgsLength) + { + LogClarificationArgsTooLarge(logger, call.Name, partialArgsJson.Length); + return new ToolCallOutcome( + toolResult, + new ActionResult( + ToolNameToPascalCase(call.Name), + ActionStatus.Failed, + Error: "Tool arguments exceeded the clarification stash limit."), + operationResult, + executionResponse.PolicyDenial, + executionResponse.PendingOperation); + } + + var stashedId = await clarificationStore.CreateAsync( + request.UserId, + call.Name, + partialArgsJson, + payload.MissingArgumentKey, + payload.Question, + quickActionsJson, + cancellationToken); + LogClarificationRequested(logger, call.Name, stashedId, payload.MissingArgumentKey); + var clarification = new ClarificationRequest( + payload.Question, + stashedId, + payload.MissingArgumentKey, + payload.QuickActions ?? Array.Empty()); + return new ToolCallOutcome( + toolResult, + new ActionResult( + ToolNameToPascalCase(call.Name), + ActionStatus.NeedsClarification, + EntityName: call.Name, + ClarificationRequest: clarification), + operationResult, + executionResponse.PolicyDenial, + executionResponse.PendingOperation); + } + + private sealed record ToolCallOutcome( + AiToolCallResult ToolResult, + ActionResult? ActionResult, + AgentOperationResult? OperationResult, + AgentPolicyDenial? PolicyDenial, + PendingAgentOperation? PendingOperation); +} diff --git a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs index ca0bcb5a..71d91135 100644 --- a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs +++ b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs @@ -201,859 +201,6 @@ internal static bool IsShareableFaqTurn(ToolExecutionAccumulator results) => && results.CalledToolNames.All(name => name == DescribeFeatureToolName) && results.OperationResults.All(operation => operation.Status == AgentOperationStatus.Succeeded); - private async Task> LoadChatContextAsync( - ProcessUserChatCommand request, - CancellationToken cancellationToken) - { - LogFetchingContext(logger); - var dbStopwatch = System.Diagnostics.Stopwatch.StartNew(); - - var userHabits = await data.HabitRepository.FindAsync( - h => h.UserId == request.UserId, - q => q, - cancellationToken); - var activeHabits = userHabits.Where(habit => !habit.IsCompleted).ToList(); - var promptHabits = BuildPromptHabitIndex(userHabits); - var user = await data.UserRepository.GetByIdAsync(request.UserId, cancellationToken); - var hasProAccess = user?.HasProAccess ?? false; - var aiMemoryEnabled = user is { HasProAccess: true, AiMemoryEnabled: true }; - var userToday = await execution.UserDateService.GetUserTodayAsync(request.UserId, cancellationToken); - - IReadOnlyList activeGoals = []; - if (hasProAccess) - { - var freshStreakValues = await execution.StreakGoalReadSyncer.ComputeFreshValuesAsync(request.UserId, userToday, cancellationToken); - var loadedGoals = await data.GoalRepository.FindAsync( - g => g.UserId == request.UserId && g.Status == GoalStatus.Active, - q => q.Include(g => g.Habits), - cancellationToken); - foreach (var goal in loadedGoals) - { - if (freshStreakValues.TryGetValue(goal.Id, out var fresh)) - goal.SyncStreakProgress(fresh, allowCompletion: false); - } - activeGoals = loadedGoals; - } - - var messageGate = await execution.PayGateService.CanSendAiMessage(request.UserId, cancellationToken); - if (messageGate.IsFailure) - return messageGate.PropagateError(); - - IReadOnlyList userFacts = []; - if (aiMemoryEnabled) - { - userFacts = await data.UserFactRepository.FindAsync( - f => f.UserId == request.UserId, - cancellationToken); - } - - var userTags = await data.TagRepository.FindAsync( - t => t.UserId == request.UserId, - cancellationToken); - - var checklistTemplates = await data.ChecklistTemplateRepository.FindAsync( - template => template.UserId == request.UserId, - cancellationToken); - - var enabledFeatureFlags = await data.FeatureFlagService.GetEnabledKeysForUserAsync( - request.UserId, - cancellationToken); - - dbStopwatch.Stop(); - LogContextLoaded(logger, dbStopwatch.ElapsedMilliseconds, activeHabits.Count, userFacts.Count); - - return Result.Success(new ChatContext( - activeHabits, - promptHabits, - user, - hasProAccess, - aiMemoryEnabled, - activeGoals, - userFacts, - userTags, - checklistTemplates, - enabledFeatureFlags, - userToday, - dbStopwatch.ElapsedMilliseconds)); - } - - private async Task> RequestInitialAiResponseAsync( - ProcessUserChatCommand request, - ChatContext context, - Func? aiStreamSink, - bool skipTools, - CancellationToken cancellationToken) - { - var promptRequest = new PromptBuildRequest( - context.PromptHabits, context.UserFacts, - HasImage: request.ImageData is not null, - UserTags: context.UserTags, UserToday: context.UserToday, ActiveGoals: context.ActiveGoals); - var agentSnapshot = BuildAgentContextSnapshot( - context.User, - request.ClientContext, - context.EnabledFeatureFlags, - context.UserTags, - context.ChecklistTemplates, - context.ActiveHabits, - context.ActiveGoals, - context.HasProAccess); - - var systemPrompt = string.Join( - Environment.NewLine, - ai.PromptBuilder.BuildStatic(promptRequest), - ai.CatalogService.BuildStaticSupplement(), - ai.PromptBuilder.BuildDynamic(promptRequest), - ai.CatalogService.BuildDynamicSupplement(agentSnapshot)); - - if (request.ClientContext?.SupportsHabitListCard == true) - systemPrompt = string.Join(Environment.NewLine, systemPrompt, HabitListCardBuilder.PromptInstruction); - - if (request.ClientContext?.SupportsGoalListCard == true) - systemPrompt = string.Join(Environment.NewLine, systemPrompt, GoalListCardBuilder.PromptInstruction); - - var activeToolNames = ChatToolGroups.ResolveActiveToolNames( - ai.ToolRegistry.GetAll().Select(t => t.Name), - BuildConversationText(request)); - - var toolDeclarations = skipTools - ? new List() - : ai.ToolRegistry.GetAll() - .Where(t => activeToolNames.Contains(t.Name)) - .OrderBy(t => t.Name, StringComparer.Ordinal) - .Select(t => (object)new - { - name = t.Name, - description = t.Description, - parameters = t.GetParameterSchema() - }) - .ToList(); - - LogCallingAiIntentService(logger, toolDeclarations.Count); - - return await ai.IntentService.SendWithToolsAsync( - request.Message, - systemPrompt, - toolDeclarations, - request.UserId, - request.ImageData, - request.ImageMimeType, - request.History, - aiStreamSink, - cancellationToken); - } - - private static string BuildConversationText(ProcessUserChatCommand request) - { - if (request.History is not { Count: > 0 }) - return request.Message; - - return request.Message + " " + string.Join(" ", request.History.Select(message => message.Content)); - } - - private async Task<(AiResponse FinalResponse, int Iterations)> RunToolCallLoopAsync( - AiResponse initialResponse, - ProcessUserChatCommand request, - ToolExecutionAccumulator executionResults, - Func? aiStreamSink, - CancellationToken cancellationToken) - { - var aiResponse = initialResponse; - var iteration = 0; - - while (aiResponse.HasToolCalls && iteration < MaxToolIterations) - { - iteration++; - if (request.StreamSink is not null) - await request.StreamSink(ChatStreamEvent.Round(iteration)); - - var continueResponse = await ProcessToolCallsAsync( - aiResponse, - request, - executionResults, - iteration, - aiStreamSink, - cancellationToken); - - if (continueResponse is null) - break; - - aiResponse = continueResponse; - } - - return (aiResponse, iteration); - } - - private async Task PersistExecutionResultsAsync( - Guid userId, - IReadOnlyList actionResults, - CancellationToken cancellationToken) - { - await execution.UnitOfWork.SaveChangesAsync(cancellationToken); - if (RequiresStreakRecalculation(actionResults)) - { - await ConcurrencyRetry.SaveWithRetryAsync( - execution.UnitOfWork, - ct => execution.UserStreakService.RecalculateAsync(userId, ct), - cancellationToken); - } - } - - private sealed record ChatContext( - List ActiveHabits, - List PromptHabits, - User? User, - bool HasProAccess, - bool AiMemoryEnabled, - IReadOnlyList ActiveGoals, - IReadOnlyList UserFacts, - IReadOnlyList UserTags, - IReadOnlyList ChecklistTemplates, - IReadOnlyList EnabledFeatureFlags, - DateOnly UserToday, - long ContextLoadMilliseconds); - - private static Func? BuildAiStreamSink(Func? streamSink) - { - if (streamSink is null) - return null; - - return aiEvent => streamSink(aiEvent.Kind == AiStreamEventKind.Delta - ? ChatStreamEvent.Delta(aiEvent.Text ?? "") - : ChatStreamEvent.Reset()); - } - - /// - /// Processes one iteration of AI tool calls: orders them, executes each, and sends results - /// back to the AI. Returns the next AI response, or null if continuation failed. - /// - private async Task ProcessToolCallsAsync( - AiResponse aiResponse, - ProcessUserChatCommand request, - ToolExecutionAccumulator executionResults, - int iteration, - Func? aiStreamSink, - CancellationToken cancellationToken) - { - LogToolCallingIteration(logger, iteration, aiResponse.ToolCalls!.Count); - - var orderedCalls = aiResponse.ToolCalls! - .OrderBy(c => ai.ToolRegistry.GetTool(c.Name)?.Order ?? int.MaxValue) - .ToList(); - - var outcomesByCallId = await ExecuteToolCallsAsync(orderedCalls, request, cancellationToken); - - var toolResults = new List(orderedCalls.Count); - foreach (var call in orderedCalls) - { - var outcome = outcomesByCallId[call.Id]; - toolResults.Add(outcome.ToolResult); - executionResults.Add(call.Name, outcome.ActionResult, outcome.OperationResult, outcome.PolicyDenial, outcome.PendingOperation); - } - - var continueResult = await ai.IntentService.ContinueWithToolResultsAsync(aiResponse.ConversationContext!, toolResults, aiStreamSink, cancellationToken); - if (continueResult.IsFailure) - { - LogContinueWithToolResultsFailed(logger, continueResult.Error); - return null; - } - - return continueResult.Value; - } - - /// - /// Executes a round's tool calls, dispatching the read-only subset concurrently (each on its - /// own DI scope for DbContext isolation) and the write subset sequentially on the ambient - /// scope in Order. Returns every outcome keyed by tool-call id so the caller can - /// reassemble results deterministically, independent of task-completion timing. - /// - private async Task> ExecuteToolCallsAsync( - IReadOnlyList orderedCalls, - ProcessUserChatCommand request, - CancellationToken cancellationToken) - { - var readOnlyCalls = orderedCalls - .Where(call => ai.ToolRegistry.GetTool(call.Name)?.IsReadOnly == true) - .ToList(); - var writeCalls = orderedCalls - .Where(call => ai.ToolRegistry.GetTool(call.Name)?.IsReadOnly != true) - .ToList(); - - var readOnlyTasks = readOnlyCalls - .Select(call => ExecuteReadOnlyToolCallOnIsolatedScopeAsync(call, request, cancellationToken)) - .ToList(); - var readOnlyOutcomes = await Task.WhenAll(readOnlyTasks); - - var outcomesByCallId = new Dictionary(orderedCalls.Count, StringComparer.Ordinal); - for (var index = 0; index < readOnlyCalls.Count; index++) - outcomesByCallId[readOnlyCalls[index].Id] = readOnlyOutcomes[index]; - - foreach (var call in writeCalls) - { - outcomesByCallId[call.Id] = await ExecuteSingleToolCallAsync( - call, request, execution.OperationExecutor, execution.PendingClarificationStore, cancellationToken); - } - - return outcomesByCallId; - } - - private async Task ExecuteReadOnlyToolCallOnIsolatedScopeAsync( - AiToolCall call, - ProcessUserChatCommand request, - CancellationToken cancellationToken) - { - using var scope = execution.ServiceScopeFactory.CreateScope(); - var scopedExecutor = scope.ServiceProvider.GetRequiredService(); - var scopedClarificationStore = scope.ServiceProvider.GetRequiredService(); - - return await ExecuteSingleToolCallAsync( - call, request, scopedExecutor, scopedClarificationStore, cancellationToken); - } - - /// - /// Executes a single tool call: resolves the tool, runs it, and produces both a result - /// for the AI and an optional action result for the frontend. - /// - private async Task ExecuteSingleToolCallAsync( - AiToolCall call, - ProcessUserChatCommand request, - IAgentOperationExecutor operationExecutor, - IPendingClarificationStore clarificationStore, - CancellationToken cancellationToken) - { - var tool = ai.ToolRegistry.GetTool(call.Name); - if (tool is null) - { - LogUnknownToolRequested(logger, call.Name); - return UnknownToolOutcome(call); - } - - var capability = ai.CatalogService.GetCapabilityByChatTool(call.Name); - if (capability is null) - return UnsupportedByPolicyOutcome(call, tool); - - var executionResponse = await DispatchToolCallAsync(call, request, operationExecutor, cancellationToken); - var operationResult = executionResponse.Operation; - var toolResult = BuildToolCallResult(call, operationResult); - LogToolCallOutcome(call, operationResult); - - if (operationResult.Status == AgentOperationStatus.Succeeded - && operationResult.Payload is NeedsClarificationPayload payload) - { - return await StashClarificationAsync(call, request, clarificationStore, toolResult, operationResult, executionResponse, payload, cancellationToken); - } - - return operationResult.Status switch - { - AgentOperationStatus.PendingConfirmation => new ToolCallOutcome( - new AiToolCallResult(call.Name, call.Id, false, null, null, "Confirmation required before this action can run."), - null, - null, - executionResponse.PolicyDenial, - executionResponse.PendingOperation), - AgentOperationStatus.Denied or AgentOperationStatus.UnsupportedByPolicy => new ToolCallOutcome( - toolResult, - tool.IsReadOnly ? null : new ActionResult(ToolNameToPascalCase(call.Name), ActionStatus.Failed, Error: toolResult.Error), - operationResult, - executionResponse.PolicyDenial, - null), - _ => new ToolCallOutcome( - toolResult, - BuildActionResult(call, tool, ToToolResult(operationResult)), - operationResult, - executionResponse.PolicyDenial, - executionResponse.PendingOperation) - }; - } - - private static ToolCallOutcome UnknownToolOutcome(AiToolCall call) - { - return new ToolCallOutcome( - new AiToolCallResult(call.Name, call.Id, false, null, null, $"Unknown tool: {call.Name}"), - new ActionResult(ToolNameToPascalCase(call.Name), ActionStatus.Failed, Error: $"Unknown tool: {call.Name}"), - new AgentOperationResult( - call.Name, - call.Name, - AgentRiskClass.Low, - AgentConfirmationRequirement.None, - AgentOperationStatus.UnsupportedByPolicy, - PolicyReason: UnsupportedByPolicyReason), - new AgentPolicyDenial( - call.Name, - call.Name, - AgentRiskClass.Low, - AgentConfirmationRequirement.None, - UnsupportedByPolicyReason), - null); - } - - private static ToolCallOutcome UnsupportedByPolicyOutcome(AiToolCall call, IAiTool tool) - { - return new ToolCallOutcome( - new AiToolCallResult(call.Name, call.Id, false, null, null, "Operation is unsupported by policy."), - tool.IsReadOnly ? null : new ActionResult(ToolNameToPascalCase(call.Name), ActionStatus.Failed, Error: "Operation is unsupported by policy."), - new AgentOperationResult( - call.Name, - call.Name, - AgentRiskClass.Low, - AgentConfirmationRequirement.None, - AgentOperationStatus.UnsupportedByPolicy, - Summary: BuildOperationSummary(call), - PolicyReason: UnsupportedByPolicyReason), - new AgentPolicyDenial( - call.Name, - call.Name, - AgentRiskClass.Low, - AgentConfirmationRequirement.None, - UnsupportedByPolicyReason), - null); - } - - private static async Task DispatchToolCallAsync( - AiToolCall call, - ProcessUserChatCommand request, - IAgentOperationExecutor operationExecutor, - CancellationToken cancellationToken) - { - var dispatchArgs = call.Name == "send_support_request" && !string.IsNullOrWhiteSpace(request.CorrelationId) - ? AppendSupportTrace(call.Args, request.CorrelationId) - : call.Args; - - return await operationExecutor.ExecuteAsync(new AgentExecuteOperationRequest( - request.UserId, - call.Name, - dispatchArgs, - AgentExecutionSurface.Chat, - request.AuthMethod, - request.GrantedScopes, - request.IsReadOnlyCredential, - request.ConfirmationToken, - request.CorrelationId), cancellationToken); - } - - private void LogToolCallOutcome(AiToolCall call, AgentOperationResult operationResult) - { - var isClarification = operationResult.Payload is NeedsClarificationPayload; - - if (operationResult.Status == AgentOperationStatus.Succeeded && !isClarification) - { - LogToolSucceeded(logger, call.Name, operationResult.TargetName); - } - else if (operationResult.Status is AgentOperationStatus.Failed or AgentOperationStatus.Denied) - { - LogToolFailed(logger, call.Name, operationResult.PolicyReason); - if (isClarification) - LogClarificationDroppedOnFailedTool(logger, call.Name, operationResult.PolicyReason); - } - } - - private async Task StashClarificationAsync( - AiToolCall call, - ProcessUserChatCommand request, - IPendingClarificationStore clarificationStore, - AiToolCallResult toolResult, - AgentOperationResult operationResult, - AgentExecuteOperationResponse executionResponse, - NeedsClarificationPayload payload, - CancellationToken cancellationToken) - { - var quickActionsJson = payload.QuickActions is null - ? "[]" - : JsonSerializer.Serialize(payload.QuickActions); - - var partialArgsJson = call.Args.GetRawText(); - if (partialArgsJson.Length > AppConstants.MaxClarificationArgsLength) - { - LogClarificationArgsTooLarge(logger, call.Name, partialArgsJson.Length); - return new ToolCallOutcome( - toolResult, - new ActionResult( - ToolNameToPascalCase(call.Name), - ActionStatus.Failed, - Error: "Tool arguments exceeded the clarification stash limit."), - operationResult, - executionResponse.PolicyDenial, - executionResponse.PendingOperation); - } - - var stashedId = await clarificationStore.CreateAsync( - request.UserId, - call.Name, - partialArgsJson, - payload.MissingArgumentKey, - payload.Question, - quickActionsJson, - cancellationToken); - LogClarificationRequested(logger, call.Name, stashedId, payload.MissingArgumentKey); - var clarification = new ClarificationRequest( - payload.Question, - stashedId, - payload.MissingArgumentKey, - payload.QuickActions ?? Array.Empty()); - return new ToolCallOutcome( - toolResult, - new ActionResult( - ToolNameToPascalCase(call.Name), - ActionStatus.NeedsClarification, - EntityName: call.Name, - ClarificationRequest: clarification), - operationResult, - executionResponse.PolicyDenial, - executionResponse.PendingOperation); - } - - private sealed record ToolCallOutcome( - AiToolCallResult ToolResult, - ActionResult? ActionResult, - AgentOperationResult? OperationResult, - AgentPolicyDenial? PolicyDenial, - PendingAgentOperation? PendingOperation); - - /// - /// Builds the frontend-facing ActionResult from a tool execution result. - /// Returns null for read-only tools (they don't produce action chips). - /// - private static ActionResult? BuildActionResult(AiToolCall call, IAiTool tool, ToolResult result) - { - if (tool.IsReadOnly) - return null; - - if (!result.Success) - { - return new ActionResult( - ToolNameToPascalCase(call.Name), - ActionStatus.Failed, - EntityName: result.EntityName, - Error: result.Error); - } - - if (call.Name == "suggest_breakdown") - { - return new ActionResult( - ToolNameToPascalCase(call.Name), - ActionStatus.Suggestion, - EntityName: result.EntityName, - SuggestedSubHabits: ExtractSuggestedSubHabits(call.Args)); - } - - return new ActionResult( - ToolNameToPascalCase(call.Name), - ActionStatus.Success, - result.EntityId is not null ? Guid.Parse(result.EntityId) : null, - result.EntityName); - } - - private static AgentContextSnapshot BuildAgentContextSnapshot( - User? user, - AgentClientContext? clientContext, - IReadOnlyList featureFlags, - IReadOnlyCollection userTags, - IReadOnlyCollection checklistTemplates, - IReadOnlyCollection activeHabits, - IReadOnlyCollection activeGoals, - bool hasProAccess) - { - return new AgentContextSnapshot( - hasProAccess ? "pro" : "free", - user?.Language, - user?.TimeZone, - hasProAccess && (user?.AiMemoryEnabled ?? true), - hasProAccess && (user?.AiSummaryEnabled ?? true), - user?.WeekStartDay ?? 1, - user?.ThemePreference, - hasProAccess ? user?.ColorScheme : null, - hasProAccess && user?.GoogleAccessToken is not null, - hasProAccess && (user?.GoogleCalendarAutoSyncEnabled ?? false), - hasProAccess - ? (user?.GoogleCalendarAutoSyncStatus ?? GoogleCalendarAutoSyncStatus.Idle).ToString() - : "Locked", - featureFlags, - userTags - .Select(tag => tag.Name) - .OrderBy(name => name, StringComparer.OrdinalIgnoreCase) - .Take(12) - .ToList(), - checklistTemplates - .Select(template => template.Name) - .OrderBy(name => name, StringComparer.OrdinalIgnoreCase) - .Take(10) - .ToList(), - activeHabits - .OrderByDescending(habit => habit.UpdatedAtUtc) - .Select(habit => habit.Title) - .Distinct(StringComparer.OrdinalIgnoreCase) - .Take(8) - .ToList(), - hasProAccess - ? activeGoals - .OrderByDescending(goal => goal.UpdatedAtUtc) - .Select(goal => goal.Title) - .Distinct(StringComparer.OrdinalIgnoreCase) - .Take(8) - .ToList() - : [], - ClientContext: clientContext); - } - - private static List BuildPromptHabitIndex(IReadOnlyCollection userHabits) - { - if (userHabits.Count == 0) - return []; - - var habitsById = userHabits.ToDictionary(habit => habit.Id); - var indexedHabitIds = new HashSet(); - - foreach (var habit in userHabits.Where(habit => !habit.IsCompleted)) - { - var current = habit; - - while (indexedHabitIds.Add(current.Id) && - current.ParentHabitId is Guid parentId && - habitsById.TryGetValue(parentId, out var parent)) - { - current = parent; - } - } - - return userHabits - .Where(habit => indexedHabitIds.Contains(habit.Id)) - .ToList(); - } - - private static string BuildOperationSummary(AiToolCall call) - { - return $"{ToolNameToPascalCase(call.Name)} requested via chat"; - } - - private static AiToolCallResult BuildToolCallResult(AiToolCall call, AgentOperationResult operationResult) - { - return new AiToolCallResult( - call.Name, - call.Id, - operationResult.Status == AgentOperationStatus.Succeeded, - operationResult.TargetId, - operationResult.TargetName, - BuildToolError(operationResult), - operationResult.Payload); - } - - private static string? BuildToolError(AgentOperationResult operationResult) - { - return operationResult.Status switch - { - AgentOperationStatus.Denied => $"Policy denied: {operationResult.PolicyReason}", - AgentOperationStatus.UnsupportedByPolicy => "Operation is unsupported by policy.", - AgentOperationStatus.PendingConfirmation => "Confirmation required before this action can run.", - AgentOperationStatus.Failed => string.Equals(operationResult.PolicyReason, "unexpected_error", StringComparison.Ordinal) - ? "An unexpected error occurred." - : operationResult.PolicyReason, - _ => null - }; - } - - private static ToolResult ToToolResult(AgentOperationResult operationResult) - { - return new ToolResult( - operationResult.Status == AgentOperationStatus.Succeeded, - operationResult.TargetId, - operationResult.TargetName, - BuildToolError(operationResult), - operationResult.Payload); - } - - /// - /// Strips a JSON wrapper from the AI response text, extracting the "aiMessage" property - /// if the model returned a raw JSON object instead of using function calling. - /// - private static string? StripJsonWrapper(string? text) - { - if (text is null || !text.TrimStart().StartsWith('{')) - return text; - - try - { - using var doc = JsonDocument.Parse(text); - if (doc.RootElement.TryGetProperty("aiMessage", out var msgEl)) - return msgEl.GetString(); - } - catch (JsonException) - { - } - - return text; - } - - private static bool RequiresStreakRecalculation(IEnumerable actionResults) - { - return actionResults.Any(action => action.Status == ActionStatus.Success && action.Type is "LogHabit" or "BulkLogHabits" or "DeleteHabit"); - } - - internal sealed class ToolExecutionAccumulator - { - private readonly List _relatedSurfaces = []; - private readonly HashSet _seenRelatedSurfaces = new(StringComparer.Ordinal); - private readonly HashSet _calledToolNames = new(StringComparer.Ordinal); - - public List ActionResults { get; } = []; - public List OperationResults { get; } = []; - public List PendingOperations { get; } = []; - public List PolicyDenials { get; } = []; - - /// - /// Distinct names of every tool invoked this turn, used to decide whether the turn is safe to - /// serve from the shared FAQ cache (only static, user-data-free tools may have run). - /// - public IReadOnlyCollection CalledToolNames => _calledToolNames; - - /// - /// App surface IDs (e.g. "today", "gamification") surfaced by read-only tools such as - /// describe_feature, deduplicated in first-seen order. The client maps these to deep links. - /// - public IReadOnlyList RelatedSurfaces => _relatedSurfaces; - - public void Add( - string toolName, - ActionResult? actionResult, - AgentOperationResult? operationResult, - AgentPolicyDenial? policyDenial, - PendingAgentOperation? pendingOperation) - { - _calledToolNames.Add(toolName); - - if (actionResult is not null) - ActionResults.Add(actionResult); - - if (operationResult is not null) - { - OperationResults.Add(operationResult); - CollectRelatedSurfaces(operationResult); - } - - if (policyDenial is not null) - PolicyDenials.Add(policyDenial); - - if (pendingOperation is not null) - PendingOperations.Add(pendingOperation); - } - - private void CollectRelatedSurfaces(AgentOperationResult operationResult) - { - if (operationResult.Status != AgentOperationStatus.Succeeded) - return; - - foreach (var surface in ExtractRelatedSurfaces(operationResult.Payload)) - { - if (_seenRelatedSurfaces.Add(surface)) - _relatedSurfaces.Add(surface); - } - } - } - - /// - /// Reads the optional "related_surfaces" string array from a tool's anonymous payload - /// (e.g. describe_feature) by round-tripping it through JSON. Returns an empty sequence - /// when the payload is null, not an object, or carries no usable surface IDs. - /// - private static IEnumerable ExtractRelatedSurfaces(object? payload) - { - if (payload is null) - return []; - - JsonElement element; - try - { - element = JsonSerializer.SerializeToElement(payload); - } - catch (NotSupportedException) - { - return []; - } - - if (element.ValueKind != JsonValueKind.Object - || !element.TryGetProperty("related_surfaces", out var surfaces) - || surfaces.ValueKind != JsonValueKind.Array) - { - return []; - } - - return surfaces - .EnumerateArray() - .Where(item => item.ValueKind == JsonValueKind.String) - .Select(item => item.GetString()) - .Where(value => !string.IsNullOrWhiteSpace(value)) - .Select(value => value!) - .ToList(); - } - - /// - /// Fires off background work for fact extraction and AI message counter increment. - /// Runs in a separate DI scope so it doesn't block the response. - /// - private void RunBackgroundPostResponseWork( - Guid userId, - string userMessage, - string? aiMessage, - bool shouldExtractFacts, - IReadOnlyList existingFacts) - { - _ = Task.Run(async () => - { - try - { - using var scope = execution.ServiceScopeFactory.CreateScope(); - var bgUnitOfWork = scope.ServiceProvider.GetRequiredService(); - var bgUserRepo = scope.ServiceProvider.GetRequiredService>(); - var bgLogger = scope.ServiceProvider.GetRequiredService>(); - - if (shouldExtractFacts) - await SubmitFactExtractionBatchAsync(scope, userId, userMessage, aiMessage, existingFacts); - - await IncrementAiMessageCountAsync(bgUserRepo, bgUnitOfWork, userId, bgLogger); - } - catch (Exception ex) - { - LogBackgroundPostResponseFailed(logger, ex); - } - }, CancellationToken.None); - } - - private static async Task SubmitFactExtractionBatchAsync( - IServiceScope scope, - Guid userId, - string userMessage, - string? aiMessage, - IReadOnlyList existingFacts) - { - var bgFactService = scope.ServiceProvider.GetRequiredService(); - await bgFactService.SubmitBatchAsync(userMessage: userMessage, aiResponse: aiMessage, - existingFacts: existingFacts, userId: userId, cancellationToken: CancellationToken.None); - } - - private static async Task IncrementAiMessageCountAsync( - IGenericRepository bgUserRepo, - IUnitOfWork bgUnitOfWork, - Guid userId, - ILogger bgLogger) - { - try - { - await ConcurrencyRetry.ExecuteAsync( - bgUserRepo, - bgUnitOfWork, - ct => bgUserRepo.FindOneTrackedAsync(u => u.Id == userId, cancellationToken: ct), - user => - { - user.IncrementAiMessageCount(); - return Task.FromResult(Result.Success()); - }, - ErrorMessages.UserNotFound, - CancellationToken.None); - } - catch (Exception ex) - { - LogBackgroundMessageCounterFailed(bgLogger, ex); - } - } - [LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "Processing chat message: '{Message}'")] private static partial void LogProcessingChatMessage(ILogger logger, string message); @@ -1125,103 +272,4 @@ await ConcurrencyRetry.ExecuteAsync( [LoggerMessage(EventId = 23, Level = LogLevel.Warning, Message = "Background post-response work failed")] private static partial void LogBackgroundPostResponseFailed(ILogger logger, Exception ex); - - /// - /// Converts snake_case tool names to PascalCase for backward compatibility with the frontend. - /// e.g., "log_habit" -> "LogHabit", "create_sub_habit" -> "CreateSubHabit" - /// - private static string ToolNameToPascalCase(string toolName) - { - var parts = toolName.Split('_'); - return string.Concat(parts.Select(p => - string.IsNullOrEmpty(p) ? p : char.ToUpper(p[0], CultureInfo.InvariantCulture) + p[1..])); - } - - /// - /// Returns a copy of the send_support_request args with the correlation id appended to - /// the message body as a "[trace: {id}]" line, so emailed support tickets carry the trace. - /// The append respects the support Message length cap; if there is no string message the - /// args are returned unchanged. - /// - private static JsonElement AppendSupportTrace(JsonElement args, string correlationId) - { - var node = JsonNode.Parse(args.GetRawText()); - if (node is not JsonObject argsObject || argsObject["message"] is not JsonValue messageValue - || !messageValue.TryGetValue(out string? message) || message is null) - { - return args; - } - - var suffix = $"\n\n[trace: {correlationId}]"; - var available = MaxSupportMessageLength - suffix.Length; - var trimmedMessage = message.Length > available ? message[..Math.Max(0, available)] : message; - argsObject["message"] = trimmedMessage + suffix; - - return JsonSerializer.Deserialize(argsObject.ToJsonString()); - } - - /// - /// Extracts suggested sub-habits from the suggest_breakdown tool call args - /// for backward-compatible ActionResult.SuggestedSubHabits. - /// - private static List? ExtractSuggestedSubHabits(JsonElement args) - { - if (!args.TryGetProperty("suggested_sub_habits", out var subHabitsEl) || - subHabitsEl.ValueKind != JsonValueKind.Array) - return null; - - var suggestions = new List(); - foreach (var item in subHabitsEl.EnumerateArray()) - suggestions.Add(ParseSingleSubHabit(item)); - - return suggestions.Count > 0 ? suggestions : null; - } - - private static AiAction ParseSingleSubHabit(JsonElement item) - { - return new AiAction - { - Type = AiActionType.SuggestBreakdown, - Title = GetStringProperty(item, "title"), - Description = GetStringProperty(item, "description"), - FrequencyUnit = GetEnumProperty(item, "frequency_unit"), - FrequencyQuantity = GetIntProperty(item, "frequency_quantity"), - Days = GetDaysProperty(item) - }; - } - - private static string? GetStringProperty(JsonElement element, string propertyName) - { - return element.TryGetProperty(propertyName, out var el) && el.ValueKind == JsonValueKind.String - ? el.GetString() : null; - } - - private static TEnum? GetEnumProperty(JsonElement element, string propertyName) where TEnum : struct, Enum - { - return element.TryGetProperty(propertyName, out var el) && el.ValueKind == JsonValueKind.String - && Enum.TryParse(el.GetString(), true, out var value) - ? value : null; - } - - private static int? GetIntProperty(JsonElement element, string propertyName) - { - return element.TryGetProperty(propertyName, out var el) && el.ValueKind == JsonValueKind.Number - ? el.GetInt32() : null; - } - - private static List? GetDaysProperty(JsonElement item) - { - if (!item.TryGetProperty("days", out var daysEl) || daysEl.ValueKind != JsonValueKind.Array) - return null; - - var days = new List(); - foreach (var dayEl in daysEl.EnumerateArray()) - { - if (dayEl.ValueKind == JsonValueKind.String && - Enum.TryParse(dayEl.GetString(), true, out var dow)) - days.Add(dow); - } - - return days; - } } diff --git a/src/Orbit.Application/Chat/Tools/Implementations/GetApiKeysTool.cs b/src/Orbit.Application/Chat/Tools/Implementations/GetApiKeysTool.cs new file mode 100644 index 00000000..de507e9a --- /dev/null +++ b/src/Orbit.Application/Chat/Tools/Implementations/GetApiKeysTool.cs @@ -0,0 +1,26 @@ +using System.Text.Json; +using MediatR; +using Orbit.Application.ApiKeys.Queries; + +namespace Orbit.Application.Chat.Tools.Implementations; + +public class GetApiKeysTool(IMediator mediator) : IAiTool +{ + public string Name => "get_api_keys"; + public string Description => "Read the user's API keys, scopes, last use, and revocation state."; + public bool IsReadOnly => true; + + public object GetParameterSchema() => new + { + type = JsonSchemaTypes.Object, + properties = new { } + }; + + public async Task ExecuteAsync(JsonElement args, Guid userId, CancellationToken ct) + { + var result = await mediator.Send(new GetApiKeysQuery(userId), ct); + return result.IsSuccess + ? new ToolResult(true, Payload: result.Value) + : ToolResult.FromFailure(result); + } +} diff --git a/src/Orbit.Application/Chat/Tools/Implementations/GetGamificationOverviewTool.cs b/src/Orbit.Application/Chat/Tools/Implementations/GetGamificationOverviewTool.cs new file mode 100644 index 00000000..192c81c4 --- /dev/null +++ b/src/Orbit.Application/Chat/Tools/Implementations/GetGamificationOverviewTool.cs @@ -0,0 +1,57 @@ +using System.Text.Json; +using MediatR; +using Orbit.Application.Gamification.Queries; + +namespace Orbit.Application.Chat.Tools.Implementations; + +public class GetGamificationOverviewTool(IMediator mediator) : IAiTool +{ + public string Name => "get_gamification_overview"; + public string Description => "Read the user's gamification profile, achievements, and streak information."; + public bool IsReadOnly => true; + + public object GetParameterSchema() => new + { + type = JsonSchemaTypes.Object, + properties = new + { + include_profile = new { type = JsonSchemaTypes.Boolean }, + include_achievements = new { type = JsonSchemaTypes.Boolean }, + include_streak = new { type = JsonSchemaTypes.Boolean } + } + }; + + public async Task ExecuteAsync(JsonElement args, Guid userId, CancellationToken ct) + { + var includeProfile = JsonArgumentParser.GetOptionalBool(args, "include_profile") ?? true; + var includeAchievements = JsonArgumentParser.GetOptionalBool(args, "include_achievements") ?? true; + var includeStreak = JsonArgumentParser.GetOptionalBool(args, "include_streak") ?? true; + + object? profile = null; + object? achievements = null; + object? streak = null; + + if (includeProfile) + { + var profileResult = await mediator.Send(new GetGamificationProfileQuery(userId), ct); + if (profileResult.IsFailure) return ToolResult.FromFailure(profileResult); + profile = profileResult.Value; + } + + if (includeAchievements) + { + var achievementsResult = await mediator.Send(new GetAchievementsQuery(userId), ct); + if (achievementsResult.IsFailure) return ToolResult.FromFailure(achievementsResult); + achievements = achievementsResult.Value; + } + + if (includeStreak) + { + var streakResult = await mediator.Send(new GetStreakInfoQuery(userId), ct); + if (streakResult.IsFailure) return ToolResult.FromFailure(streakResult); + streak = streakResult.Value; + } + + return new ToolResult(true, Payload: new { profile, achievements, streak }); + } +} diff --git a/src/Orbit.Application/Chat/Tools/Implementations/GetReferralCodeTool.cs b/src/Orbit.Application/Chat/Tools/Implementations/GetReferralCodeTool.cs new file mode 100644 index 00000000..a911d85c --- /dev/null +++ b/src/Orbit.Application/Chat/Tools/Implementations/GetReferralCodeTool.cs @@ -0,0 +1,25 @@ +using System.Text.Json; +using MediatR; +using Orbit.Application.Referrals.Commands; + +namespace Orbit.Application.Chat.Tools.Implementations; + +public class GetReferralCodeTool(IMediator mediator) : IAiTool +{ + public string Name => "get_referral_code"; + public string Description => "Get or create the user's referral code (generates one if absent)."; + + public object GetParameterSchema() => new + { + type = JsonSchemaTypes.Object, + properties = new { } + }; + + public async Task ExecuteAsync(JsonElement args, Guid userId, CancellationToken ct) + { + var result = await mediator.Send(new GetOrCreateReferralCodeCommand(userId), ct); + return result.IsSuccess + ? new ToolResult(true, EntityId: userId.ToString(), EntityName: result.Value, Payload: new { code = result.Value }) + : ToolResult.FromFailure(result); + } +} diff --git a/src/Orbit.Application/Chat/Tools/Implementations/GetReferralOverviewTool.cs b/src/Orbit.Application/Chat/Tools/Implementations/GetReferralOverviewTool.cs new file mode 100644 index 00000000..784952b2 --- /dev/null +++ b/src/Orbit.Application/Chat/Tools/Implementations/GetReferralOverviewTool.cs @@ -0,0 +1,26 @@ +using System.Text.Json; +using MediatR; +using Orbit.Application.Referrals.Queries; + +namespace Orbit.Application.Chat.Tools.Implementations; + +public class GetReferralOverviewTool(IMediator mediator) : IAiTool +{ + public string Name => "get_referral_overview"; + public string Description => "Read the user's referral dashboard, code, link, and stats."; + public bool IsReadOnly => true; + + public object GetParameterSchema() => new + { + type = JsonSchemaTypes.Object, + properties = new { } + }; + + public async Task ExecuteAsync(JsonElement args, Guid userId, CancellationToken ct) + { + var result = await mediator.Send(new GetReferralDashboardQuery(userId), ct); + return result.IsSuccess + ? new ToolResult(true, Payload: result.Value) + : ToolResult.FromFailure(result); + } +} diff --git a/src/Orbit.Application/Chat/Tools/Implementations/GetSubscriptionOverviewTool.cs b/src/Orbit.Application/Chat/Tools/Implementations/GetSubscriptionOverviewTool.cs new file mode 100644 index 00000000..444faef7 --- /dev/null +++ b/src/Orbit.Application/Chat/Tools/Implementations/GetSubscriptionOverviewTool.cs @@ -0,0 +1,57 @@ +using System.Text.Json; +using MediatR; +using Orbit.Application.Subscriptions.Queries; + +namespace Orbit.Application.Chat.Tools.Implementations; + +public class GetSubscriptionOverviewTool(IMediator mediator) : IAiTool +{ + public string Name => "get_subscription_overview"; + public string Description => "Read subscription status, billing details, and available plans."; + public bool IsReadOnly => true; + + public object GetParameterSchema() => new + { + type = JsonSchemaTypes.Object, + properties = new + { + include_status = new { type = JsonSchemaTypes.Boolean }, + include_billing = new { type = JsonSchemaTypes.Boolean }, + include_plans = new { type = JsonSchemaTypes.Boolean } + } + }; + + public async Task ExecuteAsync(JsonElement args, Guid userId, CancellationToken ct) + { + var includeStatus = JsonArgumentParser.GetOptionalBool(args, "include_status") ?? true; + var includeBilling = JsonArgumentParser.GetOptionalBool(args, "include_billing") ?? true; + var includePlans = JsonArgumentParser.GetOptionalBool(args, "include_plans") ?? true; + + object? status = null; + object? billing = null; + object? plans = null; + + if (includeStatus) + { + var statusResult = await mediator.Send(new GetSubscriptionStatusQuery(userId), ct); + if (statusResult.IsFailure) return ToolResult.FromFailure(statusResult); + status = statusResult.Value; + } + + if (includeBilling) + { + var billingResult = await mediator.Send(new GetBillingDetailsQuery(userId), ct); + if (billingResult.IsFailure) return ToolResult.FromFailure(billingResult); + billing = billingResult.Value; + } + + if (includePlans) + { + var plansResult = await mediator.Send(new GetPlansQuery(userId, null, null), ct); + if (plansResult.IsFailure) return ToolResult.FromFailure(plansResult); + plans = plansResult.Value; + } + + return new ToolResult(true, Payload: new { status, billing, plans }); + } +} diff --git a/src/Orbit.Application/Chat/Tools/Implementations/ManageAccountTool.cs b/src/Orbit.Application/Chat/Tools/Implementations/ManageAccountTool.cs new file mode 100644 index 00000000..8c730076 --- /dev/null +++ b/src/Orbit.Application/Chat/Tools/Implementations/ManageAccountTool.cs @@ -0,0 +1,66 @@ +using System.Text.Json; +using MediatR; +using Orbit.Application.Auth.Commands; +using Orbit.Application.Profile.Commands; + +namespace Orbit.Application.Chat.Tools.Implementations; + +public class ManageAccountTool(IMediator mediator) : IAiTool +{ + public string Name => "manage_account"; + public string Description => "Reset the account, request an account deletion code, or confirm account deletion with a code."; + + public object GetParameterSchema() => new + { + type = JsonSchemaTypes.Object, + properties = new + { + action = new { type = JsonSchemaTypes.String, @enum = new[] { "reset_account", "request_deletion", "confirm_deletion" } }, + code = new { type = JsonSchemaTypes.String, nullable = true } + }, + required = new[] { "action" } + }; + + public async Task ExecuteAsync(JsonElement args, Guid userId, CancellationToken ct) + { + var action = JsonArgumentParser.GetOptionalString(args, "action"); + if (string.IsNullOrWhiteSpace(action)) + return new ToolResult(false, Error: "action is required."); + + return action switch + { + "reset_account" => await ResetAccountAsync(userId, ct), + "request_deletion" => await RequestDeletionAsync(userId, ct), + "confirm_deletion" => await ConfirmDeletionAsync(args, userId, ct), + _ => new ToolResult(false, Error: $"Unsupported action '{action}'.") + }; + } + + private async Task ResetAccountAsync(Guid userId, CancellationToken ct) + { + var result = await mediator.Send(new ResetAccountCommand(userId), ct); + return result.IsSuccess + ? new ToolResult(true, EntityId: userId.ToString(), EntityName: "Account reset completed", Payload: new { success = true }) + : ToolResult.FromFailure(result, userId.ToString()); + } + + private async Task RequestDeletionAsync(Guid userId, CancellationToken ct) + { + var result = await mediator.Send(new RequestAccountDeletionCommand(userId), ct); + return result.IsSuccess + ? new ToolResult(true, EntityId: userId.ToString(), EntityName: "Deletion code requested", Payload: new { success = true }) + : ToolResult.FromFailure(result, userId.ToString()); + } + + private async Task ConfirmDeletionAsync(JsonElement args, Guid userId, CancellationToken ct) + { + var code = JsonArgumentParser.GetOptionalString(args, "code"); + if (string.IsNullOrWhiteSpace(code)) + return new ToolResult(false, Error: "code is required."); + + var result = await mediator.Send(new ConfirmAccountDeletionCommand(userId, code), ct); + return result.IsSuccess + ? new ToolResult(true, EntityId: userId.ToString(), EntityName: "Account deletion confirmed", Payload: new { scheduledDeletionAt = result.Value }) + : ToolResult.FromFailure(result, userId.ToString()); + } +} diff --git a/src/Orbit.Application/Chat/Tools/Implementations/ManageApiKeysTool.cs b/src/Orbit.Application/Chat/Tools/Implementations/ManageApiKeysTool.cs new file mode 100644 index 00000000..663f0ee8 --- /dev/null +++ b/src/Orbit.Application/Chat/Tools/Implementations/ManageApiKeysTool.cs @@ -0,0 +1,101 @@ +using System.Globalization; +using System.Text.Json; +using MediatR; +using Orbit.Application.ApiKeys.Commands; + +namespace Orbit.Application.Chat.Tools.Implementations; + +public class ManageApiKeysTool(IMediator mediator) : IAiTool +{ + public string Name => "manage_api_keys"; + public string Description => "Create or revoke scoped API keys."; + + public object GetParameterSchema() => new + { + type = JsonSchemaTypes.Object, + properties = new + { + action = new { type = JsonSchemaTypes.String, @enum = new[] { "create", "revoke" } }, + key_id = new { type = JsonSchemaTypes.String, nullable = true }, + name = new { type = JsonSchemaTypes.String, nullable = true }, + scopes = new + { + type = JsonSchemaTypes.Array, + nullable = true, + items = new { type = JsonSchemaTypes.String } + }, + is_read_only = new { type = JsonSchemaTypes.Boolean, nullable = true }, + expires_at_utc = new { type = JsonSchemaTypes.String, nullable = true, description = "ISO-8601 UTC timestamp." } + }, + required = new[] { "action" } + }; + + public async Task ExecuteAsync(JsonElement args, Guid userId, CancellationToken ct) + { + var action = JsonArgumentParser.GetOptionalString(args, "action"); + if (string.IsNullOrWhiteSpace(action)) + return new ToolResult(false, Error: "action is required."); + + return action switch + { + "create" => await CreateAsync(args, userId, ct), + "revoke" => await RevokeAsync(args, userId, ct), + _ => new ToolResult(false, Error: $"Unsupported action '{action}'.") + }; + } + + private async Task CreateAsync(JsonElement args, Guid userId, CancellationToken ct) + { + var name = JsonArgumentParser.GetOptionalString(args, "name"); + if (string.IsNullOrWhiteSpace(name)) + return new ToolResult(false, Error: "name is required."); + + var scopes = JsonArgumentParser.ParseStringArray(args, "scopes"); + var isReadOnly = JsonArgumentParser.GetOptionalBool(args, "is_read_only") ?? false; + var expiresAtValue = JsonArgumentParser.GetOptionalString(args, "expires_at_utc"); + DateTime? expiresAtUtc = null; + if (JsonArgumentParser.PropertyExists(args, "expires_at_utc") && + !TryParseUtcTimestamp(expiresAtValue, out expiresAtUtc)) + { + return new ToolResult(false, Error: "expires_at_utc must be a valid ISO-8601 UTC timestamp."); + } + + var result = await mediator.Send(new CreateApiKeyCommand(userId, name, scopes, isReadOnly, expiresAtUtc), ct); + return result.IsSuccess + ? new ToolResult(true, EntityId: result.Value.Id.ToString(), EntityName: result.Value.Name, Payload: result.Value) + : ToolResult.FromFailure(result, userId.ToString()); + } + + private async Task RevokeAsync(JsonElement args, Guid userId, CancellationToken ct) + { + var keyId = JsonArgumentParser.GetOptionalString(args, "key_id"); + if (!Guid.TryParse(keyId, out var parsedId)) + return new ToolResult(false, Error: "key_id must be a valid GUID."); + + var result = await mediator.Send(new RevokeApiKeyCommand(userId, parsedId), ct); + return result.IsSuccess + ? new ToolResult(true, EntityId: parsedId.ToString(), EntityName: "Revoked API key", Payload: new { id = parsedId }) + : ToolResult.FromFailure(result, parsedId.ToString()); + } + + private static bool TryParseUtcTimestamp(string? value, out DateTime? parsedUtc) + { + parsedUtc = null; + + if (string.IsNullOrWhiteSpace(value)) + return true; + + if (!DateTime.TryParse( + value, + CultureInfo.InvariantCulture, + DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, + out var parsed) + ) + { + return false; + } + + parsedUtc = parsed; + return true; + } +} diff --git a/src/Orbit.Application/Chat/Tools/Implementations/ManageSubscriptionTool.cs b/src/Orbit.Application/Chat/Tools/Implementations/ManageSubscriptionTool.cs new file mode 100644 index 00000000..919f3381 --- /dev/null +++ b/src/Orbit.Application/Chat/Tools/Implementations/ManageSubscriptionTool.cs @@ -0,0 +1,65 @@ +using System.Text.Json; +using MediatR; +using Orbit.Application.Subscriptions.Commands; + +namespace Orbit.Application.Chat.Tools.Implementations; + +public class ManageSubscriptionTool(IMediator mediator) : IAiTool +{ + public string Name => "manage_subscription"; + public string Description => "Create a checkout session, create a billing portal session, or claim an ad reward."; + + public object GetParameterSchema() => new + { + type = JsonSchemaTypes.Object, + properties = new + { + action = new { type = JsonSchemaTypes.String, @enum = new[] { "create_checkout", "create_portal", "claim_ad_reward" } }, + interval = new { type = JsonSchemaTypes.String, nullable = true, @enum = new[] { "monthly", "yearly" } } + }, + required = new[] { "action" } + }; + + public async Task ExecuteAsync(JsonElement args, Guid userId, CancellationToken ct) + { + var action = JsonArgumentParser.GetOptionalString(args, "action"); + if (string.IsNullOrWhiteSpace(action)) + return new ToolResult(false, Error: "action is required."); + + return action switch + { + "create_checkout" => await CreateCheckoutAsync(args, userId, ct), + "create_portal" => await CreatePortalAsync(userId, ct), + "claim_ad_reward" => await ClaimAdRewardAsync(userId, ct), + _ => new ToolResult(false, Error: $"Unsupported action '{action}'.") + }; + } + + private async Task CreateCheckoutAsync(JsonElement args, Guid userId, CancellationToken ct) + { + var interval = JsonArgumentParser.GetOptionalString(args, "interval"); + if (string.IsNullOrWhiteSpace(interval)) + return new ToolResult(false, Error: "interval is required."); + + var result = await mediator.Send(new CreateCheckoutCommand(userId, interval, null, null), ct); + return result.IsSuccess + ? new ToolResult(true, EntityId: userId.ToString(), EntityName: "Created checkout session", Payload: result.Value) + : ToolResult.FromFailure(result, userId.ToString()); + } + + private async Task CreatePortalAsync(Guid userId, CancellationToken ct) + { + var result = await mediator.Send(new CreatePortalSessionCommand(userId), ct); + return result.IsSuccess + ? new ToolResult(true, EntityId: userId.ToString(), EntityName: "Created billing portal session", Payload: result.Value) + : ToolResult.FromFailure(result, userId.ToString()); + } + + private async Task ClaimAdRewardAsync(Guid userId, CancellationToken ct) + { + var result = await mediator.Send(new ClaimAdRewardCommand(userId), ct); + return result.IsSuccess + ? new ToolResult(true, EntityId: userId.ToString(), EntityName: "Claimed ad reward", Payload: result.Value) + : ToolResult.FromFailure(result, userId.ToString()); + } +} diff --git a/src/Orbit.Application/Chat/Tools/Implementations/PlatformTools.cs b/src/Orbit.Application/Chat/Tools/Implementations/PlatformTools.cs deleted file mode 100644 index c5df4a6f..00000000 --- a/src/Orbit.Application/Chat/Tools/Implementations/PlatformTools.cs +++ /dev/null @@ -1,437 +0,0 @@ -using System.Globalization; -using System.Text.Json; -using MediatR; -using Orbit.Application.ApiKeys.Commands; -using Orbit.Application.ApiKeys.Queries; -using Orbit.Application.Chat.Tools; -using Orbit.Application.Gamification.Queries; -using Orbit.Application.Referrals.Commands; -using Orbit.Application.Referrals.Queries; -using Orbit.Application.Subscriptions.Commands; -using Orbit.Application.Subscriptions.Queries; -using Orbit.Application.Support.Commands; -using Orbit.Application.Auth.Commands; -using Orbit.Application.Profile.Commands; - -namespace Orbit.Application.Chat.Tools.Implementations; - -public class GetGamificationOverviewTool(IMediator mediator) : IAiTool -{ - public string Name => "get_gamification_overview"; - public string Description => "Read the user's gamification profile, achievements, and streak information."; - public bool IsReadOnly => true; - - public object GetParameterSchema() => new - { - type = JsonSchemaTypes.Object, - properties = new - { - include_profile = new { type = JsonSchemaTypes.Boolean }, - include_achievements = new { type = JsonSchemaTypes.Boolean }, - include_streak = new { type = JsonSchemaTypes.Boolean } - } - }; - - public async Task ExecuteAsync(JsonElement args, Guid userId, CancellationToken ct) - { - var includeProfile = JsonArgumentParser.GetOptionalBool(args, "include_profile") ?? true; - var includeAchievements = JsonArgumentParser.GetOptionalBool(args, "include_achievements") ?? true; - var includeStreak = JsonArgumentParser.GetOptionalBool(args, "include_streak") ?? true; - - object? profile = null; - object? achievements = null; - object? streak = null; - - if (includeProfile) - { - var profileResult = await mediator.Send(new GetGamificationProfileQuery(userId), ct); - if (profileResult.IsFailure) return ToolResult.FromFailure(profileResult); - profile = profileResult.Value; - } - - if (includeAchievements) - { - var achievementsResult = await mediator.Send(new GetAchievementsQuery(userId), ct); - if (achievementsResult.IsFailure) return ToolResult.FromFailure(achievementsResult); - achievements = achievementsResult.Value; - } - - if (includeStreak) - { - var streakResult = await mediator.Send(new GetStreakInfoQuery(userId), ct); - if (streakResult.IsFailure) return ToolResult.FromFailure(streakResult); - streak = streakResult.Value; - } - - return new ToolResult(true, Payload: new { profile, achievements, streak }); - } -} - -public class GetReferralOverviewTool(IMediator mediator) : IAiTool -{ - public string Name => "get_referral_overview"; - public string Description => "Read the user's referral dashboard, code, link, and stats."; - public bool IsReadOnly => true; - - public object GetParameterSchema() => new - { - type = JsonSchemaTypes.Object, - properties = new { } - }; - - public async Task ExecuteAsync(JsonElement args, Guid userId, CancellationToken ct) - { - var result = await mediator.Send(new GetReferralDashboardQuery(userId), ct); - return result.IsSuccess - ? new ToolResult(true, Payload: result.Value) - : ToolResult.FromFailure(result); - } -} - -public class GetReferralCodeTool(IMediator mediator) : IAiTool -{ - public string Name => "get_referral_code"; - public string Description => "Get or create the user's referral code (generates one if absent)."; - - public object GetParameterSchema() => new - { - type = JsonSchemaTypes.Object, - properties = new { } - }; - - public async Task ExecuteAsync(JsonElement args, Guid userId, CancellationToken ct) - { - var result = await mediator.Send(new GetOrCreateReferralCodeCommand(userId), ct); - return result.IsSuccess - ? new ToolResult(true, EntityId: userId.ToString(), EntityName: result.Value, Payload: new { code = result.Value }) - : ToolResult.FromFailure(result); - } -} - -public class GetSubscriptionOverviewTool(IMediator mediator) : IAiTool -{ - public string Name => "get_subscription_overview"; - public string Description => "Read subscription status, billing details, and available plans."; - public bool IsReadOnly => true; - - public object GetParameterSchema() => new - { - type = JsonSchemaTypes.Object, - properties = new - { - include_status = new { type = JsonSchemaTypes.Boolean }, - include_billing = new { type = JsonSchemaTypes.Boolean }, - include_plans = new { type = JsonSchemaTypes.Boolean } - } - }; - - public async Task ExecuteAsync(JsonElement args, Guid userId, CancellationToken ct) - { - var includeStatus = JsonArgumentParser.GetOptionalBool(args, "include_status") ?? true; - var includeBilling = JsonArgumentParser.GetOptionalBool(args, "include_billing") ?? true; - var includePlans = JsonArgumentParser.GetOptionalBool(args, "include_plans") ?? true; - - object? status = null; - object? billing = null; - object? plans = null; - - if (includeStatus) - { - var statusResult = await mediator.Send(new GetSubscriptionStatusQuery(userId), ct); - if (statusResult.IsFailure) return ToolResult.FromFailure(statusResult); - status = statusResult.Value; - } - - if (includeBilling) - { - var billingResult = await mediator.Send(new GetBillingDetailsQuery(userId), ct); - if (billingResult.IsFailure) return ToolResult.FromFailure(billingResult); - billing = billingResult.Value; - } - - if (includePlans) - { - var plansResult = await mediator.Send(new GetPlansQuery(userId, null, null), ct); - if (plansResult.IsFailure) return ToolResult.FromFailure(plansResult); - plans = plansResult.Value; - } - - return new ToolResult(true, Payload: new { status, billing, plans }); - } -} - -public class ManageSubscriptionTool(IMediator mediator) : IAiTool -{ - public string Name => "manage_subscription"; - public string Description => "Create a checkout session, create a billing portal session, or claim an ad reward."; - - public object GetParameterSchema() => new - { - type = JsonSchemaTypes.Object, - properties = new - { - action = new { type = JsonSchemaTypes.String, @enum = new[] { "create_checkout", "create_portal", "claim_ad_reward" } }, - interval = new { type = JsonSchemaTypes.String, nullable = true, @enum = new[] { "monthly", "yearly" } } - }, - required = new[] { "action" } - }; - - public async Task ExecuteAsync(JsonElement args, Guid userId, CancellationToken ct) - { - var action = JsonArgumentParser.GetOptionalString(args, "action"); - if (string.IsNullOrWhiteSpace(action)) - return new ToolResult(false, Error: "action is required."); - - return action switch - { - "create_checkout" => await CreateCheckoutAsync(args, userId, ct), - "create_portal" => await CreatePortalAsync(userId, ct), - "claim_ad_reward" => await ClaimAdRewardAsync(userId, ct), - _ => new ToolResult(false, Error: $"Unsupported action '{action}'.") - }; - } - - private async Task CreateCheckoutAsync(JsonElement args, Guid userId, CancellationToken ct) - { - var interval = JsonArgumentParser.GetOptionalString(args, "interval"); - if (string.IsNullOrWhiteSpace(interval)) - return new ToolResult(false, Error: "interval is required."); - - var result = await mediator.Send(new CreateCheckoutCommand(userId, interval, null, null), ct); - return result.IsSuccess - ? new ToolResult(true, EntityId: userId.ToString(), EntityName: "Created checkout session", Payload: result.Value) - : ToolResult.FromFailure(result, userId.ToString()); - } - - private async Task CreatePortalAsync(Guid userId, CancellationToken ct) - { - var result = await mediator.Send(new CreatePortalSessionCommand(userId), ct); - return result.IsSuccess - ? new ToolResult(true, EntityId: userId.ToString(), EntityName: "Created billing portal session", Payload: result.Value) - : ToolResult.FromFailure(result, userId.ToString()); - } - - private async Task ClaimAdRewardAsync(Guid userId, CancellationToken ct) - { - var result = await mediator.Send(new ClaimAdRewardCommand(userId), ct); - return result.IsSuccess - ? new ToolResult(true, EntityId: userId.ToString(), EntityName: "Claimed ad reward", Payload: result.Value) - : ToolResult.FromFailure(result, userId.ToString()); - } -} - -public class GetApiKeysTool(IMediator mediator) : IAiTool -{ - public string Name => "get_api_keys"; - public string Description => "Read the user's API keys, scopes, last use, and revocation state."; - public bool IsReadOnly => true; - - public object GetParameterSchema() => new - { - type = JsonSchemaTypes.Object, - properties = new { } - }; - - public async Task ExecuteAsync(JsonElement args, Guid userId, CancellationToken ct) - { - var result = await mediator.Send(new GetApiKeysQuery(userId), ct); - return result.IsSuccess - ? new ToolResult(true, Payload: result.Value) - : ToolResult.FromFailure(result); - } -} - -public class ManageApiKeysTool(IMediator mediator) : IAiTool -{ - public string Name => "manage_api_keys"; - public string Description => "Create or revoke scoped API keys."; - - public object GetParameterSchema() => new - { - type = JsonSchemaTypes.Object, - properties = new - { - action = new { type = JsonSchemaTypes.String, @enum = new[] { "create", "revoke" } }, - key_id = new { type = JsonSchemaTypes.String, nullable = true }, - name = new { type = JsonSchemaTypes.String, nullable = true }, - scopes = new - { - type = JsonSchemaTypes.Array, - nullable = true, - items = new { type = JsonSchemaTypes.String } - }, - is_read_only = new { type = JsonSchemaTypes.Boolean, nullable = true }, - expires_at_utc = new { type = JsonSchemaTypes.String, nullable = true, description = "ISO-8601 UTC timestamp." } - }, - required = new[] { "action" } - }; - - public async Task ExecuteAsync(JsonElement args, Guid userId, CancellationToken ct) - { - var action = JsonArgumentParser.GetOptionalString(args, "action"); - if (string.IsNullOrWhiteSpace(action)) - return new ToolResult(false, Error: "action is required."); - - return action switch - { - "create" => await CreateAsync(args, userId, ct), - "revoke" => await RevokeAsync(args, userId, ct), - _ => new ToolResult(false, Error: $"Unsupported action '{action}'.") - }; - } - - private async Task CreateAsync(JsonElement args, Guid userId, CancellationToken ct) - { - var name = JsonArgumentParser.GetOptionalString(args, "name"); - if (string.IsNullOrWhiteSpace(name)) - return new ToolResult(false, Error: "name is required."); - - var scopes = JsonArgumentParser.ParseStringArray(args, "scopes"); - var isReadOnly = JsonArgumentParser.GetOptionalBool(args, "is_read_only") ?? false; - var expiresAtValue = JsonArgumentParser.GetOptionalString(args, "expires_at_utc"); - DateTime? expiresAtUtc = null; - if (JsonArgumentParser.PropertyExists(args, "expires_at_utc") && - !TryParseUtcTimestamp(expiresAtValue, out expiresAtUtc)) - { - return new ToolResult(false, Error: "expires_at_utc must be a valid ISO-8601 UTC timestamp."); - } - - var result = await mediator.Send(new CreateApiKeyCommand(userId, name, scopes, isReadOnly, expiresAtUtc), ct); - return result.IsSuccess - ? new ToolResult(true, EntityId: result.Value.Id.ToString(), EntityName: result.Value.Name, Payload: result.Value) - : ToolResult.FromFailure(result, userId.ToString()); - } - - private async Task RevokeAsync(JsonElement args, Guid userId, CancellationToken ct) - { - var keyId = JsonArgumentParser.GetOptionalString(args, "key_id"); - if (!Guid.TryParse(keyId, out var parsedId)) - return new ToolResult(false, Error: "key_id must be a valid GUID."); - - var result = await mediator.Send(new RevokeApiKeyCommand(userId, parsedId), ct); - return result.IsSuccess - ? new ToolResult(true, EntityId: parsedId.ToString(), EntityName: "Revoked API key", Payload: new { id = parsedId }) - : ToolResult.FromFailure(result, parsedId.ToString()); - } - - private static bool TryParseUtcTimestamp(string? value, out DateTime? parsedUtc) - { - parsedUtc = null; - - if (string.IsNullOrWhiteSpace(value)) - return true; - - if (!DateTime.TryParse( - value, - CultureInfo.InvariantCulture, - DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, - out var parsed) - ) - { - return false; - } - - parsedUtc = parsed; - return true; - } -} - -public class SendSupportRequestTool(IMediator mediator) : IAiTool -{ - public string Name => "send_support_request"; - public string Description => "Send a support request on behalf of the user."; - - public object GetParameterSchema() => new - { - type = JsonSchemaTypes.Object, - properties = new - { - name = new { type = JsonSchemaTypes.String }, - email = new { type = JsonSchemaTypes.String }, - subject = new { type = JsonSchemaTypes.String }, - message = new { type = JsonSchemaTypes.String } - }, - required = new[] { "name", "email", "subject", "message" } - }; - - public async Task ExecuteAsync(JsonElement args, Guid userId, CancellationToken ct) - { - var name = JsonArgumentParser.GetOptionalString(args, "name"); - var email = JsonArgumentParser.GetOptionalString(args, "email"); - var subject = JsonArgumentParser.GetOptionalString(args, "subject"); - var message = JsonArgumentParser.GetOptionalString(args, "message"); - - if (string.IsNullOrWhiteSpace(name) || - string.IsNullOrWhiteSpace(email) || - string.IsNullOrWhiteSpace(subject) || - string.IsNullOrWhiteSpace(message)) - { - return new ToolResult(false, Error: "name, email, subject, and message are required."); - } - - var result = await mediator.Send(new SendSupportCommand(userId, name, email, subject, message), ct); - return result.IsSuccess - ? new ToolResult(true, EntityId: userId.ToString(), EntityName: "Support request sent", Payload: new { subject }) - : ToolResult.FromFailure(result, userId.ToString()); - } -} - -public class ManageAccountTool(IMediator mediator) : IAiTool -{ - public string Name => "manage_account"; - public string Description => "Reset the account, request an account deletion code, or confirm account deletion with a code."; - - public object GetParameterSchema() => new - { - type = JsonSchemaTypes.Object, - properties = new - { - action = new { type = JsonSchemaTypes.String, @enum = new[] { "reset_account", "request_deletion", "confirm_deletion" } }, - code = new { type = JsonSchemaTypes.String, nullable = true } - }, - required = new[] { "action" } - }; - - public async Task ExecuteAsync(JsonElement args, Guid userId, CancellationToken ct) - { - var action = JsonArgumentParser.GetOptionalString(args, "action"); - if (string.IsNullOrWhiteSpace(action)) - return new ToolResult(false, Error: "action is required."); - - return action switch - { - "reset_account" => await ResetAccountAsync(userId, ct), - "request_deletion" => await RequestDeletionAsync(userId, ct), - "confirm_deletion" => await ConfirmDeletionAsync(args, userId, ct), - _ => new ToolResult(false, Error: $"Unsupported action '{action}'.") - }; - } - - private async Task ResetAccountAsync(Guid userId, CancellationToken ct) - { - var result = await mediator.Send(new ResetAccountCommand(userId), ct); - return result.IsSuccess - ? new ToolResult(true, EntityId: userId.ToString(), EntityName: "Account reset completed", Payload: new { success = true }) - : ToolResult.FromFailure(result, userId.ToString()); - } - - private async Task RequestDeletionAsync(Guid userId, CancellationToken ct) - { - var result = await mediator.Send(new RequestAccountDeletionCommand(userId), ct); - return result.IsSuccess - ? new ToolResult(true, EntityId: userId.ToString(), EntityName: "Deletion code requested", Payload: new { success = true }) - : ToolResult.FromFailure(result, userId.ToString()); - } - - private async Task ConfirmDeletionAsync(JsonElement args, Guid userId, CancellationToken ct) - { - var code = JsonArgumentParser.GetOptionalString(args, "code"); - if (string.IsNullOrWhiteSpace(code)) - return new ToolResult(false, Error: "code is required."); - - var result = await mediator.Send(new ConfirmAccountDeletionCommand(userId, code), ct); - return result.IsSuccess - ? new ToolResult(true, EntityId: userId.ToString(), EntityName: "Account deletion confirmed", Payload: new { scheduledDeletionAt = result.Value }) - : ToolResult.FromFailure(result, userId.ToString()); - } -} diff --git a/src/Orbit.Application/Chat/Tools/Implementations/SendSupportRequestTool.cs b/src/Orbit.Application/Chat/Tools/Implementations/SendSupportRequestTool.cs new file mode 100644 index 00000000..90cd8ecc --- /dev/null +++ b/src/Orbit.Application/Chat/Tools/Implementations/SendSupportRequestTool.cs @@ -0,0 +1,45 @@ +using System.Text.Json; +using MediatR; +using Orbit.Application.Support.Commands; + +namespace Orbit.Application.Chat.Tools.Implementations; + +public class SendSupportRequestTool(IMediator mediator) : IAiTool +{ + public string Name => "send_support_request"; + public string Description => "Send a support request on behalf of the user."; + + public object GetParameterSchema() => new + { + type = JsonSchemaTypes.Object, + properties = new + { + name = new { type = JsonSchemaTypes.String }, + email = new { type = JsonSchemaTypes.String }, + subject = new { type = JsonSchemaTypes.String }, + message = new { type = JsonSchemaTypes.String } + }, + required = new[] { "name", "email", "subject", "message" } + }; + + public async Task ExecuteAsync(JsonElement args, Guid userId, CancellationToken ct) + { + var name = JsonArgumentParser.GetOptionalString(args, "name"); + var email = JsonArgumentParser.GetOptionalString(args, "email"); + var subject = JsonArgumentParser.GetOptionalString(args, "subject"); + var message = JsonArgumentParser.GetOptionalString(args, "message"); + + if (string.IsNullOrWhiteSpace(name) || + string.IsNullOrWhiteSpace(email) || + string.IsNullOrWhiteSpace(subject) || + string.IsNullOrWhiteSpace(message)) + { + return new ToolResult(false, Error: "name, email, subject, and message are required."); + } + + var result = await mediator.Send(new SendSupportCommand(userId, name, email, subject, message), ct); + return result.IsSuccess + ? new ToolResult(true, EntityId: userId.ToString(), EntityName: "Support request sent", Payload: new { subject }) + : ToolResult.FromFailure(result, userId.ToString()); + } +} diff --git a/src/Orbit.Application/Gamification/AchievementChecks.cs b/src/Orbit.Application/Gamification/AchievementChecks.cs new file mode 100644 index 00000000..bc4663a3 --- /dev/null +++ b/src/Orbit.Application/Gamification/AchievementChecks.cs @@ -0,0 +1,164 @@ +using Orbit.Application.Gamification.Models; +using Orbit.Application.Habits.Services; +using Orbit.Domain.Entities; + +namespace Orbit.Application.Gamification; + +/// +/// Pure achievement-evaluation rules shared by : given the +/// already-loaded user, earned-achievement set, and habit context, each check grants any newly +/// qualifying achievement into newAchievements (and updates the user's XP via +/// ). No I/O, no injected dependencies — every input is passed in. +/// +public static class AchievementChecks +{ + public const int PerfectStreakWindowDays = 30; + + public static void TryGrant( + string achievementId, + User user, + HashSet earned, + List<(UserAchievement Entity, AchievementDefinition Definition)> newAchievements) + { + if (earned.Contains(achievementId)) return; + + var definition = AchievementDefinitions.GetById(achievementId); + if (definition is null) return; + + var entity = UserAchievement.Create(user.Id, achievementId); + user.AddXp(definition.XpReward); + earned.Add(achievementId); + newAchievements.Add((entity, definition)); + } + + public static void CheckConsistencyAchievements( + int currentStreak, + HashSet earned, + User user, + List<(UserAchievement Entity, AchievementDefinition Definition)> newAchievements) + { + if (currentStreak >= 7) + TryGrant(AchievementDefinitions.WeekWarrior, user, earned, newAchievements); + if (currentStreak >= 14) + TryGrant(AchievementDefinitions.FortnightFocus, user, earned, newAchievements); + if (currentStreak >= 30) + TryGrant(AchievementDefinitions.MonthlyMaster, user, earned, newAchievements); + if (currentStreak >= 90) + TryGrant(AchievementDefinitions.QuarterChampion, user, earned, newAchievements); + if (currentStreak >= 100) + TryGrant(AchievementDefinitions.Centurion, user, earned, newAchievements); + if (currentStreak >= 365) + TryGrant(AchievementDefinitions.YearOfDiscipline, user, earned, newAchievements); + } + + public static void CheckVolumeAchievements( + int totalCompletions, + HashSet earned, + User user, + List<(UserAchievement Entity, AchievementDefinition Definition)> newAchievements) + { + if (totalCompletions >= 10) + TryGrant(AchievementDefinitions.GettingMomentum, user, earned, newAchievements); + if (totalCompletions >= 50) + TryGrant(AchievementDefinitions.BuildingHabits, user, earned, newAchievements); + if (totalCompletions >= 100) + TryGrant(AchievementDefinitions.Dedicated, user, earned, newAchievements); + if (totalCompletions >= 500) + TryGrant(AchievementDefinitions.Relentless, user, earned, newAchievements); + if (totalCompletions >= 1000) + TryGrant(AchievementDefinitions.LegendaryVolume, user, earned, newAchievements); + } + + public static void CheckPerfectDay( + IReadOnlyList allUserHabits, + DateOnly today, + HashSet earned, + User user, + List<(UserAchievement Entity, AchievementDefinition Definition)> newAchievements) + { + if (earned.Contains(AchievementDefinitions.PerfectDay)) return; + + var eligibleHabits = allUserHabits + .Where(h => !h.IsCompleted && !h.IsGeneral && h.ParentHabitId == null) + .ToList(); + + if (eligibleHabits.Count == 0) return; + + var scheduledToday = eligibleHabits.Where(h => HabitScheduleService.IsHabitDueOnDate(h, today)).ToList(); + if (scheduledToday.Count == 0) return; + + var allDone = scheduledToday.All(h => h.Logs.Any(l => l.Date == today)); + if (allDone) + TryGrant(AchievementDefinitions.PerfectDay, user, earned, newAchievements); + } + + public static void CheckPerfectWeekAndMonth( + IReadOnlyList allUserHabits, + DateOnly today, + HashSet earned, + User user, + List<(UserAchievement Entity, AchievementDefinition Definition)> newAchievements) + { + var eligibleHabits = allUserHabits + .Where(h => !h.IsCompleted && !h.IsGeneral && h.ParentHabitId == null) + .ToList(); + + if (eligibleHabits.Count == 0) return; + + var consecutivePerfectDays = 0; + for (var day = today; day >= today.AddDays(-PerfectStreakWindowDays); day = day.AddDays(-1)) + { + var scheduledForDay = eligibleHabits.Where(h => HabitScheduleService.IsHabitDueOnDate(h, day)).ToList(); + if (scheduledForDay.Count == 0) + { + if (day != today) consecutivePerfectDays++; + continue; + } + + var allDone = scheduledForDay.All(h => h.Logs.Any(l => l.Date == day)); + if (!allDone) break; + + consecutivePerfectDays++; + } + + if (consecutivePerfectDays >= 7 && !earned.Contains(AchievementDefinitions.PerfectWeek)) + TryGrant(AchievementDefinitions.PerfectWeek, user, earned, newAchievements); + + if (consecutivePerfectDays >= 30 && !earned.Contains(AchievementDefinitions.PerfectMonth)) + TryGrant(AchievementDefinitions.PerfectMonth, user, earned, newAchievements); + } + + public static void CheckTimeBasedAchievements( + User user, + HashSet earned, + List<(UserAchievement Entity, AchievementDefinition Definition)> newAchievements, + IReadOnlyList logsWithRecentCreationTimes, + TimeZoneInfo userTz) + { + var checkEarly = !earned.Contains(AchievementDefinitions.EarlyBird); + var checkNight = !earned.Contains(AchievementDefinitions.NightOwl); + if (!checkEarly && !checkNight) return; + + if (checkEarly) + { + var earlyCount = logsWithRecentCreationTimes.Count(l => + { + var userTime = TimeZoneInfo.ConvertTimeFromUtc(l.CreatedAtUtc, userTz); + return userTime.Hour < 7; + }); + if (earlyCount >= 10) + TryGrant(AchievementDefinitions.EarlyBird, user, earned, newAchievements); + } + + if (checkNight) + { + var nightCount = logsWithRecentCreationTimes.Count(l => + { + var userTime = TimeZoneInfo.ConvertTimeFromUtc(l.CreatedAtUtc, userTz); + return userTime.Hour >= 22; + }); + if (nightCount >= 10) + TryGrant(AchievementDefinitions.NightOwl, user, earned, newAchievements); + } + } +} diff --git a/src/Orbit.Application/Gamification/Services/GamificationService.cs b/src/Orbit.Application/Gamification/Services/GamificationService.cs index 97fa3723..f72c9037 100644 --- a/src/Orbit.Application/Gamification/Services/GamificationService.cs +++ b/src/Orbit.Application/Gamification/Services/GamificationService.cs @@ -27,7 +27,6 @@ public partial class GamificationService( ILogger logger) : IGamificationService { private const int AchievementLogWindowDays = 400; - private const int PerfectStreakWindowDays = 30; private const int MaxConcurrencyAttempts = 3; private sealed record PendingPush(Guid UserId, string Title, string Body); @@ -112,7 +111,7 @@ private sealed record LoggedHabitsContext( private async Task LoadLoggedHabitsContext( User user, HashSet earned, DateOnly today, CancellationToken ct) { - var perfectStreakCutoff = today.AddDays(-PerfectStreakWindowDays); + var perfectStreakCutoff = today.AddDays(-AchievementChecks.PerfectStreakWindowDays); var allUserHabits = await repos.HabitRepository.FindAsync( h => h.UserId == user.Id, q => q.Include(h => h.Logs.Where(l => l.Date >= perfectStreakCutoff && l.Date <= today)), @@ -162,7 +161,7 @@ private async Task ProcessLoggedHabit( && !earned.Contains(AchievementDefinitions.BadHabitBreaker) && metrics.CurrentStreak >= 30) { - TryGrant(AchievementDefinitions.BadHabitBreaker, user, earned, newAchievements); + AchievementChecks.TryGrant(AchievementDefinitions.BadHabitBreaker, user, earned, newAchievements); } foreach (var (entity, _) in newAchievements) @@ -198,25 +197,25 @@ private static int AwardLoggedHabitXpAndAchievements( user.AddXp(xp); if (!earned.Contains(AchievementDefinitions.Liftoff) && context.TotalLogCount == 1) - TryGrant(AchievementDefinitions.Liftoff, user, earned, newAchievements); + AchievementChecks.TryGrant(AchievementDefinitions.Liftoff, user, earned, newAchievements); - CheckConsistencyAchievements(currentStreak, earned, user, newAchievements); + AchievementChecks.CheckConsistencyAchievements(currentStreak, earned, user, newAchievements); if (!earned.Contains(AchievementDefinitions.LegendaryVolume)) - CheckVolumeAchievements(context.TotalLogCount, earned, user, newAchievements); + AchievementChecks.CheckVolumeAchievements(context.TotalLogCount, earned, user, newAchievements); - CheckPerfectDay(context.AllUserHabits, today, earned, user, newAchievements); + AchievementChecks.CheckPerfectDay(context.AllUserHabits, today, earned, user, newAchievements); if (earned.Contains(AchievementDefinitions.PerfectDay) || newAchievements.Any(a => a.Definition.Id == AchievementDefinitions.PerfectDay)) { - CheckPerfectWeekAndMonth(context.AllUserHabits, today, earned, user, newAchievements); + AchievementChecks.CheckPerfectWeekAndMonth(context.AllUserHabits, today, earned, user, newAchievements); } - CheckTimeBasedAchievements(user, earned, newAchievements, context.LogsWithRecentCreationTimes, context.UserTimeZone); + AchievementChecks.CheckTimeBasedAchievements(user, earned, newAchievements, context.LogsWithRecentCreationTimes, context.UserTimeZone); if (!earned.Contains(AchievementDefinitions.Comeback) && !context.HasActivityInPriorWeek) - TryGrant(AchievementDefinitions.Comeback, user, earned, newAchievements); + AchievementChecks.TryGrant(AchievementDefinitions.Comeback, user, earned, newAchievements); return xp; } @@ -229,7 +228,7 @@ await ProcessGamificationEventAsync(userId, async (user, earned, newAchievements { var habitCount = await repos.HabitRepository.CountAsync(h => h.UserId == userId && h.ParentHabitId == null, ct); if (habitCount == 1) - TryGrant(AchievementDefinitions.FirstOrbit, user, earned, newAchievements); + AchievementChecks.TryGrant(AchievementDefinitions.FirstOrbit, user, earned, newAchievements); } }, ct); } @@ -241,10 +240,10 @@ await ProcessGamificationEventAsync(userId, async (user, earned, newAchievements var goalCount = await repos.GoalRepository.CountAsync(g => g.UserId == userId, ct); if (!earned.Contains(AchievementDefinitions.MissionControl) && goalCount == 1) - TryGrant(AchievementDefinitions.MissionControl, user, earned, newAchievements); + AchievementChecks.TryGrant(AchievementDefinitions.MissionControl, user, earned, newAchievements); if (!earned.Contains(AchievementDefinitions.GoalSetter) && goalCount >= 3) - TryGrant(AchievementDefinitions.GoalSetter, user, earned, newAchievements); + AchievementChecks.TryGrant(AchievementDefinitions.GoalSetter, user, earned, newAchievements); }, ct); } @@ -258,13 +257,13 @@ await ProcessGamificationEventAsync(userId, async (user, earned, newAchievements g => g.UserId == userId && g.Status == Domain.Enums.GoalStatus.Completed, ct); if (!earned.Contains(AchievementDefinitions.GoalCrusher) && completedGoals == 1) - TryGrant(AchievementDefinitions.GoalCrusher, user, earned, newAchievements); + AchievementChecks.TryGrant(AchievementDefinitions.GoalCrusher, user, earned, newAchievements); if (!earned.Contains(AchievementDefinitions.Overachiever) && completedGoals >= 5) - TryGrant(AchievementDefinitions.Overachiever, user, earned, newAchievements); + AchievementChecks.TryGrant(AchievementDefinitions.Overachiever, user, earned, newAchievements); if (!earned.Contains(AchievementDefinitions.DreamMaker) && completedGoals >= 10) - TryGrant(AchievementDefinitions.DreamMaker, user, earned, newAchievements); + AchievementChecks.TryGrant(AchievementDefinitions.DreamMaker, user, earned, newAchievements); }, ct); } @@ -340,154 +339,6 @@ private async Task> LoadEarnedAchievementIds(Guid userId, Cancel return earned.Select(a => a.AchievementId).ToHashSet(); } - private static void TryGrant( - string achievementId, - User user, - HashSet earned, - List<(UserAchievement Entity, AchievementDefinition Definition)> newAchievements) - { - if (earned.Contains(achievementId)) return; - - var definition = AchievementDefinitions.GetById(achievementId); - if (definition is null) return; - - var entity = UserAchievement.Create(user.Id, achievementId); - user.AddXp(definition.XpReward); - earned.Add(achievementId); - newAchievements.Add((entity, definition)); - } - - private static void CheckConsistencyAchievements( - int currentStreak, - HashSet earned, - User user, - List<(UserAchievement Entity, AchievementDefinition Definition)> newAchievements) - { - if (currentStreak >= 7) - TryGrant(AchievementDefinitions.WeekWarrior, user, earned, newAchievements); - if (currentStreak >= 14) - TryGrant(AchievementDefinitions.FortnightFocus, user, earned, newAchievements); - if (currentStreak >= 30) - TryGrant(AchievementDefinitions.MonthlyMaster, user, earned, newAchievements); - if (currentStreak >= 90) - TryGrant(AchievementDefinitions.QuarterChampion, user, earned, newAchievements); - if (currentStreak >= 100) - TryGrant(AchievementDefinitions.Centurion, user, earned, newAchievements); - if (currentStreak >= 365) - TryGrant(AchievementDefinitions.YearOfDiscipline, user, earned, newAchievements); - } - - private static void CheckVolumeAchievements( - int totalCompletions, - HashSet earned, - User user, - List<(UserAchievement Entity, AchievementDefinition Definition)> newAchievements) - { - if (totalCompletions >= 10) - TryGrant(AchievementDefinitions.GettingMomentum, user, earned, newAchievements); - if (totalCompletions >= 50) - TryGrant(AchievementDefinitions.BuildingHabits, user, earned, newAchievements); - if (totalCompletions >= 100) - TryGrant(AchievementDefinitions.Dedicated, user, earned, newAchievements); - if (totalCompletions >= 500) - TryGrant(AchievementDefinitions.Relentless, user, earned, newAchievements); - if (totalCompletions >= 1000) - TryGrant(AchievementDefinitions.LegendaryVolume, user, earned, newAchievements); - } - - private static void CheckPerfectDay( - IReadOnlyList allUserHabits, - DateOnly today, - HashSet earned, - User user, - List<(UserAchievement Entity, AchievementDefinition Definition)> newAchievements) - { - if (earned.Contains(AchievementDefinitions.PerfectDay)) return; - - var eligibleHabits = allUserHabits - .Where(h => !h.IsCompleted && !h.IsGeneral && h.ParentHabitId == null) - .ToList(); - - if (eligibleHabits.Count == 0) return; - - var scheduledToday = eligibleHabits.Where(h => HabitScheduleService.IsHabitDueOnDate(h, today)).ToList(); - if (scheduledToday.Count == 0) return; - - var allDone = scheduledToday.All(h => h.Logs.Any(l => l.Date == today)); - if (allDone) - TryGrant(AchievementDefinitions.PerfectDay, user, earned, newAchievements); - } - - private static void CheckPerfectWeekAndMonth( - IReadOnlyList allUserHabits, - DateOnly today, - HashSet earned, - User user, - List<(UserAchievement Entity, AchievementDefinition Definition)> newAchievements) - { - var eligibleHabits = allUserHabits - .Where(h => !h.IsCompleted && !h.IsGeneral && h.ParentHabitId == null) - .ToList(); - - if (eligibleHabits.Count == 0) return; - - var consecutivePerfectDays = 0; - for (var day = today; day >= today.AddDays(-PerfectStreakWindowDays); day = day.AddDays(-1)) - { - var scheduledForDay = eligibleHabits.Where(h => HabitScheduleService.IsHabitDueOnDate(h, day)).ToList(); - if (scheduledForDay.Count == 0) - { - if (day != today) consecutivePerfectDays++; - continue; - } - - var allDone = scheduledForDay.All(h => h.Logs.Any(l => l.Date == day)); - if (!allDone) break; - - consecutivePerfectDays++; - } - - if (consecutivePerfectDays >= 7 && !earned.Contains(AchievementDefinitions.PerfectWeek)) - TryGrant(AchievementDefinitions.PerfectWeek, user, earned, newAchievements); - - if (consecutivePerfectDays >= 30 && !earned.Contains(AchievementDefinitions.PerfectMonth)) - TryGrant(AchievementDefinitions.PerfectMonth, user, earned, newAchievements); - } - - private static void CheckTimeBasedAchievements( - User user, - HashSet earned, - List<(UserAchievement Entity, AchievementDefinition Definition)> newAchievements, - IReadOnlyList logsWithRecentCreationTimes, - TimeZoneInfo userTz) - { - var checkEarly = !earned.Contains(AchievementDefinitions.EarlyBird); - var checkNight = !earned.Contains(AchievementDefinitions.NightOwl); - if (!checkEarly && !checkNight) return; - - if (checkEarly) - { - var earlyCount = logsWithRecentCreationTimes.Count(l => - { - var userTime = TimeZoneInfo.ConvertTimeFromUtc(l.CreatedAtUtc, userTz); - return userTime.Hour < 7; - }); - if (earlyCount >= 10) - TryGrant(AchievementDefinitions.EarlyBird, user, earned, newAchievements); - } - - if (checkNight) - { - var nightCount = logsWithRecentCreationTimes.Count(l => - { - var userTime = TimeZoneInfo.ConvertTimeFromUtc(l.CreatedAtUtc, userTz); - return userTime.Hour >= 22; - }); - if (nightCount >= 10) - TryGrant(AchievementDefinitions.NightOwl, user, earned, newAchievements); - } - } - private static void UpdateLevel(User user) { var newLevel = LevelDefinitions.GetLevelForXp(user.TotalXp); diff --git a/src/Orbit.Application/Habits/Queries/GetHabitScheduleQuery.cs b/src/Orbit.Application/Habits/Queries/GetHabitScheduleQuery.cs index 65bd43b4..3fa71a3b 100644 --- a/src/Orbit.Application/Habits/Queries/GetHabitScheduleQuery.cs +++ b/src/Orbit.Application/Habits/Queries/GetHabitScheduleQuery.cs @@ -157,7 +157,7 @@ private async Task>> HandleGeneralHa .OrderBy(h => h.Position ?? int.MaxValue) .ThenBy(h => h.CreatedAtUtc); - topLevel = ApplyCommonFilters(topLevel, request, lookup); + topLevel = HabitScheduleFilters.ApplyCommonFilters(topLevel, request, lookup); var filtered = topLevel.ToList(); @@ -174,7 +174,7 @@ private async Task>> HandleGeneralHa var pagedItems = filtered .Skip((page - 1) * request.PageSize) .Take(request.PageSize) - .Select(h => MapToScheduleItem(h, [], false, ctx)) + .Select(h => HabitScheduleFilters.MapToScheduleItem(h, [], false, ctx)) .ToList(); return Result.Success(new PaginatedResponse( @@ -195,7 +195,7 @@ private async Task>> HandleScheduled request.UserId, logFrom, logTo, - includeTags: NeedsTagsForFiltering(request), + includeTags: HabitScheduleFilters.NeedsTagsForFiltering(request), includeGoals: false, cancellationToken); @@ -205,8 +205,8 @@ private async Task>> HandleScheduled .OrderBy(h => h.Position ?? int.MaxValue) .ThenBy(h => h.CreatedAtUtc); - topLevel = ApplyCommonFilters(topLevel, request, lookup); - topLevel = ApplyFrequencyUnitFilter(topLevel, request.FrequencyUnitFilter); + topLevel = HabitScheduleFilters.ApplyCommonFilters(topLevel, request, lookup); + topLevel = HabitScheduleFilters.ApplyFrequencyUnitFilter(topLevel, request.FrequencyUnitFilter); if (!request.DateFrom.HasValue || !request.DateTo.HasValue) return await BuildNonDateResponse(topLevel, request, lookup, today, weekStartDay, logFrom, logTo, cancellationToken); @@ -214,7 +214,7 @@ private async Task>> HandleScheduled var dateFrom = request.DateFrom.Value; var dateTo = request.DateTo.Value; - var filtered = FilterScheduledHabits(topLevel, dateFrom, dateTo, request.IncludeOverdue, lookup, weekStartDay); + var filtered = HabitScheduleFilters.FilterScheduledHabits(topLevel, dateFrom, dateTo, request.IncludeOverdue, lookup, weekStartDay); var totalCount = filtered.Count; var totalPages = (int)Math.Ceiling((double)totalCount / request.PageSize); @@ -248,7 +248,7 @@ private async Task>> HandleScheduled ScheduledDatesCache: scheduledDatesCache); var pagedHabitsById = pagedLookup.SelectMany(group => group).ToDictionary(h => h.Id); var pagedItems = pageItems - .Select(x => MapToScheduleItem( + .Select(x => HabitScheduleFilters.MapToScheduleItem( pagedHabitsById.TryGetValue(x.habit.Id, out var hydratedHabit) ? hydratedHabit : x.habit, x.scheduledDates, x.isOverdue, @@ -266,27 +266,6 @@ private async Task>> HandleScheduled totalPages)); } - private static IEnumerable ApplyFrequencyUnitFilter( - IEnumerable habits, string? frequencyUnitFilter) - { - if (string.IsNullOrWhiteSpace(frequencyUnitFilter)) - return habits; - - if (frequencyUnitFilter.Equals("none", StringComparison.OrdinalIgnoreCase)) - return habits.Where(h => h.FrequencyUnit == null); - - if (Enum.TryParse(frequencyUnitFilter, true, out var unit)) - return habits.Where(h => h.FrequencyUnit == unit); - - return habits; - } - - private static bool NeedsTagsForFiltering(GetHabitScheduleQuery request) - { - return !string.IsNullOrWhiteSpace(request.Search) - || request.TagIds is { Count: > 0 }; - } - private async Task> LoadScheduleHabits( Guid userId, DateOnly logFrom, @@ -297,7 +276,7 @@ private async Task> LoadScheduleHabits( { return await habitRepository.FindAsync( h => h.UserId == userId && !h.IsGeneral, - q => IncludeHabitGraph(q, logFrom, logTo, includeTags, includeGoals), + q => HabitScheduleFilters.IncludeHabitGraph(q, logFrom, logTo, includeTags, includeGoals), cancellationToken); } @@ -311,14 +290,14 @@ private async Task> LoadScheduleHabits( { var ids = new HashSet(); foreach (var habit in pageTopLevelHabits) - AddSubtreeIds(habit.Id, baseLookup, ids); + HabitScheduleFilters.AddSubtreeIds(habit.Id, baseLookup, ids); if (ids.Count == 0) return Enumerable.Empty().ToLookup(h => h.ParentHabitId); var pageHabits = await habitRepository.FindAsync( h => ids.Contains(h.Id), - q => IncludeHabitGraph(q, logFrom, logTo, includeTags: true, includeGoals: includeGoals), + q => HabitScheduleFilters.IncludeHabitGraph(q, logFrom, logTo, includeTags: true, includeGoals: includeGoals), cancellationToken); return pageHabits @@ -326,33 +305,6 @@ private async Task> LoadScheduleHabits( .ToLookup(h => h.ParentHabitId); } - private static void AddSubtreeIds(Guid habitId, ILookup lookup, HashSet ids) - { - if (!ids.Add(habitId)) - return; - - foreach (var child in lookup[habitId]) - AddSubtreeIds(child.Id, lookup, ids); - } - - private static IQueryable IncludeHabitGraph( - IQueryable query, - DateOnly logFrom, - DateOnly logTo, - bool includeTags, - bool includeGoals) - { - query = query.Include(h => h.Logs.Where(l => l.Date >= logFrom && l.Date <= logTo)); - - if (includeTags) - query = query.Include(h => h.Tags); - - if (includeGoals) - query = query.Include(h => h.Goals); - - return query; - } - private async Task>> BuildNonDateResponse( IEnumerable topLevel, GetHabitScheduleQuery request, @@ -390,7 +342,7 @@ private async Task>> BuildNonDateRes UserToday: today, Search: request.Search); var allPagedItems = pageHabits - .Select(h => MapToScheduleItem( + .Select(h => HabitScheduleFilters.MapToScheduleItem( pagedHabitsById.TryGetValue(h.Id, out var hydratedHabit) ? hydratedHabit : h, [], false, @@ -401,50 +353,6 @@ private async Task>> BuildNonDateRes allPagedItems, allPage, request.PageSize, allTotalCount, allTotalPages)); } - private static List<(Habit habit, List scheduledDates, bool isOverdue)> FilterScheduledHabits( - IEnumerable topLevel, - DateOnly dateFrom, - DateOnly dateTo, - bool includeOverdue, - ILookup lookup, - int weekStartDay) - { - var filtered = new List<(Habit habit, List scheduledDates, bool isOverdue)>(); - - foreach (var habit in topLevel) - { - var hasCompletedLogInRange = HabitScheduleService.HasCompletedLogInRange(habit, dateFrom, dateTo); - - if (habit.IsFlexible - && !hasCompletedLogInRange - && !HabitScheduleService.IsFlexibleHabitDueOnDate(habit, dateFrom, habit.Logs, weekStartDay)) - continue; - - var scheduledDates = HabitScheduleService.GetScheduledDates(habit, dateFrom, dateTo); - var isOverdue = DetermineOverdueStatus(habit, dateFrom, includeOverdue); - var hasDescendantDue = HasAnyDescendantDue( - habit.Id, - lookup, - dateFrom, - dateTo, - includeOverdue); - - if (scheduledDates.Count > 0 || isOverdue || hasDescendantDue || hasCompletedLogInRange) - filtered.Add((habit, scheduledDates, isOverdue)); - } - - return filtered; - } - - /// - /// Whether a habit is overdue on the reference date, honoring the request's - /// flag. Delegates the overdue rule to - /// so the schedule query and the - /// daily summary share a single definition of "overdue". - /// - private static bool DetermineOverdueStatus(Habit habit, DateOnly dateFrom, bool includeOverdue) => - includeOverdue && HabitScheduleService.IsOverdueOnDate(habit, dateFrom); - private async Task AppendGeneralHabits( List pagedItems, GetHabitScheduleQuery request, @@ -475,278 +383,9 @@ private async Task AppendGeneralHabits( UserToday: today, Search: request.Search); var generalItems = generalTopLevel - .Select(h => MapToScheduleItem(h, [], false, ctx)) + .Select(h => HabitScheduleFilters.MapToScheduleItem(h, [], false, ctx)) .ToList(); pagedItems.AddRange(generalItems); } - - private static IEnumerable ApplyCommonFilters( - IEnumerable topLevel, - GetHabitScheduleQuery request, - ILookup lookup) - { - if (!string.IsNullOrWhiteSpace(request.Search)) - topLevel = ApplySearchFilter( - topLevel, - request.Search.Trim(), - request.DateFrom, - request.DateTo, - request.IncludeOverdue, - lookup); - - if (request.IsCompleted.HasValue) - topLevel = topLevel.Where(h => h.IsCompleted == request.IsCompleted.Value); - - if (request.TagIds is { Count: > 0 }) - topLevel = ApplyTagFilter(topLevel, request.TagIds, lookup); - - return topLevel; - } - - private static IEnumerable ApplySearchFilter( - IEnumerable topLevel, - string term, - DateOnly? dateFrom, - DateOnly? dateTo, - bool includeOverdue, - ILookup lookup) - { - return topLevel.Where(h => MatchesSearch(h, term, lookup, dateFrom, dateTo, includeOverdue)); - } - - private static bool MatchesSearch( - Habit h, - string term, - ILookup lookup, - DateOnly? dateFrom, - DateOnly? dateTo, - bool includeOverdue) - { - if (FuzzyMatcher.FuzzyContains(h.Title, term)) return true; - if (h.Description != null && FuzzyMatcher.FuzzyContains(h.Description, term)) return true; - if (h.Tags.Any(t => FuzzyMatcher.FuzzyContains(t.Name, term))) return true; - return HasDescendantMatchingSearch(h.Id, lookup, term, dateFrom, dateTo, includeOverdue); - } - - private static bool HasDescendantMatchingSearch( - Guid parentId, - ILookup lookup, - string term, - DateOnly? dateFrom, - DateOnly? dateTo, - bool includeOverdue) - { - foreach (var child in lookup[parentId]) - { - if (!IsChildRelevantForSearch(child, dateFrom, dateTo, includeOverdue)) continue; - if (FuzzyMatcher.FuzzyContains(child.Title, term)) return true; - if (HasDescendantMatchingSearch(child.Id, lookup, term, dateFrom, dateTo, includeOverdue)) - return true; - } - return false; - } - - private static bool IsChildRelevantForSearch( - Habit child, - DateOnly? dateFrom, - DateOnly? dateTo, - bool includeOverdue) - { - if (child.IsCompleted) return false; - if (!dateFrom.HasValue || !dateTo.HasValue) return true; - - var scheduledDates = HabitScheduleService.GetScheduledDates(child, dateFrom.Value, dateTo.Value); - var isOverdue = DetermineOverdueStatus(child, dateFrom.Value, includeOverdue); - - return scheduledDates.Count > 0 || isOverdue; - } - - private static IEnumerable ApplyTagFilter( - IEnumerable topLevel, - IReadOnlyList tagIds, - ILookup lookup) - { - var tagIdSet = tagIds.ToHashSet(); - bool HasMatchingTag(Habit h) => h.Tags.Any(t => tagIdSet.Contains(t.Id)); - bool HasDescendantWithTag(Guid parentId) - { - foreach (var child in lookup[parentId]) - { - if (HasMatchingTag(child)) return true; - if (HasDescendantWithTag(child.Id)) return true; - } - return false; - } - return topLevel.Where(h => HasMatchingTag(h) || HasDescendantWithTag(h.Id)); - } - - private static HabitScheduleItem MapToScheduleItem( - Habit h, - List scheduledDates, - bool isOverdue, - ScheduleMapContext ctx) - { - var (flexibleTarget, flexibleCompleted) = CalculateFlexibleProgress(h, ctx.ReferenceDate, ctx.WeekStartDay); - - var instances = ctx.DateFrom.HasValue && ctx.DateTo.HasValue && ctx.UserToday.HasValue - ? HabitScheduleService.GetInstances(h, ctx.DateFrom.Value, ctx.DateTo.Value, ctx.UserToday.Value) - : []; - - var isLoggedInRange = ctx.DateFrom.HasValue - && ctx.DateTo.HasValue - && HabitScheduleService.HasCompletedLogInRange(h, ctx.DateFrom.Value, ctx.DateTo.Value); - - return new HabitScheduleItem( - h.Id, h.Title, h.Description, h.FrequencyUnit, h.FrequencyQuantity, - h.IsBadHabit, h.IsCompleted, h.IsGeneral, h.IsFlexible, - h.Days.ToList(), h.Position, h.CreatedAtUtc, - h.DueDate, h.DueTime, h.DueEndTime, h.EndDate, - scheduledDates, isOverdue, - h.ReminderEnabled, h.ReminderTimes, h.ScheduledReminders, h.SlipAlertEnabled, - h.ChecklistItems, MapTags(h), MapGoals(h), - MapChildren(h.Id, ctx), - ctx.ChildLookup[h.Id].Any(), - flexibleTarget, flexibleCompleted, - isLoggedInRange, instances, - ComputeSearchMatches(h, ctx), - Emoji: h.Emoji); - } - - private static (int? Target, int? Completed) CalculateFlexibleProgress( - Habit h, DateOnly? referenceDate, int weekStartDay) - { - if (!h.IsFlexible || !referenceDate.HasValue) - return (null, null); - - var totalTarget = h.FrequencyQuantity ?? 1; - var skipped = HabitScheduleService.GetSkippedInWindow(h, referenceDate.Value, h.Logs, weekStartDay); - var target = Math.Max(0, totalTarget - skipped); - var completed = HabitScheduleService.GetCompletedInWindow(h, referenceDate.Value, h.Logs, weekStartDay); - return (target, completed); - } - - private static List? ComputeSearchMatches(Habit h, ScheduleMapContext ctx) - { - if (string.IsNullOrWhiteSpace(ctx.Search)) return null; - - var matches = new List(); - if (FuzzyMatcher.FuzzyContains(h.Title, ctx.Search)) - matches.Add(new SearchMatchField("title", null)); - if (h.Description != null && FuzzyMatcher.FuzzyContains(h.Description, ctx.Search)) - matches.Add(new SearchMatchField("description", null)); - matches.AddRange(h.Tags - .Where(tag => FuzzyMatcher.FuzzyContains(tag.Name, ctx.Search)) - .Select(tag => new SearchMatchField("tag", tag.Name))); - AddChildSearchMatches(matches, h.Id, ctx); - return matches.Count > 0 ? matches : null; - } - - private static void AddChildSearchMatches( - List matches, Guid parentId, ScheduleMapContext ctx) - { - foreach (var child in ctx.ChildLookup[parentId]) - { - if (child.IsCompleted) continue; - if (ctx.DateFrom.HasValue && ctx.DateTo.HasValue) - { - var childScheduledDates = ctx.GetScheduledDates(child); - var childIsOverdue = DetermineOverdueStatus( - child, - ctx.DateFrom.Value, - ctx.IncludeOverdue); - - if (childScheduledDates.Count == 0 && !childIsOverdue) - continue; - } - - if (FuzzyMatcher.FuzzyContains(child.Title, ctx.Search!)) - matches.Add(new SearchMatchField("child", child.Title)); - } - } - - private static bool HasAnyDescendantDue( - Guid parentId, - ILookup lookup, - DateOnly dateFrom, - DateOnly dateTo, - bool includeOverdue) - { - foreach (var child in lookup[parentId]) - { - var scheduledDates = HabitScheduleService.GetScheduledDates(child, dateFrom, dateTo); - var isOverdue = DetermineOverdueStatus(child, dateFrom, includeOverdue); - - if (scheduledDates.Count > 0 || isOverdue) - return true; - if (child.Logs.Any(l => l.Date >= dateFrom && l.Date <= dateTo)) - return true; - if (HasAnyDescendantDue(child.Id, lookup, dateFrom, dateTo, includeOverdue)) - return true; - } - return false; - } - - private static List MapChildren(Guid parentId, ScheduleMapContext ctx) - { - var children = ctx.ChildLookup[parentId]; - - if (!ctx.IncludeAllChildren && ctx.DateFrom.HasValue && ctx.DateTo.HasValue) - { - var df = ctx.DateFrom.Value; - var dt = ctx.DateTo.Value; - children = children - .Where(c => - { - var scheduledDates = ctx.GetScheduledDates(c); - var isOverdue = DetermineOverdueStatus(c, df, ctx.IncludeOverdue); - - return scheduledDates.Count > 0 - || c.IsCompleted - || isOverdue - || HasAnyDescendantDue(c.Id, ctx.ChildLookup, df, dt, ctx.IncludeOverdue) - || c.Logs.Any(l => l.Date >= df && l.Date <= dt); - }); - } - - return children - .OrderBy(c => c.Position ?? int.MaxValue) - .ThenBy(c => c.CreatedAtUtc) - .Select(c => MapSingleChild(c, ctx)) - .ToList(); - } - - private static HabitScheduleChildItem MapSingleChild(Habit c, ScheduleMapContext ctx) - { - var (ft, fc) = CalculateFlexibleProgress(c, ctx.ReferenceDate, ctx.WeekStartDay); - var isLoggedInRange = ctx.DateFrom.HasValue && ctx.DateTo.HasValue - && HabitScheduleService.HasCompletedLogInRange(c, ctx.DateFrom.Value, ctx.DateTo.Value); - var scheduledDates = ctx.DateFrom.HasValue && ctx.DateTo.HasValue - ? ctx.GetScheduledDates(c) - : []; - var isOverdue = ctx.DateFrom.HasValue - && DetermineOverdueStatus(c, ctx.DateFrom.Value, ctx.IncludeOverdue); - - var instances = ctx.DateFrom.HasValue && ctx.DateTo.HasValue && ctx.UserToday.HasValue - ? HabitScheduleService.GetInstances(c, ctx.DateFrom.Value, ctx.DateTo.Value, ctx.UserToday.Value) - : []; - - return new HabitScheduleChildItem( - c.Id, c.Title, c.Description, - c.FrequencyUnit, c.FrequencyQuantity, c.IsBadHabit, c.IsCompleted, c.IsGeneral, c.IsFlexible, - c.Days.ToList(), c.DueDate, c.DueTime, c.DueEndTime, c.EndDate, - scheduledDates, isOverdue, - c.Position, c.ChecklistItems, MapTags(c), - MapChildren(c.Id, ctx), - ctx.ChildLookup[c.Id].Any(), ft, fc, isLoggedInRange, - instances, - ComputeSearchMatches(c, ctx), - Emoji: c.Emoji); - } - - private static List MapTags(Habit h) => - h.Tags.Select(t => new HabitTagItem(t.Id, t.Name, t.Color)).ToList(); - - private static List MapGoals(Habit h) => - h.Goals.Select(g => new LinkedGoalDto(g.Id, g.Title)).ToList(); } diff --git a/src/Orbit.Application/Habits/Queries/HabitScheduleFilters.cs b/src/Orbit.Application/Habits/Queries/HabitScheduleFilters.cs new file mode 100644 index 00000000..1b6978bd --- /dev/null +++ b/src/Orbit.Application/Habits/Queries/HabitScheduleFilters.cs @@ -0,0 +1,370 @@ +using Microsoft.EntityFrameworkCore; +using Orbit.Application.Habits.Services; +using Orbit.Domain.Entities; +using Orbit.Domain.Enums; + +namespace Orbit.Application.Habits.Queries; + +internal static class HabitScheduleFilters +{ + internal static IEnumerable ApplyFrequencyUnitFilter( + IEnumerable habits, string? frequencyUnitFilter) + { + if (string.IsNullOrWhiteSpace(frequencyUnitFilter)) + return habits; + + if (frequencyUnitFilter.Equals("none", StringComparison.OrdinalIgnoreCase)) + return habits.Where(h => h.FrequencyUnit == null); + + if (Enum.TryParse(frequencyUnitFilter, true, out var unit)) + return habits.Where(h => h.FrequencyUnit == unit); + + return habits; + } + + internal static bool NeedsTagsForFiltering(GetHabitScheduleQuery request) + { + return !string.IsNullOrWhiteSpace(request.Search) + || request.TagIds is { Count: > 0 }; + } + + internal static void AddSubtreeIds(Guid habitId, ILookup lookup, HashSet ids) + { + if (!ids.Add(habitId)) + return; + + foreach (var child in lookup[habitId]) + AddSubtreeIds(child.Id, lookup, ids); + } + + internal static IQueryable IncludeHabitGraph( + IQueryable query, + DateOnly logFrom, + DateOnly logTo, + bool includeTags, + bool includeGoals) + { + query = query.Include(h => h.Logs.Where(l => l.Date >= logFrom && l.Date <= logTo)); + + if (includeTags) + query = query.Include(h => h.Tags); + + if (includeGoals) + query = query.Include(h => h.Goals); + + return query; + } + + internal static List<(Habit habit, List scheduledDates, bool isOverdue)> FilterScheduledHabits( + IEnumerable topLevel, + DateOnly dateFrom, + DateOnly dateTo, + bool includeOverdue, + ILookup lookup, + int weekStartDay) + { + var filtered = new List<(Habit habit, List scheduledDates, bool isOverdue)>(); + + foreach (var habit in topLevel) + { + var hasCompletedLogInRange = HabitScheduleService.HasCompletedLogInRange(habit, dateFrom, dateTo); + + if (habit.IsFlexible + && !hasCompletedLogInRange + && !HabitScheduleService.IsFlexibleHabitDueOnDate(habit, dateFrom, habit.Logs, weekStartDay)) + continue; + + var scheduledDates = HabitScheduleService.GetScheduledDates(habit, dateFrom, dateTo); + var isOverdue = DetermineOverdueStatus(habit, dateFrom, includeOverdue); + var hasDescendantDue = HasAnyDescendantDue( + habit.Id, + lookup, + dateFrom, + dateTo, + includeOverdue); + + if (scheduledDates.Count > 0 || isOverdue || hasDescendantDue || hasCompletedLogInRange) + filtered.Add((habit, scheduledDates, isOverdue)); + } + + return filtered; + } + + /// + /// Whether a habit is overdue on the reference date, honoring the request's + /// flag. Delegates the overdue rule to + /// so the schedule query and the + /// daily summary share a single definition of "overdue". + /// + private static bool DetermineOverdueStatus(Habit habit, DateOnly dateFrom, bool includeOverdue) => + includeOverdue && HabitScheduleService.IsOverdueOnDate(habit, dateFrom); + + internal static IEnumerable ApplyCommonFilters( + IEnumerable topLevel, + GetHabitScheduleQuery request, + ILookup lookup) + { + if (!string.IsNullOrWhiteSpace(request.Search)) + topLevel = ApplySearchFilter( + topLevel, + request.Search.Trim(), + request.DateFrom, + request.DateTo, + request.IncludeOverdue, + lookup); + + if (request.IsCompleted.HasValue) + topLevel = topLevel.Where(h => h.IsCompleted == request.IsCompleted.Value); + + if (request.TagIds is { Count: > 0 }) + topLevel = ApplyTagFilter(topLevel, request.TagIds, lookup); + + return topLevel; + } + + private static IEnumerable ApplySearchFilter( + IEnumerable topLevel, + string term, + DateOnly? dateFrom, + DateOnly? dateTo, + bool includeOverdue, + ILookup lookup) + { + return topLevel.Where(h => MatchesSearch(h, term, lookup, dateFrom, dateTo, includeOverdue)); + } + + private static bool MatchesSearch( + Habit h, + string term, + ILookup lookup, + DateOnly? dateFrom, + DateOnly? dateTo, + bool includeOverdue) + { + if (FuzzyMatcher.FuzzyContains(h.Title, term)) return true; + if (h.Description != null && FuzzyMatcher.FuzzyContains(h.Description, term)) return true; + if (h.Tags.Any(t => FuzzyMatcher.FuzzyContains(t.Name, term))) return true; + return HasDescendantMatchingSearch(h.Id, lookup, term, dateFrom, dateTo, includeOverdue); + } + + private static bool HasDescendantMatchingSearch( + Guid parentId, + ILookup lookup, + string term, + DateOnly? dateFrom, + DateOnly? dateTo, + bool includeOverdue) + { + foreach (var child in lookup[parentId]) + { + if (!IsChildRelevantForSearch(child, dateFrom, dateTo, includeOverdue)) continue; + if (FuzzyMatcher.FuzzyContains(child.Title, term)) return true; + if (HasDescendantMatchingSearch(child.Id, lookup, term, dateFrom, dateTo, includeOverdue)) + return true; + } + return false; + } + + private static bool IsChildRelevantForSearch( + Habit child, + DateOnly? dateFrom, + DateOnly? dateTo, + bool includeOverdue) + { + if (child.IsCompleted) return false; + if (!dateFrom.HasValue || !dateTo.HasValue) return true; + + var scheduledDates = HabitScheduleService.GetScheduledDates(child, dateFrom.Value, dateTo.Value); + var isOverdue = DetermineOverdueStatus(child, dateFrom.Value, includeOverdue); + + return scheduledDates.Count > 0 || isOverdue; + } + + private static IEnumerable ApplyTagFilter( + IEnumerable topLevel, + IReadOnlyList tagIds, + ILookup lookup) + { + var tagIdSet = tagIds.ToHashSet(); + bool HasMatchingTag(Habit h) => h.Tags.Any(t => tagIdSet.Contains(t.Id)); + bool HasDescendantWithTag(Guid parentId) + { + foreach (var child in lookup[parentId]) + { + if (HasMatchingTag(child)) return true; + if (HasDescendantWithTag(child.Id)) return true; + } + return false; + } + return topLevel.Where(h => HasMatchingTag(h) || HasDescendantWithTag(h.Id)); + } + + internal static HabitScheduleItem MapToScheduleItem( + Habit h, + List scheduledDates, + bool isOverdue, + ScheduleMapContext ctx) + { + var (flexibleTarget, flexibleCompleted) = CalculateFlexibleProgress(h, ctx.ReferenceDate, ctx.WeekStartDay); + + var instances = ctx.DateFrom.HasValue && ctx.DateTo.HasValue && ctx.UserToday.HasValue + ? HabitScheduleService.GetInstances(h, ctx.DateFrom.Value, ctx.DateTo.Value, ctx.UserToday.Value) + : []; + + var isLoggedInRange = ctx.DateFrom.HasValue + && ctx.DateTo.HasValue + && HabitScheduleService.HasCompletedLogInRange(h, ctx.DateFrom.Value, ctx.DateTo.Value); + + return new HabitScheduleItem( + h.Id, h.Title, h.Description, h.FrequencyUnit, h.FrequencyQuantity, + h.IsBadHabit, h.IsCompleted, h.IsGeneral, h.IsFlexible, + h.Days.ToList(), h.Position, h.CreatedAtUtc, + h.DueDate, h.DueTime, h.DueEndTime, h.EndDate, + scheduledDates, isOverdue, + h.ReminderEnabled, h.ReminderTimes, h.ScheduledReminders, h.SlipAlertEnabled, + h.ChecklistItems, MapTags(h), MapGoals(h), + MapChildren(h.Id, ctx), + ctx.ChildLookup[h.Id].Any(), + flexibleTarget, flexibleCompleted, + isLoggedInRange, instances, + ComputeSearchMatches(h, ctx), + Emoji: h.Emoji); + } + + private static (int? Target, int? Completed) CalculateFlexibleProgress( + Habit h, DateOnly? referenceDate, int weekStartDay) + { + if (!h.IsFlexible || !referenceDate.HasValue) + return (null, null); + + var totalTarget = h.FrequencyQuantity ?? 1; + var skipped = HabitScheduleService.GetSkippedInWindow(h, referenceDate.Value, h.Logs, weekStartDay); + var target = Math.Max(0, totalTarget - skipped); + var completed = HabitScheduleService.GetCompletedInWindow(h, referenceDate.Value, h.Logs, weekStartDay); + return (target, completed); + } + + private static List? ComputeSearchMatches(Habit h, ScheduleMapContext ctx) + { + if (string.IsNullOrWhiteSpace(ctx.Search)) return null; + + var matches = new List(); + if (FuzzyMatcher.FuzzyContains(h.Title, ctx.Search)) + matches.Add(new SearchMatchField("title", null)); + if (h.Description != null && FuzzyMatcher.FuzzyContains(h.Description, ctx.Search)) + matches.Add(new SearchMatchField("description", null)); + matches.AddRange(h.Tags + .Where(tag => FuzzyMatcher.FuzzyContains(tag.Name, ctx.Search)) + .Select(tag => new SearchMatchField("tag", tag.Name))); + AddChildSearchMatches(matches, h.Id, ctx); + return matches.Count > 0 ? matches : null; + } + + private static void AddChildSearchMatches( + List matches, Guid parentId, ScheduleMapContext ctx) + { + foreach (var child in ctx.ChildLookup[parentId]) + { + if (child.IsCompleted) continue; + if (ctx.DateFrom.HasValue && ctx.DateTo.HasValue) + { + var childScheduledDates = ctx.GetScheduledDates(child); + var childIsOverdue = DetermineOverdueStatus( + child, + ctx.DateFrom.Value, + ctx.IncludeOverdue); + + if (childScheduledDates.Count == 0 && !childIsOverdue) + continue; + } + + if (FuzzyMatcher.FuzzyContains(child.Title, ctx.Search!)) + matches.Add(new SearchMatchField("child", child.Title)); + } + } + + private static bool HasAnyDescendantDue( + Guid parentId, + ILookup lookup, + DateOnly dateFrom, + DateOnly dateTo, + bool includeOverdue) + { + foreach (var child in lookup[parentId]) + { + var scheduledDates = HabitScheduleService.GetScheduledDates(child, dateFrom, dateTo); + var isOverdue = DetermineOverdueStatus(child, dateFrom, includeOverdue); + + if (scheduledDates.Count > 0 || isOverdue) + return true; + if (child.Logs.Any(l => l.Date >= dateFrom && l.Date <= dateTo)) + return true; + if (HasAnyDescendantDue(child.Id, lookup, dateFrom, dateTo, includeOverdue)) + return true; + } + return false; + } + + private static List MapChildren(Guid parentId, ScheduleMapContext ctx) + { + var children = ctx.ChildLookup[parentId]; + + if (!ctx.IncludeAllChildren && ctx.DateFrom.HasValue && ctx.DateTo.HasValue) + { + var df = ctx.DateFrom.Value; + var dt = ctx.DateTo.Value; + children = children + .Where(c => + { + var scheduledDates = ctx.GetScheduledDates(c); + var isOverdue = DetermineOverdueStatus(c, df, ctx.IncludeOverdue); + + return scheduledDates.Count > 0 + || c.IsCompleted + || isOverdue + || HasAnyDescendantDue(c.Id, ctx.ChildLookup, df, dt, ctx.IncludeOverdue) + || c.Logs.Any(l => l.Date >= df && l.Date <= dt); + }); + } + + return children + .OrderBy(c => c.Position ?? int.MaxValue) + .ThenBy(c => c.CreatedAtUtc) + .Select(c => MapSingleChild(c, ctx)) + .ToList(); + } + + private static HabitScheduleChildItem MapSingleChild(Habit c, ScheduleMapContext ctx) + { + var (ft, fc) = CalculateFlexibleProgress(c, ctx.ReferenceDate, ctx.WeekStartDay); + var isLoggedInRange = ctx.DateFrom.HasValue && ctx.DateTo.HasValue + && HabitScheduleService.HasCompletedLogInRange(c, ctx.DateFrom.Value, ctx.DateTo.Value); + var scheduledDates = ctx.DateFrom.HasValue && ctx.DateTo.HasValue + ? ctx.GetScheduledDates(c) + : []; + var isOverdue = ctx.DateFrom.HasValue + && DetermineOverdueStatus(c, ctx.DateFrom.Value, ctx.IncludeOverdue); + + var instances = ctx.DateFrom.HasValue && ctx.DateTo.HasValue && ctx.UserToday.HasValue + ? HabitScheduleService.GetInstances(c, ctx.DateFrom.Value, ctx.DateTo.Value, ctx.UserToday.Value) + : []; + + return new HabitScheduleChildItem( + c.Id, c.Title, c.Description, + c.FrequencyUnit, c.FrequencyQuantity, c.IsBadHabit, c.IsCompleted, c.IsGeneral, c.IsFlexible, + c.Days.ToList(), c.DueDate, c.DueTime, c.DueEndTime, c.EndDate, + scheduledDates, isOverdue, + c.Position, c.ChecklistItems, MapTags(c), + MapChildren(c.Id, ctx), + ctx.ChildLookup[c.Id].Any(), ft, fc, isLoggedInRange, + instances, + ComputeSearchMatches(c, ctx), + Emoji: c.Emoji); + } + + private static List MapTags(Habit h) => + h.Tags.Select(t => new HabitTagItem(t.Id, t.Name, t.Color)).ToList(); + + private static List MapGoals(Habit h) => + h.Goals.Select(g => new LinkedGoalDto(g.Id, g.Title)).ToList(); +} diff --git a/src/Orbit.Domain/Entities/Goal.cs b/src/Orbit.Domain/Entities/Goal.cs index 88c39eb7..6326512b 100644 --- a/src/Orbit.Domain/Entities/Goal.cs +++ b/src/Orbit.Domain/Entities/Goal.cs @@ -77,14 +77,9 @@ public static Result Create(CreateGoalParams p) if (p.UserId == Guid.Empty) return Result.Failure(DomainErrors.UserIdRequired); - if (string.IsNullOrWhiteSpace(p.Title)) - return Result.Failure(DomainErrors.TitleRequired); - - if (p.TargetValue <= 0) - return Result.Failure(DomainErrors.TargetValueInvalid); - - if (string.IsNullOrWhiteSpace(p.Unit)) - return Result.Failure(DomainErrors.UnitRequired); + var coreFieldsValidation = GoalInvariants.ValidateCoreFields(p.Title, p.TargetValue, p.Unit); + if (coreFieldsValidation is not null) + return Result.Failure(coreFieldsValidation); return Result.Success(new Goal { @@ -174,14 +169,9 @@ private bool TryComplete() /// public Result Update(string title, string? description, decimal targetValue, string unit, DateOnly? deadline) { - if (string.IsNullOrWhiteSpace(title)) - return Result.Failure(DomainErrors.TitleRequired); - - if (targetValue <= 0) - return Result.Failure(DomainErrors.TargetValueInvalid); - - if (string.IsNullOrWhiteSpace(unit)) - return Result.Failure(DomainErrors.UnitRequired); + var coreFieldsValidation = GoalInvariants.ValidateCoreFields(title, targetValue, unit); + if (coreFieldsValidation is not null) + return Result.Failure(coreFieldsValidation); Title = title.Trim(); Description = description?.Trim(); diff --git a/src/Orbit.Domain/Entities/GoalInvariants.cs b/src/Orbit.Domain/Entities/GoalInvariants.cs new file mode 100644 index 00000000..bcb213ca --- /dev/null +++ b/src/Orbit.Domain/Entities/GoalInvariants.cs @@ -0,0 +1,25 @@ +using Orbit.Domain.Common; + +namespace Orbit.Domain.Entities; + +/// +/// Pure validation guards for invariants shared by the create and update paths. +/// Returns the matching entry on the first violation (title, then target +/// value, then unit), or null when the core fields are valid. +/// +internal static class GoalInvariants +{ + public static AppError? ValidateCoreFields(string title, decimal targetValue, string unit) + { + if (string.IsNullOrWhiteSpace(title)) + return DomainErrors.TitleRequired; + + if (targetValue <= 0) + return DomainErrors.TargetValueInvalid; + + if (string.IsNullOrWhiteSpace(unit)) + return DomainErrors.UnitRequired; + + return null; + } +} diff --git a/src/Orbit.Domain/Entities/Habit.cs b/src/Orbit.Domain/Entities/Habit.cs index fdce5126..f453d532 100644 --- a/src/Orbit.Domain/Entities/Habit.cs +++ b/src/Orbit.Domain/Entities/Habit.cs @@ -111,21 +111,21 @@ public static Result Create(HabitCreateParams p) if (string.IsNullOrWhiteSpace(p.Title)) return Result.Failure(DomainErrors.TitleRequired); - var emojiValidation = ValidateEmoji(p.Emoji); + var emojiValidation = HabitInvariants.ValidateEmoji(p.Emoji); if (emojiValidation is not null) return Result.Failure(emojiValidation); - var scheduleValidation = ValidateScheduleOptions( + var scheduleValidation = HabitInvariants.ValidateScheduleOptions( p.IsGeneral, p.IsFlexible, p.IsBadHabit, p.FrequencyUnit, p.FrequencyQuantity, p.Days); if (scheduleValidation is not null) return Result.Failure(scheduleValidation); - var dateValidation = ValidateDateOptions( + var dateValidation = HabitInvariants.ValidateDateOptions( p.DueTime, p.DueEndTime, p.EndDate, p.FrequencyUnit, p.IsGeneral, p.DueDate); if (dateValidation is not null) return Result.Failure(dateValidation); - var reminderValidation = ValidateScheduledReminders(p.ScheduledReminders); + var reminderValidation = HabitInvariants.ValidateScheduledReminders(p.ScheduledReminders); if (reminderValidation is not null) return Result.Failure(reminderValidation); @@ -136,7 +136,7 @@ public static Result Create(HabitCreateParams p) UserId = p.UserId, Title = p.Title.Trim(), Description = p.Description?.Trim(), - Emoji = NormalizeEmoji(p.Emoji), + Emoji = HabitInvariants.NormalizeEmoji(p.Emoji), FrequencyUnit = p.FrequencyUnit, FrequencyQuantity = p.FrequencyQuantity, Days = p.IsFlexible ? [] : (p.Days?.ToList() ?? []), @@ -328,23 +328,23 @@ public Result Update(HabitUpdateParams p) var effectiveIsGeneral = p.IsGeneral ?? IsGeneral; var effectiveIsFlexible = p.IsFlexible ?? IsFlexible; - var scheduleValidation = ValidateScheduleOptions( + var scheduleValidation = HabitInvariants.ValidateScheduleOptions( effectiveIsGeneral, effectiveIsFlexible, p.IsBadHabit, p.FrequencyUnit, p.FrequencyQuantity, p.Days); if (scheduleValidation is not null) return scheduleValidation; - var dateValidation = ValidateDateOptions( + var dateValidation = HabitInvariants.ValidateDateOptions( p.DueTime ?? DueTime, p.DueEndTime ?? DueEndTime, p.ClearEndDate == true ? null : (p.EndDate ?? EndDate), p.FrequencyUnit, effectiveIsGeneral, p.DueDate ?? DueDate); if (dateValidation is not null) return dateValidation; - var emojiValidation = ValidateEmoji(p.Emoji); + var emojiValidation = HabitInvariants.ValidateEmoji(p.Emoji); if (emojiValidation is not null) return emojiValidation; - return ValidateScheduledReminders(p.ScheduledReminders); + return HabitInvariants.ValidateScheduledReminders(p.ScheduledReminders); } private void ApplyRequiredUpdates(HabitUpdateParams p) @@ -353,7 +353,7 @@ private void ApplyRequiredUpdates(HabitUpdateParams p) Title = p.Title.Trim(); Description = p.Description?.Trim(); - Emoji = NormalizeEmoji(p.Emoji); + Emoji = HabitInvariants.NormalizeEmoji(p.Emoji); FrequencyUnit = p.FrequencyUnit; FrequencyQuantity = p.FrequencyQuantity; Days = effectiveIsFlexible ? [] : (p.Days?.ToList() ?? []); @@ -447,84 +447,4 @@ public void SoftDelete() public void AddGoal(Goal goal) { if (!_goals.Contains(goal)) _goals.Add(goal); } public void RemoveGoal(Goal goal) => _goals.Remove(goal); - - private static AppError? ValidateScheduleOptions( - bool isGeneral, bool isFlexible, bool isBadHabit, - FrequencyUnit? frequencyUnit, int? frequencyQuantity, - IReadOnlyList? days) - { - if (isGeneral && (frequencyUnit is not null || frequencyQuantity is not null)) - return DomainErrors.GeneralHabitHasFrequency; - - if (isGeneral && isBadHabit) - return DomainErrors.GeneralHabitIsBadHabit; - - if (frequencyQuantity is not null && frequencyQuantity <= 0) - return DomainErrors.FrequencyQuantityInvalid; - - if (isFlexible && frequencyUnit is null) - return DomainErrors.FlexibleNeedsFrequencyUnit; - - if (isFlexible && days?.Count > 0) - return DomainErrors.FlexibleHasDays; - - if (!isFlexible && days?.Count > 0 && (frequencyQuantity != 1 || frequencyUnit != Enums.FrequencyUnit.Day)) - return DomainErrors.DaysRequireQuantityOne; - - return null; - } - - private static AppError? ValidateDateOptions( - TimeOnly? dueTime, TimeOnly? dueEndTime, - DateOnly? endDate, FrequencyUnit? frequencyUnit, - bool isGeneral, DateOnly? dueDate) - { - if (dueEndTime.HasValue && dueTime.HasValue && dueEndTime.Value <= dueTime.Value) - return DomainErrors.EndTimeBeforeStartTime; - - if (endDate.HasValue && frequencyUnit is null && !isGeneral) - return DomainErrors.OneTimeTaskHasEndDate; - - var effectiveDueDate = dueDate ?? DateOnly.FromDateTime(DateTime.UtcNow); - if (endDate.HasValue && endDate.Value < effectiveDueDate) - return DomainErrors.EndDateBeforeStartDate; - - return null; - } - - private static AppError? ValidateScheduledReminders( - IReadOnlyList? scheduledReminders) - { - if (scheduledReminders is null) - return null; - - if (scheduledReminders.Count > DomainConstants.MaxScheduledReminders) - return DomainErrors.MaxScheduledReminders.Format(DomainConstants.MaxScheduledReminders); - - var hasDuplicates = scheduledReminders - .GroupBy(sr => (sr.When, sr.Time)) - .Any(g => g.Count() > 1); - - if (hasDuplicates) - return DomainErrors.DuplicateScheduledReminders; - - return null; - } - - private static AppError? ValidateEmoji(string? emoji) - { - if (emoji is null) - return null; - - if (emoji.Trim().Length > DomainConstants.MaxHabitEmojiLength) - return DomainErrors.EmojiTooLong.Format(DomainConstants.MaxHabitEmojiLength); - - return null; - } - - private static string? NormalizeEmoji(string? emoji) - { - var normalized = emoji?.Trim(); - return string.IsNullOrWhiteSpace(normalized) ? null : normalized; - } } diff --git a/src/Orbit.Domain/Entities/HabitInvariants.cs b/src/Orbit.Domain/Entities/HabitInvariants.cs new file mode 100644 index 00000000..b21f6801 --- /dev/null +++ b/src/Orbit.Domain/Entities/HabitInvariants.cs @@ -0,0 +1,94 @@ +using Orbit.Domain.Common; +using Orbit.Domain.Enums; +using Orbit.Domain.ValueObjects; + +namespace Orbit.Domain.Entities; + +/// +/// Pure validation and normalization guards for invariants. +/// Each method returns the matching entry on violation (or null when valid), +/// exactly as the factory and update paths expect; trims and collapses +/// blank emoji to null. +/// +internal static class HabitInvariants +{ + public static AppError? ValidateScheduleOptions( + bool isGeneral, bool isFlexible, bool isBadHabit, + FrequencyUnit? frequencyUnit, int? frequencyQuantity, + IReadOnlyList? days) + { + if (isGeneral && (frequencyUnit is not null || frequencyQuantity is not null)) + return DomainErrors.GeneralHabitHasFrequency; + + if (isGeneral && isBadHabit) + return DomainErrors.GeneralHabitIsBadHabit; + + if (frequencyQuantity is not null && frequencyQuantity <= 0) + return DomainErrors.FrequencyQuantityInvalid; + + if (isFlexible && frequencyUnit is null) + return DomainErrors.FlexibleNeedsFrequencyUnit; + + if (isFlexible && days?.Count > 0) + return DomainErrors.FlexibleHasDays; + + if (!isFlexible && days?.Count > 0 && (frequencyQuantity != 1 || frequencyUnit != Enums.FrequencyUnit.Day)) + return DomainErrors.DaysRequireQuantityOne; + + return null; + } + + public static AppError? ValidateDateOptions( + TimeOnly? dueTime, TimeOnly? dueEndTime, + DateOnly? endDate, FrequencyUnit? frequencyUnit, + bool isGeneral, DateOnly? dueDate) + { + if (dueEndTime.HasValue && dueTime.HasValue && dueEndTime.Value <= dueTime.Value) + return DomainErrors.EndTimeBeforeStartTime; + + if (endDate.HasValue && frequencyUnit is null && !isGeneral) + return DomainErrors.OneTimeTaskHasEndDate; + + var effectiveDueDate = dueDate ?? DateOnly.FromDateTime(DateTime.UtcNow); + if (endDate.HasValue && endDate.Value < effectiveDueDate) + return DomainErrors.EndDateBeforeStartDate; + + return null; + } + + public static AppError? ValidateScheduledReminders( + IReadOnlyList? scheduledReminders) + { + if (scheduledReminders is null) + return null; + + if (scheduledReminders.Count > DomainConstants.MaxScheduledReminders) + return DomainErrors.MaxScheduledReminders.Format(DomainConstants.MaxScheduledReminders); + + var hasDuplicates = scheduledReminders + .GroupBy(sr => (sr.When, sr.Time)) + .Any(g => g.Count() > 1); + + if (hasDuplicates) + return DomainErrors.DuplicateScheduledReminders; + + return null; + } + + public static AppError? ValidateEmoji(string? emoji) + { + if (emoji is null) + return null; + + if (emoji.Trim().Length > DomainConstants.MaxHabitEmojiLength) + return DomainErrors.EmojiTooLong.Format(DomainConstants.MaxHabitEmojiLength); + + return null; + } + + public static string? NormalizeEmoji(string? emoji) + { + var normalized = emoji?.Trim(); + return string.IsNullOrWhiteSpace(normalized) ? null : normalized; + } +} diff --git a/src/Orbit.Infrastructure/Services/AgentOperationExecutor.cs b/src/Orbit.Infrastructure/Services/AgentOperationExecutor.cs index 69216afd..a617a0e1 100644 --- a/src/Orbit.Infrastructure/Services/AgentOperationExecutor.cs +++ b/src/Orbit.Infrastructure/Services/AgentOperationExecutor.cs @@ -26,7 +26,7 @@ public async Task ExecuteAsync( { var operation = catalogService.GetOperation(request.OperationId); if (operation is null) - return UnknownOperationResponse(request.OperationId); + return AgentOperationResponseFactory.UnknownOperation(request.OperationId); var capability = catalogService.GetCapability(operation.CapabilityId) ?? throw new InvalidOperationException($"Operation '{operation.Id}' is mapped to an unknown capability '{operation.CapabilityId}'."); @@ -63,29 +63,15 @@ public async Task ExecuteAsync( var tool = toolRegistry.GetTool(operation.Id); if (tool is null) - return MissingToolExecutorResponse(execution); + return AgentOperationResponseFactory.MissingTool( + execution.Operation.Id, + execution.Capability.RiskClass, + execution.Capability.ConfirmationRequirement, + execution.Summary); return await ExecuteToolAsync(tool, execution, policyDecision, cancellationToken); } - private static AgentExecuteOperationResponse UnknownOperationResponse(string operationId) - { - return new AgentExecuteOperationResponse( - new AgentOperationResult( - operationId, - operationId, - AgentRiskClass.Low, - AgentConfirmationRequirement.None, - AgentOperationStatus.UnsupportedByPolicy, - PolicyReason: "unsupported_by_policy"), - PolicyDenial: new AgentPolicyDenial( - operationId, - operationId, - AgentRiskClass.Low, - AgentConfirmationRequirement.None, - "unsupported_by_policy")); - } - private async Task DenyDirectUserFlowAsync( OperationExecutionContext execution, CancellationToken cancellationToken) @@ -183,36 +169,13 @@ await TryAuditAsync( shadowReason: policyDecision.ShadowReason), cancellationToken); - return new AgentExecuteOperationResponse( - new AgentOperationResult( - execution.Operation.Id, - execution.Operation.Id, - execution.Capability.RiskClass, - execution.Capability.ConfirmationRequirement, - AgentOperationStatus.PendingConfirmation, - Summary: execution.Summary, - PolicyReason: policyDecision.Reason, - PendingOperationId: policyDecision.PendingOperation?.Id), - PendingOperation: policyDecision.PendingOperation); - } - - private static AgentExecuteOperationResponse MissingToolExecutorResponse(OperationExecutionContext execution) - { - return new AgentExecuteOperationResponse( - new AgentOperationResult( - execution.Operation.Id, - execution.Operation.Id, - execution.Capability.RiskClass, - execution.Capability.ConfirmationRequirement, - AgentOperationStatus.UnsupportedByPolicy, - Summary: execution.Summary, - PolicyReason: "missing_tool_executor"), - PolicyDenial: new AgentPolicyDenial( - execution.Operation.Id, - execution.Operation.Id, - execution.Capability.RiskClass, - execution.Capability.ConfirmationRequirement, - "missing_tool_executor")); + return AgentOperationResponseFactory.ConfirmationRequired( + execution.Operation.Id, + execution.Capability.RiskClass, + execution.Capability.ConfirmationRequirement, + execution.Summary, + policyDecision.Reason, + policyDecision.PendingOperation); } private async Task ExecuteToolAsync( @@ -241,14 +204,11 @@ await TryAuditAsync( shadowReason: policyDecision.ShadowReason), cancellationToken); - return new AgentExecuteOperationResponse(new AgentOperationResult( - execution.Operation.Id, + return AgentOperationResponseFactory.Failed( execution.Operation.Id, execution.Capability.RiskClass, execution.Capability.ConfirmationRequirement, - AgentOperationStatus.Failed, - Summary: execution.Summary, - PolicyReason: "unexpected_error")); + execution.Summary); } } @@ -299,26 +259,14 @@ await TryAuditAsync( policyDecision.ShadowReason), cancellationToken); - return new AgentExecuteOperationResponse( - new AgentOperationResult( - execution.Operation.Id, - execution.Operation.Id, - execution.Capability.RiskClass, - execution.Capability.ConfirmationRequirement, - outcomeStatus, - Summary: execution.Summary, - TargetId: result.EntityId, - TargetName: result.EntityName, - PolicyReason: result.Success ? null : result.Error, - Payload: result.Payload), - PolicyDenial: isPayGateDenial - ? new AgentPolicyDenial( - execution.Operation.Id, - execution.Operation.Id, - execution.Capability.RiskClass, - execution.Capability.ConfirmationRequirement, - result.Error ?? Result.PayGateErrorCode) - : null); + return AgentOperationResponseFactory.ToolOutcome( + execution.Operation.Id, + execution.Capability.RiskClass, + execution.Capability.ConfirmationRequirement, + execution.Summary, + result, + outcomeStatus, + isPayGateDenial); } private static AgentExecuteOperationResponse DeniedResponse( @@ -327,21 +275,13 @@ private static AgentExecuteOperationResponse DeniedResponse( string? policyReason, string denialReason) { - return new AgentExecuteOperationResponse( - new AgentOperationResult( - execution.Operation.Id, - execution.Operation.Id, - execution.Capability.RiskClass, - execution.Capability.ConfirmationRequirement, - AgentOperationStatus.Denied, - Summary: summary, - PolicyReason: policyReason), - PolicyDenial: new AgentPolicyDenial( - execution.Operation.Id, - execution.Operation.Id, - execution.Capability.RiskClass, - execution.Capability.ConfirmationRequirement, - denialReason)); + return AgentOperationResponseFactory.Denied( + execution.Operation.Id, + execution.Capability.RiskClass, + execution.Capability.ConfirmationRequirement, + summary, + policyReason, + denialReason); } private IReadOnlyList GetGrantedScopes(AgentExecuteOperationRequest request) diff --git a/src/Orbit.Infrastructure/Services/AgentOperationResponseFactory.cs b/src/Orbit.Infrastructure/Services/AgentOperationResponseFactory.cs new file mode 100644 index 00000000..b18bc384 --- /dev/null +++ b/src/Orbit.Infrastructure/Services/AgentOperationResponseFactory.cs @@ -0,0 +1,142 @@ +using Orbit.Application.Chat.Tools; +using Orbit.Domain.Common; +using Orbit.Domain.Models; + +namespace Orbit.Infrastructure.Services; + +internal static class AgentOperationResponseFactory +{ + public static AgentExecuteOperationResponse UnknownOperation(string operationId) + { + return new AgentExecuteOperationResponse( + new AgentOperationResult( + operationId, + operationId, + AgentRiskClass.Low, + AgentConfirmationRequirement.None, + AgentOperationStatus.UnsupportedByPolicy, + PolicyReason: "unsupported_by_policy"), + PolicyDenial: new AgentPolicyDenial( + operationId, + operationId, + AgentRiskClass.Low, + AgentConfirmationRequirement.None, + "unsupported_by_policy")); + } + + public static AgentExecuteOperationResponse MissingTool( + string operationId, + AgentRiskClass riskClass, + AgentConfirmationRequirement confirmationRequirement, + string summary) + { + return new AgentExecuteOperationResponse( + new AgentOperationResult( + operationId, + operationId, + riskClass, + confirmationRequirement, + AgentOperationStatus.UnsupportedByPolicy, + Summary: summary, + PolicyReason: "missing_tool_executor"), + PolicyDenial: new AgentPolicyDenial( + operationId, + operationId, + riskClass, + confirmationRequirement, + "missing_tool_executor")); + } + + public static AgentExecuteOperationResponse Denied( + string operationId, + AgentRiskClass riskClass, + AgentConfirmationRequirement confirmationRequirement, + string summary, + string? policyReason, + string denialReason) + { + return new AgentExecuteOperationResponse( + new AgentOperationResult( + operationId, + operationId, + riskClass, + confirmationRequirement, + AgentOperationStatus.Denied, + Summary: summary, + PolicyReason: policyReason), + PolicyDenial: new AgentPolicyDenial( + operationId, + operationId, + riskClass, + confirmationRequirement, + denialReason)); + } + + public static AgentExecuteOperationResponse ConfirmationRequired( + string operationId, + AgentRiskClass riskClass, + AgentConfirmationRequirement confirmationRequirement, + string summary, + string? policyReason, + PendingAgentOperation? pendingOperation) + { + return new AgentExecuteOperationResponse( + new AgentOperationResult( + operationId, + operationId, + riskClass, + confirmationRequirement, + AgentOperationStatus.PendingConfirmation, + Summary: summary, + PolicyReason: policyReason, + PendingOperationId: pendingOperation?.Id), + PendingOperation: pendingOperation); + } + + public static AgentExecuteOperationResponse Failed( + string operationId, + AgentRiskClass riskClass, + AgentConfirmationRequirement confirmationRequirement, + string summary) + { + return new AgentExecuteOperationResponse(new AgentOperationResult( + operationId, + operationId, + riskClass, + confirmationRequirement, + AgentOperationStatus.Failed, + Summary: summary, + PolicyReason: "unexpected_error")); + } + + public static AgentExecuteOperationResponse ToolOutcome( + string operationId, + AgentRiskClass riskClass, + AgentConfirmationRequirement confirmationRequirement, + string summary, + ToolResult result, + AgentOperationStatus outcomeStatus, + bool isPayGateDenial) + { + return new AgentExecuteOperationResponse( + new AgentOperationResult( + operationId, + operationId, + riskClass, + confirmationRequirement, + outcomeStatus, + Summary: summary, + TargetId: result.EntityId, + TargetName: result.EntityName, + PolicyReason: result.Success ? null : result.Error, + Payload: result.Payload), + PolicyDenial: isPayGateDenial + ? new AgentPolicyDenial( + operationId, + operationId, + riskClass, + confirmationRequirement, + result.Error ?? Result.PayGateErrorCode) + : null); + } +} diff --git a/src/Orbit.Infrastructure/Services/GoalDeadlineNotificationService.cs b/src/Orbit.Infrastructure/Services/GoalDeadlineNotificationService.cs index 9fb13b16..cf2267fa 100644 --- a/src/Orbit.Infrastructure/Services/GoalDeadlineNotificationService.cs +++ b/src/Orbit.Infrastructure/Services/GoalDeadlineNotificationService.cs @@ -1,7 +1,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Orbit.Application.Common; using Orbit.Application.Goals.Services; @@ -10,13 +9,14 @@ using Orbit.Domain.Interfaces; using Orbit.Infrastructure.BackgroundJobs; using Orbit.Infrastructure.Persistence; +using Orbit.Infrastructure.Services.Hosting; namespace Orbit.Infrastructure.Services; public partial class GoalDeadlineNotificationService( IServiceScopeFactory scopeFactory, ILogger logger, - IConfiguration configuration) : BackgroundService, IScheduledJob + IConfiguration configuration) : ScheduledServiceBase, IScheduledJob { private static readonly int[] NotifyDaysBefore = [7, 3, 1]; @@ -27,38 +27,21 @@ public partial class GoalDeadlineNotificationService( public string CronExpression => "*/30 * * * *"; - public async Task RunAsync(CancellationToken cancellationToken) + public Task RunAsync(CancellationToken cancellationToken) => ExecuteTickAsync(cancellationToken); + + protected override TimeSpan Interval => _interval; + + protected override async Task ExecuteTickAsync(CancellationToken stoppingToken) { - await CheckAndSendDeadlineNotifications(cancellationToken); + await CheckAndSendDeadlineNotifications(stoppingToken); BackgroundServiceHealthCheck.RecordTick("GoalDeadlineNotification"); } - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - LogServiceStarted(logger); + protected override void LogStarted() => LogServiceStarted(logger); - try - { - while (!stoppingToken.IsCancellationRequested) - { - try - { - await CheckAndSendDeadlineNotifications(stoppingToken); - BackgroundServiceHealthCheck.RecordTick("GoalDeadlineNotification"); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - LogServiceError(logger, ex); - } - - await Task.Delay(_interval, stoppingToken); - } - } - finally - { - LogServiceStopped(logger); - } - } + protected override void LogStopped() => LogServiceStopped(logger); + + protected override void LogTickError(Exception ex) => LogServiceError(logger, ex); internal async Task CheckAndSendDeadlineNotifications(CancellationToken ct) { diff --git a/src/Orbit.Infrastructure/Services/Hosting/ScheduledServiceBase.cs b/src/Orbit.Infrastructure/Services/Hosting/ScheduledServiceBase.cs new file mode 100644 index 00000000..06fbb83b --- /dev/null +++ b/src/Orbit.Infrastructure/Services/Hosting/ScheduledServiceBase.cs @@ -0,0 +1,49 @@ +using Microsoft.Extensions.Hosting; + +namespace Orbit.Infrastructure.Services.Hosting; + +/// +/// Templates the in-process scheduler loop shared by the periodic +/// schedulers: log started, then until cancellation run one guarded by +/// a non-cancellation catch that logs the tick error, delay by , and log stopped +/// on exit. Each derived service supplies its own interval, per-tick work, and service-specific log +/// messages so timing, work, and logging output stay identical to the hand-rolled loops. +/// +public abstract class ScheduledServiceBase : BackgroundService +{ + protected abstract TimeSpan Interval { get; } + + protected abstract Task ExecuteTickAsync(CancellationToken stoppingToken); + + protected abstract void LogStarted(); + + protected abstract void LogStopped(); + + protected abstract void LogTickError(Exception ex); + + protected sealed override async Task ExecuteAsync(CancellationToken stoppingToken) + { + LogStarted(); + + try + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + await ExecuteTickAsync(stoppingToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + LogTickError(ex); + } + + await Task.Delay(Interval, stoppingToken); + } + } + finally + { + LogStopped(); + } + } +} diff --git a/src/Orbit.Infrastructure/Services/OpenAiBatchPollerService.cs b/src/Orbit.Infrastructure/Services/OpenAiBatchPollerService.cs index 1efa5240..7605b6be 100644 --- a/src/Orbit.Infrastructure/Services/OpenAiBatchPollerService.cs +++ b/src/Orbit.Infrastructure/Services/OpenAiBatchPollerService.cs @@ -1,7 +1,6 @@ using System.Text.Json; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Orbit.Application.Common; using Orbit.Domain.Entities; @@ -10,6 +9,7 @@ using Orbit.Domain.Models; using Orbit.Infrastructure.AI; using Orbit.Infrastructure.BackgroundJobs; +using Orbit.Infrastructure.Services.Hosting; namespace Orbit.Infrastructure.Services; @@ -22,7 +22,7 @@ namespace Orbit.Infrastructure.Services; public sealed partial class OpenAiBatchPollerService( IServiceScopeFactory scopeFactory, ILogger logger, - IConfiguration configuration) : BackgroundService, IScheduledJob + IConfiguration configuration) : ScheduledServiceBase, IScheduledJob { private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; @@ -33,38 +33,21 @@ public sealed partial class OpenAiBatchPollerService( public string CronExpression => "*/2 * * * *"; - public async Task RunAsync(CancellationToken cancellationToken) + public Task RunAsync(CancellationToken cancellationToken) => ExecuteTickAsync(cancellationToken); + + protected override TimeSpan Interval => _interval; + + protected override async Task ExecuteTickAsync(CancellationToken stoppingToken) { - await PollPendingBatches(cancellationToken); + await PollPendingBatches(stoppingToken); BackgroundServiceHealthCheck.RecordTick("OpenAiBatchPoller"); } - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - LogServiceStarted(logger); + protected override void LogStarted() => LogServiceStarted(logger); - try - { - while (!stoppingToken.IsCancellationRequested) - { - try - { - await PollPendingBatches(stoppingToken); - BackgroundServiceHealthCheck.RecordTick("OpenAiBatchPoller"); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - LogServiceError(logger, ex); - } - - await Task.Delay(_interval, stoppingToken); - } - } - finally - { - LogServiceStopped(logger); - } - } + protected override void LogStopped() => LogServiceStopped(logger); + + protected override void LogTickError(Exception ex) => LogServiceError(logger, ex); internal async Task PollPendingBatches(CancellationToken ct) { diff --git a/src/Orbit.Infrastructure/Services/ReminderSchedulerService.cs b/src/Orbit.Infrastructure/Services/ReminderSchedulerService.cs index bf8ec614..4a22147d 100644 --- a/src/Orbit.Infrastructure/Services/ReminderSchedulerService.cs +++ b/src/Orbit.Infrastructure/Services/ReminderSchedulerService.cs @@ -1,7 +1,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Orbit.Application.Common; using Orbit.Application.Habits.Services; @@ -10,13 +9,14 @@ using Orbit.Domain.Interfaces; using Orbit.Infrastructure.BackgroundJobs; using Orbit.Infrastructure.Persistence; +using Orbit.Infrastructure.Services.Hosting; namespace Orbit.Infrastructure.Services; public partial class ReminderSchedulerService( IServiceScopeFactory scopeFactory, ILogger logger, - IConfiguration configuration) : BackgroundService, IScheduledJob + IConfiguration configuration) : ScheduledServiceBase, IScheduledJob { private readonly TimeSpan _interval = TimeSpan.FromMinutes( configuration.GetValue("BackgroundServices:ReminderIntervalMinutes", 1)); @@ -25,38 +25,21 @@ public partial class ReminderSchedulerService( public string CronExpression => "* * * * *"; - public async Task RunAsync(CancellationToken cancellationToken) + public Task RunAsync(CancellationToken cancellationToken) => ExecuteTickAsync(cancellationToken); + + protected override TimeSpan Interval => _interval; + + protected override async Task ExecuteTickAsync(CancellationToken stoppingToken) { - await CheckAndSendReminders(cancellationToken); + await CheckAndSendReminders(stoppingToken); BackgroundServiceHealthCheck.RecordTick("ReminderScheduler"); } - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - LogServiceStarted(logger); + protected override void LogStarted() => LogServiceStarted(logger); - try - { - while (!stoppingToken.IsCancellationRequested) - { - try - { - await CheckAndSendReminders(stoppingToken); - BackgroundServiceHealthCheck.RecordTick("ReminderScheduler"); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - LogServiceError(logger, ex); - } - - await Task.Delay(_interval, stoppingToken); - } - } - finally - { - LogServiceStopped(logger); - } - } + protected override void LogStopped() => LogServiceStopped(logger); + + protected override void LogTickError(Exception ex) => LogServiceError(logger, ex); internal async Task CheckAndSendReminders(CancellationToken ct) { diff --git a/src/Orbit.Infrastructure/Services/SlipAlertSchedulerService.cs b/src/Orbit.Infrastructure/Services/SlipAlertSchedulerService.cs index 499d5895..979caa62 100644 --- a/src/Orbit.Infrastructure/Services/SlipAlertSchedulerService.cs +++ b/src/Orbit.Infrastructure/Services/SlipAlertSchedulerService.cs @@ -1,7 +1,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Orbit.Application.Common; using Orbit.Application.Habits.Services; @@ -9,13 +8,14 @@ using Orbit.Domain.Interfaces; using Orbit.Infrastructure.BackgroundJobs; using Orbit.Infrastructure.Persistence; +using Orbit.Infrastructure.Services.Hosting; namespace Orbit.Infrastructure.Services; public partial class SlipAlertSchedulerService( IServiceScopeFactory scopeFactory, ILogger logger, - IConfiguration configuration) : BackgroundService, IScheduledJob + IConfiguration configuration) : ScheduledServiceBase, IScheduledJob { private const int DefaultMorningHour = 8; private const int MaxTimeZoneSkewDays = 1; @@ -27,38 +27,21 @@ public partial class SlipAlertSchedulerService( public string CronExpression => "*/5 * * * *"; - public async Task RunAsync(CancellationToken cancellationToken) + public Task RunAsync(CancellationToken cancellationToken) => ExecuteTickAsync(cancellationToken); + + protected override TimeSpan Interval => _interval; + + protected override async Task ExecuteTickAsync(CancellationToken stoppingToken) { - await CheckAndSendAlerts(cancellationToken); + await CheckAndSendAlerts(stoppingToken); BackgroundServiceHealthCheck.RecordTick("SlipAlertScheduler"); } - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - LogServiceStarted(logger); + protected override void LogStarted() => LogServiceStarted(logger); - try - { - while (!stoppingToken.IsCancellationRequested) - { - try - { - await CheckAndSendAlerts(stoppingToken); - BackgroundServiceHealthCheck.RecordTick("SlipAlertScheduler"); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - LogServiceError(logger, ex); - } - - await Task.Delay(_interval, stoppingToken); - } - } - finally - { - LogServiceStopped(logger); - } - } + protected override void LogStopped() => LogServiceStopped(logger); + + protected override void LogTickError(Exception ex) => LogServiceError(logger, ex); internal async Task CheckAndSendAlerts(CancellationToken ct) { diff --git a/src/Orbit.Infrastructure/Services/StreakFreezeAutoActivationService.cs b/src/Orbit.Infrastructure/Services/StreakFreezeAutoActivationService.cs index 223cd85f..8b153c6d 100644 --- a/src/Orbit.Infrastructure/Services/StreakFreezeAutoActivationService.cs +++ b/src/Orbit.Infrastructure/Services/StreakFreezeAutoActivationService.cs @@ -1,13 +1,13 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Orbit.Application.Common; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; using Orbit.Infrastructure.BackgroundJobs; using Orbit.Infrastructure.Persistence; +using Orbit.Infrastructure.Services.Hosting; namespace Orbit.Infrastructure.Services; @@ -23,7 +23,7 @@ namespace Orbit.Infrastructure.Services; public partial class StreakFreezeAutoActivationService( IServiceScopeFactory scopeFactory, ILogger logger, - IConfiguration configuration) : BackgroundService, IScheduledJob + IConfiguration configuration) : ScheduledServiceBase, IScheduledJob { private const int MaxTimeZoneSkewDays = 1; @@ -34,38 +34,21 @@ public partial class StreakFreezeAutoActivationService( public string CronExpression => "0 * * * *"; - public async Task RunAsync(CancellationToken cancellationToken) + public Task RunAsync(CancellationToken cancellationToken) => ExecuteTickAsync(cancellationToken); + + protected override TimeSpan Interval => _interval; + + protected override async Task ExecuteTickAsync(CancellationToken stoppingToken) { - await ActivateMissedDayFreezes(cancellationToken); + await ActivateMissedDayFreezes(stoppingToken); BackgroundServiceHealthCheck.RecordTick("StreakFreezeAutoActivation"); } - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - LogServiceStarted(logger); + protected override void LogStarted() => LogServiceStarted(logger); - try - { - while (!stoppingToken.IsCancellationRequested) - { - try - { - await ActivateMissedDayFreezes(stoppingToken); - BackgroundServiceHealthCheck.RecordTick("StreakFreezeAutoActivation"); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - LogServiceError(logger, ex); - } - - await Task.Delay(_interval, stoppingToken); - } - } - finally - { - LogServiceStopped(logger); - } - } + protected override void LogStopped() => LogServiceStopped(logger); + + protected override void LogTickError(Exception ex) => LogServiceError(logger, ex); internal async Task ActivateMissedDayFreezes(CancellationToken ct) {