Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 31 additions & 25 deletions src/Orbit.Api/Controllers/AiController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,18 @@

namespace Orbit.Api.Controllers;

/// <summary>Groups the pending-agent-state stores the AI controller touches to keep its constructor small.</summary>
public record AgentPendingStores(
IPendingAgentOperationStore OperationStore,
IPendingClarificationStore ClarificationStore);

[Authorize]
[ApiController]
[Route("api/ai")]
public class AiController(
IAgentCatalogService catalogService,
IAgentPolicyEvaluator policyEvaluator,
IPendingAgentOperationStore pendingOperationStore,
IPendingClarificationStore pendingClarificationStore,
AgentPendingStores pendingStores,
IAgentStepUpService stepUpService,
IAgentAuditService auditService,
IAgentOperationExecutor operationExecutor,
Expand Down Expand Up @@ -112,7 +116,7 @@ public async Task<IActionResult> ConfirmPendingOperation(Guid id, CancellationTo
return Forbid();

var userId = HttpContext.GetUserId();
var confirmation = pendingOperationStore.Confirm(HttpContext.GetUserId(), id);
var confirmation = pendingStores.OperationStore.Confirm(HttpContext.GetUserId(), id);
await auditService.RecordAsync(new AgentAuditEntry(
userId,
AgentCapabilityIds.ChatInteract,
Expand Down Expand Up @@ -215,7 +219,7 @@ public async Task<IActionResult> ExecutePendingOperation(
return Forbid();

var userId = HttpContext.GetUserId();
var pendingExecution = pendingOperationStore.GetExecution(userId, id);
var pendingExecution = pendingStores.OperationStore.GetExecution(userId, id);
if (pendingExecution is null)
{
await auditService.RecordAsync(new AgentAuditEntry(
Expand Down Expand Up @@ -275,7 +279,7 @@ public async Task<IActionResult> ResolveClarification(
cancellationToken);
}

var pending = await pendingClarificationStore.GetForResolutionAsync(operationId, userId, cancellationToken);
var pending = await pendingStores.ClarificationStore.GetForResolutionAsync(operationId, userId, cancellationToken);
if (pending is null)
{
return await DenyResolveClarificationAsync(
Expand Down Expand Up @@ -314,7 +318,7 @@ public async Task<IActionResult> ResolveClarification(
cancellationToken);
}

var claimed = await pendingClarificationStore.MarkResolvedAsync(operationId, userId, cancellationToken);
var claimed = await pendingStores.ClarificationStore.MarkResolvedAsync(operationId, userId, cancellationToken);
if (!claimed)
{
var auditError = pending.ExpiresAtUtc <= DateTime.UtcNow
Expand Down Expand Up @@ -344,9 +348,7 @@ await RecordResolveAuditAsync(
userId,
authMethod,
operationId,
AgentPolicyDecisionStatus.Denied,
AgentOperationStatus.Failed,
auditError,
new ResolveAuditOutcome(AgentPolicyDecisionStatus.Denied, AgentOperationStatus.Failed, auditError),
cancellationToken);

return response;
Expand Down Expand Up @@ -375,26 +377,30 @@ await RecordResolveAuditAsync(
userId,
authMethod,
operationId,
result.Operation.Status == AgentOperationStatus.Succeeded
? AgentPolicyDecisionStatus.Allowed
: AgentPolicyDecisionStatus.Denied,
result.Operation.Status,
result.Operation.PolicyReason,
cancellationToken,
targetName: result.Operation.TargetName);
new ResolveAuditOutcome(
result.Operation.Status == AgentOperationStatus.Succeeded
? AgentPolicyDecisionStatus.Allowed
: AgentPolicyDecisionStatus.Denied,
result.Operation.Status,
result.Operation.PolicyReason,
result.Operation.TargetName),
cancellationToken);

return Ok(result);
}

private sealed record ResolveAuditOutcome(
AgentPolicyDecisionStatus PolicyDecision,
AgentOperationStatus Status,
string? Error,
string? TargetName = null);

private Task RecordResolveAuditAsync(
Guid userId,
AgentAuthMethod authMethod,
Guid operationId,
AgentPolicyDecisionStatus policyDecision,
AgentOperationStatus outcome,
string? error,
CancellationToken cancellationToken,
string? targetName = null)
ResolveAuditOutcome auditOutcome,
CancellationToken cancellationToken)
{
return auditService.RecordAsync(new AgentAuditEntry(
userId,
Expand All @@ -403,13 +409,13 @@ private Task RecordResolveAuditAsync(
AgentExecutionSurface.Chat,
authMethod,
AgentRiskClass.Low,
policyDecision,
outcome,
auditOutcome.PolicyDecision,
auditOutcome.Status,
HttpContext.TraceIdentifier,
"Resolve clarification",
TargetId: operationId.ToString(),
TargetName: targetName,
Error: error), cancellationToken);
TargetName: auditOutcome.TargetName,
Error: auditOutcome.Error), cancellationToken);
}

private static JsonElement MergeClarificationValue(string baseJson, string value)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ private static void AddAiPlatformServices(WebApplicationBuilder builder)
builder.Services.AddScoped<IAgentCatalogService, AgentCatalogService>();
builder.Services.AddScoped<IPendingAgentOperationStore, PendingAgentOperationStore>();
builder.Services.AddScoped<IPendingClarificationStore, PendingClarificationStore>();
builder.Services.AddScoped<Orbit.Api.Controllers.AgentPendingStores>();
builder.Services.AddScoped<IAgentStepUpService, AgentStepUpService>();
builder.Services.AddScoped<IAgentPolicyEvaluator, AgentPolicyEvaluator>();
builder.Services.AddScoped<IAgentAuditService, AgentAuditService>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ private static void AddStripeBilling(WebApplicationBuilder builder, TimeSpan htt
builder.Services.AddSingleton<Stripe.InvoiceService>();
builder.Services.AddSingleton<Stripe.PriceService>();
builder.Services.AddSingleton<Stripe.CouponService>();
builder.Services.AddSingleton<Orbit.Infrastructure.Services.StripeServiceClients>();
builder.Services.AddScoped<Orbit.Application.Common.IBillingService, Orbit.Infrastructure.Services.StripeBillingService>();
builder.Services.AddScoped<Orbit.Application.Subscriptions.Services.IPriceResolver, Orbit.Application.Subscriptions.Services.PriceResolver>();
}
Expand Down
64 changes: 64 additions & 0 deletions src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,70 @@ public static WebApplicationBuilder AddOrbitDatabase(this WebApplicationBuilder
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.AccountabilityPair>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.AccountabilityCheckIn>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.UserAchievement>>()));
builder.Services.AddScoped<Orbit.Application.Goals.Commands.GoalRepositories>(sp =>
new Orbit.Application.Goals.Commands.GoalRepositories(
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.Goal>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.GoalProgressLog>>()));
builder.Services.AddScoped<Orbit.Application.Habits.Commands.BulkCreateHabitsRepositories>(sp =>
new Orbit.Application.Habits.Commands.BulkCreateHabitsRepositories(
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.Habit>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.GoogleCalendarSyncSuggestion>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.Tag>>()));
builder.Services.AddScoped<Orbit.Application.Habits.Commands.SkipHabitRepositories>(sp =>
new Orbit.Application.Habits.Commands.SkipHabitRepositories(
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.Habit>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.HabitLog>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.Goal>>()));
builder.Services.AddScoped<Orbit.Application.Calendar.Queries.GetCalendarEventsRepositories>(sp =>
new Orbit.Application.Calendar.Queries.GetCalendarEventsRepositories(
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.User>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.Habit>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.GoogleCalendarSyncSuggestion>>()));
builder.Services.AddScoped<Orbit.Application.Profile.Commands.ApplyOnboardingRepositories>(sp =>
new Orbit.Application.Profile.Commands.ApplyOnboardingRepositories(
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.User>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.Habit>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.Goal>>()));
builder.Services.AddScoped<Orbit.Application.Challenges.Commands.CreateChallengeRepositories>(sp =>
new Orbit.Application.Challenges.Commands.CreateChallengeRepositories(
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.Challenge>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.Habit>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.User>>()));
builder.Services.AddScoped<Orbit.Application.Social.Queries.GetFriendProfileRepositories>(sp =>
new Orbit.Application.Social.Queries.GetFriendProfileRepositories(
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.User>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.UserAchievement>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.Habit>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.AccountabilityPair>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.Challenge>>()));
builder.Services.AddScoped<Orbit.Infrastructure.Services.UserStreakRepositories>(sp =>
new Orbit.Infrastructure.Services.UserStreakRepositories(
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.User>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.Habit>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.HabitLog>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.StreakFreeze>>()));
builder.Services.AddScoped<Orbit.Application.Profile.Queries.ExportUserDataRepositories>(sp =>
new Orbit.Application.Profile.Queries.ExportUserDataRepositories(
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.User>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.Habit>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.HabitLog>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.Goal>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.GoalProgressLog>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.Tag>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.UserFact>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.Notification>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.ChecklistTemplate>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.UserAchievement>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.StreakFreeze>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.Referral>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.ApiKey>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.Friendship>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.Cheer>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.BlockedUser>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.Report>>(),
sp.GetRequiredService<IGenericRepository<Orbit.Domain.Entities.FriendFeedEvent>>()));
builder.Services.AddScoped<Orbit.Application.Social.Services.SocialInteractionServices>();
builder.Services.AddScoped<Orbit.Application.Gamification.Services.GamificationNotifiers>();
builder.Services.AddScoped<IGoogleTokenService, GoogleTokenService>();
builder.Services.AddGoogleCalendarServices(GetDefaultHttpTimeout(builder));
builder.Services.AddSingleton(TimeProvider.System);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,9 @@ namespace Orbit.Application.Accountability.Commands;
public record CheckInAccountabilityCommand(Guid UserId, Guid PairId, string? Note) : IRequest<Result<Guid>>;

public partial class CheckInAccountabilityCommandHandler(
SocialAccessGuard socialAccessGuard,
SocialInteractionServices social,
AccountabilityPairService accountabilityPairService,
FriendGraphService friendGraphService,
AccountabilityRepositories repositories,
SocialNotificationDispatcher notificationDispatcher,
IContentModerationService contentModerationService,
IUserDateService userDateService,
IUnitOfWork unitOfWork,
Expand All @@ -27,7 +25,7 @@ public partial class CheckInAccountabilityCommandHandler(

public async Task<Result<Guid>> Handle(CheckInAccountabilityCommand request, CancellationToken cancellationToken)
{
var access = await socialAccessGuard.EnsureEnabledAsync(request.UserId, cancellationToken);
var access = await social.AccessGuard.EnsureEnabledAsync(request.UserId, cancellationToken);
if (access.IsFailure)
return access.PropagateError<Guid>();
var checker = access.Value;
Expand All @@ -38,7 +36,7 @@ public async Task<Result<Guid>> Handle(CheckInAccountabilityCommand request, Can

var buddyId = pair.RequesterId == request.UserId ? pair.AddresseeId : pair.RequesterId;

if (await friendGraphService.IsBlockedBetweenAsync(request.UserId, buddyId, cancellationToken))
if (await social.FriendGraph.IsBlockedBetweenAsync(request.UserId, buddyId, cancellationToken))
return Result.Failure<Guid>(ErrorMessages.Blocked);

var note = string.IsNullOrWhiteSpace(request.Note) ? null : request.Note.Trim();
Expand All @@ -65,11 +63,11 @@ public async Task<Result<Guid>> Handle(CheckInAccountabilityCommand request, Can
cancellationToken: cancellationToken);
var notification = BuildBuddyNotification(buddy, checker);
if (notification is not null)
await notificationDispatcher.StageAsync(notification, cancellationToken);
await social.NotificationDispatcher.StageAsync(notification, cancellationToken);
await unitOfWork.SaveChangesAsync(cancellationToken);

if (notification is not null)
await notificationDispatcher.PushAsync(notification, cancellationToken);
await social.NotificationDispatcher.PushAsync(notification, cancellationToken);

return Result.Success(createResult.Value.Id);
}
Expand Down
16 changes: 10 additions & 6 deletions src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,14 @@ public record CalendarEventItem(

public record GetCalendarEventsQuery(Guid UserId) : IRequest<Result<List<CalendarEventItem>>>, IConcurrencyRetryable;

/// <summary>Groups the repositories the calendar events query touches to keep the handler constructor small.</summary>
public record GetCalendarEventsRepositories(
IGenericRepository<User> Users,
IGenericRepository<Habit> Habits,
IGenericRepository<GoogleCalendarSyncSuggestion> Suggestions);

public partial class GetCalendarEventsQueryHandler(
IGenericRepository<User> userRepository,
IGenericRepository<Habit> habitRepository,
IGenericRepository<GoogleCalendarSyncSuggestion> suggestionRepository,
GetCalendarEventsRepositories repos,
IPayGateService payGate,
IGoogleTokenService googleTokenService,
ICalendarEventFetcher eventFetcher,
Expand All @@ -41,7 +45,7 @@ public async Task<Result<List<CalendarEventItem>>> Handle(GetCalendarEventsQuery
if (gateCheck.IsFailure)
return gateCheck.PropagateError<List<CalendarEventItem>>();

var user = await userRepository.GetByIdAsync(request.UserId, cancellationToken);
var user = await repos.Users.GetByIdAsync(request.UserId, cancellationToken);
if (user is null)
return Result.Failure<List<CalendarEventItem>>(ErrorMessages.UserNotFound);

Expand Down Expand Up @@ -107,12 +111,12 @@ public async Task<Result<List<CalendarEventItem>>> Handle(GetCalendarEventsQuery

private async Task<HashSet<string>> BuildImportedEventIdSet(Guid userId, CancellationToken ct)
{
var habitEventIds = (await habitRepository.FindAsync(
var habitEventIds = (await repos.Habits.FindAsync(
h => h.UserId == userId && h.GoogleEventId != null, ct))
.Select(h => h.GoogleEventId!)
.ToList();

var pendingSuggestionEventIds = (await suggestionRepository.FindAsync(
var pendingSuggestionEventIds = (await repos.Suggestions.FindAsync(
s => s.UserId == userId && s.DismissedAtUtc == null && s.ImportedAtUtc == null, ct))
.Select(s => s.GoogleEventId)
.ToList();
Expand Down
Loading
Loading