diff --git a/src/Orbit.Api/Controllers/FriendsController.cs b/src/Orbit.Api/Controllers/FriendsController.cs new file mode 100644 index 00000000..6713a5aa --- /dev/null +++ b/src/Orbit.Api/Controllers/FriendsController.cs @@ -0,0 +1,181 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Orbit.Api.Extensions; +using Orbit.Api.RateLimiting; +using Orbit.Application.Social.Commands; +using Orbit.Application.Social.Queries; +using Orbit.Domain.Enums; + +namespace Orbit.Api.Controllers; + +[Authorize] +[ApiController] +[Route("api/friends")] +public partial class FriendsController(IMediator mediator, ILogger logger) : ControllerBase +{ + public record SendFriendRequestBody(string? Handle, string? ReferralCode); + public record SendCheerBody(Guid RecipientId, Guid HabitId, string? Note); + public record BlockUserBody(Guid BlockedUserId); + public record ReportUserBody(Guid ReportedUserId, ReportReason Reason, string? Details, Guid? CheerId); + + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task GetFriends(CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetFriendsQuery(HttpContext.GetUserId()), cancellationToken); + return result.ToPayGateAwareResult(value => Ok(value)); + } + + [HttpGet("feed")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task GetFeed( + [FromQuery] string? cursor, + [FromQuery] int? pageSize, + CancellationToken cancellationToken) + { + var query = new GetFriendFeedQuery(HttpContext.GetUserId(), cursor, pageSize); + var result = await mediator.Send(query, cancellationToken); + return result.ToPayGateAwareResult(value => Ok(value)); + } + + [HttpGet("cheers")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task GetCheers( + [FromQuery] string direction = GetCheersQueryHandler.ReceivedDirection, + CancellationToken cancellationToken = default) + { + var query = new GetCheersQuery(HttpContext.GetUserId(), direction); + var result = await mediator.Send(query, cancellationToken); + return result.ToPayGateAwareResult(value => Ok(value)); + } + + [HttpPost("requests")] + [DistributedRateLimit("friend-requests")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task SendRequest( + [FromBody] SendFriendRequestBody body, + CancellationToken cancellationToken) + { + var userId = HttpContext.GetUserId(); + var command = new SendFriendRequestCommand(userId, body.Handle, body.ReferralCode); + var result = await mediator.Send(command, cancellationToken); + + if (result.IsSuccess) + LogFriendRequestSent(logger, userId); + + return result.ToPayGateAwareResult(id => Ok(new { id })); + } + + [HttpPost("requests/{friendshipId:guid}/accept")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task AcceptRequest(Guid friendshipId, CancellationToken cancellationToken) + { + var userId = HttpContext.GetUserId(); + var result = await mediator.Send(new AcceptFriendRequestCommand(userId, friendshipId), cancellationToken); + + if (result.IsSuccess) + LogFriendRequestAccepted(logger, userId); + + return result.ToPayGateAwareResult(() => NoContent()); + } + + [HttpDelete("{friendUserId:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task RemoveFriend(Guid friendUserId, CancellationToken cancellationToken) + { + var command = new RemoveFriendCommand(HttpContext.GetUserId(), friendUserId); + var result = await mediator.Send(command, cancellationToken); + return result.ToPayGateAwareResult(() => NoContent()); + } + + [HttpPost("cheers")] + [DistributedRateLimit("cheers")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task SendCheer( + [FromBody] SendCheerBody body, + CancellationToken cancellationToken) + { + var userId = HttpContext.GetUserId(); + var command = new SendCheerCommand(userId, body.RecipientId, body.HabitId, body.Note); + var result = await mediator.Send(command, cancellationToken); + + if (result.IsSuccess) + LogCheerSent(logger, userId); + + return result.ToPayGateAwareResult(id => Ok(new { id })); + } + + [HttpPost("block")] + [DistributedRateLimit("block")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task Block( + [FromBody] BlockUserBody body, + CancellationToken cancellationToken) + { + var userId = HttpContext.GetUserId(); + var result = await mediator.Send(new BlockUserCommand(userId, body.BlockedUserId), cancellationToken); + + if (result.IsSuccess) + LogUserBlocked(logger, userId); + + return result.ToPayGateAwareResult(() => NoContent()); + } + + [HttpDelete("block/{blockedUserId:guid}")] + [DistributedRateLimit("unblock")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task Unblock(Guid blockedUserId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new UnblockUserCommand(HttpContext.GetUserId(), blockedUserId), cancellationToken); + return result.ToPayGateAwareResult(() => NoContent()); + } + + [HttpPost("report")] + [DistributedRateLimit("reports")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task Report( + [FromBody] ReportUserBody body, + CancellationToken cancellationToken) + { + var userId = HttpContext.GetUserId(); + var command = new ReportUserCommand(userId, body.ReportedUserId, body.Reason, body.Details, body.CheerId); + var result = await mediator.Send(command, cancellationToken); + + if (result.IsSuccess) + LogUserReported(logger, userId); + + return result.ToPayGateAwareResult(id => Ok(new { id })); + } + + [LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "Friend request sent by user {UserId}")] + private static partial void LogFriendRequestSent(ILogger logger, Guid userId); + + [LoggerMessage(EventId = 2, Level = LogLevel.Information, Message = "Friend request accepted by user {UserId}")] + private static partial void LogFriendRequestAccepted(ILogger logger, Guid userId); + + [LoggerMessage(EventId = 3, Level = LogLevel.Information, Message = "Cheer sent by user {UserId}")] + private static partial void LogCheerSent(ILogger logger, Guid userId); + + [LoggerMessage(EventId = 4, Level = LogLevel.Information, Message = "User blocked by user {UserId}")] + private static partial void LogUserBlocked(ILogger logger, Guid userId); + + [LoggerMessage(EventId = 5, Level = LogLevel.Information, Message = "User reported by user {UserId}")] + private static partial void LogUserReported(ILogger logger, Guid userId); +} diff --git a/src/Orbit.Api/Controllers/GamificationController.cs b/src/Orbit.Api/Controllers/GamificationController.cs index f370a58b..8428ead1 100644 --- a/src/Orbit.Api/Controllers/GamificationController.cs +++ b/src/Orbit.Api/Controllers/GamificationController.cs @@ -2,14 +2,17 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Orbit.Api.Extensions; +using Orbit.Application.Common; using Orbit.Application.Gamification.Queries; +using Orbit.Application.Habits.Queries; +using Orbit.Domain.Interfaces; namespace Orbit.Api.Controllers; [Authorize] [ApiController] [Route("api/[controller]")] -public class GamificationController(IMediator mediator) : ControllerBase +public class GamificationController(IMediator mediator, IUserDateService userDateService) : ControllerBase { [HttpGet("profile")] [ProducesResponseType(StatusCodes.Status200OK)] @@ -48,4 +51,26 @@ public async Task GetStreakInfo(CancellationToken cancellationTok return result.ToPayGateAwareResult(v => Ok(v)); } + + [HttpGet("recap")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task GetRecap( + [FromQuery] string period, + CancellationToken cancellationToken) + { + if (!RetrospectivePeriodRange.IsKnownPeriod(period)) + return BadRequest(ErrorMessages.InvalidPeriod.ToErrorBody()); + + var userId = HttpContext.GetUserId(); + var today = await userDateService.GetUserTodayAsync(userId, cancellationToken); + var weekStartDay = await userDateService.GetUserWeekStartDayAsync(userId, cancellationToken); + var (dateFrom, dateTo) = RetrospectivePeriodRange.Resolve(period, today, weekStartDay); + + var query = new GetRecapQuery(userId, dateFrom, dateTo, period); + var result = await mediator.Send(query, cancellationToken); + + return result.ToPayGateAwareResult(v => Ok(v)); + } } diff --git a/src/Orbit.Api/Controllers/HabitsController.cs b/src/Orbit.Api/Controllers/HabitsController.cs index d67212dd..04b15222 100644 --- a/src/Orbit.Api/Controllers/HabitsController.cs +++ b/src/Orbit.Api/Controllers/HabitsController.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc; using Orbit.Api.Extensions; using Orbit.Api.RateLimiting; +using Orbit.Application.Common; using Orbit.Application.Habits.Commands; using Orbit.Application.Habits.Queries; using Orbit.Domain.Interfaces; @@ -107,6 +108,9 @@ public async Task GetRetrospective( [FromQuery] string language = "en", CancellationToken cancellationToken = default) { + if (!RetrospectivePeriodRange.IsKnownPeriod(period)) + return BadRequest(ErrorMessages.InvalidPeriod.ToErrorBody()); + var userId = HttpContext.GetUserId(); var today = await userDateService.GetUserTodayAsync(userId, cancellationToken); var weekStartDay = await userDateService.GetUserWeekStartDayAsync(userId, cancellationToken); @@ -566,7 +570,8 @@ private static BulkHabitItem MapToBulkHabitItem(BulkHabitItemRequest request) IsFlexible: request.IsFlexible, ScheduledReminders: request.ScheduledReminders, ChecklistItems: request.ChecklistItems, - GoogleEventId: request.GoogleEventId); + GoogleEventId: request.GoogleEventId, + Tags: request.Tags); } [LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "Habit created {HabitId} by user {UserId}")] diff --git a/src/Orbit.Api/Controllers/HabitsControllerRequests.cs b/src/Orbit.Api/Controllers/HabitsControllerRequests.cs index 1c22d636..95f620cc 100644 --- a/src/Orbit.Api/Controllers/HabitsControllerRequests.cs +++ b/src/Orbit.Api/Controllers/HabitsControllerRequests.cs @@ -79,7 +79,8 @@ public record BulkHabitItemRequest( bool IsFlexible = false, IReadOnlyList? ChecklistItems = null, string? GoogleEventId = null, - string? Emoji = null); + string? Emoji = null, + IReadOnlyList? Tags = null); public record BulkDeleteHabitsRequest(IReadOnlyList HabitIds); diff --git a/src/Orbit.Api/Controllers/ProfileController.cs b/src/Orbit.Api/Controllers/ProfileController.cs index fae0d57f..d8af9381 100644 --- a/src/Orbit.Api/Controllers/ProfileController.cs +++ b/src/Orbit.Api/Controllers/ProfileController.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Orbit.Api.Extensions; +using Orbit.Api.RateLimiting; using Orbit.Application.Profile.Commands; using Orbit.Application.Profile.Queries; @@ -24,6 +25,8 @@ public record SetLanguageRequest(string Language); public record SetWeekStartDayRequest([property: JsonRequired] int WeekStartDay); public record SetThemePreferenceRequest(string? ThemePreference); public record SetColorSchemeRequest(string? ColorScheme); + public record SetHandleRequest(string Handle); + public record SetSocialOptInRequest([property: JsonRequired] bool Enabled); private static readonly JsonSerializerOptions ExportJsonOptions = new(JsonSerializerDefaults.Web) { @@ -210,6 +213,42 @@ public async Task ResetTour(CancellationToken cancellationToken) return result.ToPayGateAwareResult(() => NoContent()); } + [HttpPut("handle")] + [DistributedRateLimit("set-handle")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task SetHandle( + [FromBody] SetHandleRequest request, + CancellationToken cancellationToken) + { + var command = new SetHandleCommand(HttpContext.GetUserId(), request.Handle); + var result = await mediator.Send(command, cancellationToken); + + if (result.IsSuccess) + LogHandleChanged(logger, HttpContext.GetUserId()); + + return result.ToPayGateAwareResult(() => NoContent()); + } + + [HttpPut("social-opt-in")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task SetSocialOptIn( + [FromBody] SetSocialOptInRequest request, + CancellationToken cancellationToken) + { + var command = new SetSocialOptInCommand(HttpContext.GetUserId(), request.Enabled); + var result = await mediator.Send(command, cancellationToken); + + if (result.IsSuccess) + LogSocialOptInChanged(logger, request.Enabled ? "enabled" : "disabled", HttpContext.GetUserId()); + + return result.ToPayGateAwareResult(() => NoContent()); + } + [HttpPost("reset")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] @@ -269,4 +308,10 @@ public async Task ExportUserData(CancellationToken cancellationTo [LoggerMessage(EventId = 9, Level = LogLevel.Information, Message = "Display name changed for user {UserId}")] private static partial void LogNameChanged(ILogger logger, Guid userId); + [LoggerMessage(EventId = 10, Level = LogLevel.Information, Message = "Handle changed for user {UserId}")] + private static partial void LogHandleChanged(ILogger logger, Guid userId); + + [LoggerMessage(EventId = 11, Level = LogLevel.Information, Message = "Social opt-in {State} for user {UserId}")] + private static partial void LogSocialOptInChanged(ILogger logger, string state, Guid userId); + } diff --git a/src/Orbit.Api/Extensions/ResultActionResultExtensions.cs b/src/Orbit.Api/Extensions/ResultActionResultExtensions.cs index 659b3305..5fa3dde7 100644 --- a/src/Orbit.Api/Extensions/ResultActionResultExtensions.cs +++ b/src/Orbit.Api/Extensions/ResultActionResultExtensions.cs @@ -33,6 +33,14 @@ public static class ResultActionResultExtensions [ErrorCodes.DuplicateFact] = StatusCodes.Status409Conflict, [ErrorCodes.AlreadyReferred] = StatusCodes.Status409Conflict, [ErrorCodes.ConcurrentUpdateConflict] = StatusCodes.Status409Conflict, + [ErrorCodes.HandleTaken] = StatusCodes.Status409Conflict, + [ErrorCodes.AlreadyFriends] = StatusCodes.Status409Conflict, + [ErrorCodes.FriendLimitReached] = StatusCodes.Status409Conflict, + + [ErrorCodes.SocialDisabled] = StatusCodes.Status403Forbidden, + [ErrorCodes.Blocked] = StatusCodes.Status403Forbidden, + + [ErrorCodes.FriendRequestNotFound] = StatusCodes.Status404NotFound, [ErrorCodes.InternalServerError] = StatusCodes.Status500InternalServerError, }; diff --git a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.AiServices.cs b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.AiServices.cs index cc5c6fad..952b7514 100644 --- a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.AiServices.cs +++ b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.AiServices.cs @@ -28,6 +28,8 @@ private static void AddAiPlatformServices(WebApplicationBuilder builder) builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddHttpClient() + .ConfigureHttpClient(client => client.Timeout = TimeSpan.FromSeconds(5)); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -173,6 +175,7 @@ private static void AddChatCommandDependencies(WebApplicationBuilder builder) sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), - sp.GetRequiredService())); + sp.GetRequiredService(), + sp.GetRequiredService())); } } diff --git a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs index d664402a..d6ce23d7 100644 --- a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs +++ b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs @@ -77,6 +77,16 @@ public static WebApplicationBuilder AddOrbitDatabase(this WebApplicationBuilder sp.GetRequiredService>(), sp.GetRequiredService>())); builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(sp => + new Orbit.Application.Social.Commands.SendCheerRepositories( + sp.GetRequiredService>(), + sp.GetRequiredService>(), + sp.GetRequiredService>(), + sp.GetRequiredService>())); builder.Services.AddScoped(); builder.Services.AddGoogleCalendarServices(); builder.Services.AddSingleton(TimeProvider.System); diff --git a/src/Orbit.Application/Auth/Commands/GoogleAuthCommand.cs b/src/Orbit.Application/Auth/Commands/GoogleAuthCommand.cs index a2f60aaf..50ff9786 100644 --- a/src/Orbit.Application/Auth/Commands/GoogleAuthCommand.cs +++ b/src/Orbit.Application/Auth/Commands/GoogleAuthCommand.cs @@ -113,6 +113,7 @@ private static string ExtractNameFromMetadata(JsonElement root) user = createResult.Value; user.SetLanguage(language); + user.SeedDefaultHandle(); await userRepository.AddAsync(user, cancellationToken); try diff --git a/src/Orbit.Application/Auth/Commands/VerifyCodeCommand.cs b/src/Orbit.Application/Auth/Commands/VerifyCodeCommand.cs index a7cd36ae..58eefbd5 100644 --- a/src/Orbit.Application/Auth/Commands/VerifyCodeCommand.cs +++ b/src/Orbit.Application/Auth/Commands/VerifyCodeCommand.cs @@ -107,6 +107,7 @@ private void RecordFailedAttempt(string email) user = createResult.Value; user.SetLanguage(language); + user.SeedDefaultHandle(); await userRepository.AddAsync(user, cancellationToken); try diff --git a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Persistence.cs b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Persistence.cs index f6a3e9d4..bffbc09b 100644 --- a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Persistence.cs +++ b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Persistence.cs @@ -3,6 +3,7 @@ using Orbit.Application.Common; using Orbit.Domain.Common; using Orbit.Domain.Entities; +using Orbit.Domain.Enums; using Orbit.Domain.Interfaces; namespace Orbit.Application.Chat.Commands; @@ -22,8 +23,26 @@ await ConcurrencyRetry.SaveWithRetryAsync( ct => execution.UserStreakService.RecalculateAsync(userId, ct), cancellationToken); } + + await ProcessOnboardingChecklistSafeAsync(userId, OnboardingChecklistSignal.AstraUsed, cancellationToken); } + private async Task ProcessOnboardingChecklistSafeAsync( + Guid userId, OnboardingChecklistSignal signal, CancellationToken cancellationToken) + { + try + { + await execution.GamificationService.ProcessOnboardingChecklistAsync(userId, signal, cancellationToken); + } + catch (Exception ex) + { + LogOnboardingChecklistFailed(logger, ex); + } + } + + [LoggerMessage(EventId = 27, Level = LogLevel.Warning, Message = "Onboarding checklist processing failed during chat turn")] + private static partial void LogOnboardingChecklistFailed(ILogger logger, Exception ex); + private static bool RequiresStreakRecalculation(IEnumerable actionResults) { return actionResults.Any(action => action.Status == ActionStatus.Success && action.Type is "LogHabit" or "BulkLogHabits" or "DeleteHabit"); diff --git a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs index 71d91135..5acc4ed7 100644 --- a/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs +++ b/src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs @@ -87,7 +87,8 @@ public record ChatExecutionDependencies( IServiceScopeFactory ServiceScopeFactory, IAgentOperationExecutor OperationExecutor, IPendingClarificationStore PendingClarificationStore, - IStreakGoalReadSyncer StreakGoalReadSyncer); + IStreakGoalReadSyncer StreakGoalReadSyncer, + IGamificationService GamificationService); public partial class ProcessUserChatCommandHandler( ChatDataDependencies data, diff --git a/src/Orbit.Application/Common/AppConstants.cs b/src/Orbit.Application/Common/AppConstants.cs index 46a6704d..d651acf7 100644 --- a/src/Orbit.Application/Common/AppConstants.cs +++ b/src/Orbit.Application/Common/AppConstants.cs @@ -58,5 +58,17 @@ public static class AppConstants public const int MaxStreakLookbackDays = 365; public const int MaxSyncWindowDays = 30; public const int SyncCleanupMarginDays = 1; + public const int MaxFriends = 500; + public const int HandleMinLength = DomainConstants.HandleMinLength; + public const int HandleMaxLength = DomainConstants.HandleMaxLength; + public const int MaxCheerNoteLength = DomainConstants.MaxCheerNoteLength; + public const int MaxReportDetailsLength = DomainConstants.MaxReportDetailsLength; + public const int MaxCheersPerDay = 20; + public const int MaxFriendRequestsPerDay = 30; + public const int MaxReportsPerDay = 20; + public const int MaxSetHandlePerDay = 5; + public const int FriendFeedPageSize = 30; + public const int MaxFriendFeedPageSize = 50; + public static readonly int[] StreakMilestoneTiers = [7, 14, 30, 90, 100, 365]; public static readonly string[] SupportedLanguages = ["en", "pt-BR"]; } diff --git a/src/Orbit.Application/Common/ErrorCodes.cs b/src/Orbit.Application/Common/ErrorCodes.cs index ef1f0adc..089e06cf 100644 --- a/src/Orbit.Application/Common/ErrorCodes.cs +++ b/src/Orbit.Application/Common/ErrorCodes.cs @@ -78,6 +78,7 @@ public static class ErrorCodes public const string AutoSyncEnabledRequired = "AUTO_SYNC_ENABLED_REQUIRED"; public const string CalendarIdsRequired = "CALENDAR_IDS_REQUIRED"; public const string NoHabitsForPeriod = "NO_HABITS_FOR_PERIOD"; + public const string InvalidPeriod = "INVALID_PERIOD"; public const string AiSummaryDisabled = "AI_SUMMARY_DISABLED"; public const string NoActiveGoals = "NO_ACTIVE_GOALS"; public const string NoGoalsData = "NO_GOALS_DATA"; @@ -112,4 +113,13 @@ public static class ErrorCodes public const string AudioTranscriptionFailed = "AUDIO_TRANSCRIPTION_FAILED"; public const string AudioTranscriptionEmpty = "AUDIO_TRANSCRIPTION_EMPTY"; public const string UpgradeRequired = "UPGRADE_REQUIRED"; + public const string SocialDisabled = "SOCIAL_DISABLED"; + public const string HandleTaken = "HANDLE_TAKEN"; + public const string FriendLimitReached = "FRIEND_LIMIT_REACHED"; + public const string AlreadyFriends = "ALREADY_FRIENDS"; + public const string Blocked = "BLOCKED"; + public const string NotFriends = "NOT_FRIENDS"; + public const string ContentRejected = "CONTENT_REJECTED"; + public const string FriendRequestNotFound = "FRIEND_REQUEST_NOT_FOUND"; + public const string CheerNotFound = "CHEER_NOT_FOUND"; } diff --git a/src/Orbit.Application/Common/ErrorMessages.cs b/src/Orbit.Application/Common/ErrorMessages.cs index e938397d..fc586b8f 100644 --- a/src/Orbit.Application/Common/ErrorMessages.cs +++ b/src/Orbit.Application/Common/ErrorMessages.cs @@ -77,6 +77,7 @@ public static class ErrorMessages public static readonly AppError AutoSyncEnabledRequired = new(ErrorCodes.AutoSyncEnabledRequired, "Enabled is required."); public static readonly AppError CalendarIdsRequired = new(ErrorCodes.CalendarIdsRequired, "Calendar ids are required."); public static readonly AppError NoHabitsForPeriod = new(ErrorCodes.NoHabitsForPeriod, "No habits found for this period."); + public static readonly AppError InvalidPeriod = new(ErrorCodes.InvalidPeriod, "Period must be one of: week, month, quarter, semester, year."); public static readonly AppError AiSummaryDisabled = new(ErrorCodes.AiSummaryDisabled, "AI summary is disabled."); public static readonly AppError NoActiveGoals = new(ErrorCodes.NoActiveGoals, "No active goals found."); public static readonly AppError NoGoalsData = new(ErrorCodes.NoGoalsData, "No goals data provided."); @@ -123,4 +124,13 @@ public static class ErrorMessages public static readonly AppError MutationFailed = new(ErrorCodes.MutationFailed, "Mutation failed"); public static readonly AppError BulkLogItemFailed = new(ErrorCodes.MutationFailed, "An error occurred processing this item"); public static readonly AppError UpgradeRequired = new(ErrorCodes.UpgradeRequired, "This app version is no longer supported. Please update to continue."); + public static readonly AppError SocialDisabled = new(ErrorCodes.SocialDisabled, "Turn on social features to use this."); + public static readonly AppError HandleTaken = new(ErrorCodes.HandleTaken, "That handle is already taken."); + public static readonly AppError FriendLimitReached = new(ErrorCodes.FriendLimitReached, "You've reached the maximum of {0} friends."); + public static readonly AppError AlreadyFriends = new(ErrorCodes.AlreadyFriends, "You're already connected with this person."); + public static readonly AppError Blocked = new(ErrorCodes.Blocked, "This action isn't available for this user."); + public static readonly AppError NotFriends = new(ErrorCodes.NotFriends, "You can only do this with an accepted friend."); + public static readonly AppError ContentRejected = new(ErrorCodes.ContentRejected, "This note can't be sent. Please revise it and try again."); + public static readonly AppError FriendRequestNotFound = new(ErrorCodes.FriendRequestNotFound, "Friend request not found."); + public static readonly AppError CheerNotFound = new(ErrorCodes.CheerNotFound, "Cheer not found."); } diff --git a/src/Orbit.Application/Common/FeatureFlagKeys.cs b/src/Orbit.Application/Common/FeatureFlagKeys.cs new file mode 100644 index 00000000..aa594bfc --- /dev/null +++ b/src/Orbit.Application/Common/FeatureFlagKeys.cs @@ -0,0 +1,6 @@ +namespace Orbit.Application.Common; + +public static class FeatureFlagKeys +{ + public const string GamificationFreeTier = "gamification_free_tier"; +} diff --git a/src/Orbit.Application/Gamification/AchievementChecks.cs b/src/Orbit.Application/Gamification/AchievementChecks.cs index bc4663a3..5f25841d 100644 --- a/src/Orbit.Application/Gamification/AchievementChecks.cs +++ b/src/Orbit.Application/Gamification/AchievementChecks.cs @@ -31,6 +31,15 @@ public static void TryGrant( newAchievements.Add((entity, definition)); } + public static void CheckOnboardingChecklist( + User user, + HashSet earned, + List<(UserAchievement Entity, AchievementDefinition Definition)> newAchievements) + { + if (user.HasCreatedFirstHabit && user.HasLoggedFirstHabit && user.HasTriedAstra) + TryGrant(AchievementDefinitions.OnboardingComplete, user, earned, newAchievements); + } + public static void CheckConsistencyAchievements( int currentStreak, HashSet earned, @@ -47,8 +56,12 @@ public static void CheckConsistencyAchievements( TryGrant(AchievementDefinitions.QuarterChampion, user, earned, newAchievements); if (currentStreak >= 100) TryGrant(AchievementDefinitions.Centurion, user, earned, newAchievements); + if (currentStreak >= 180) + TryGrant(AchievementDefinitions.HalfYearHero, user, earned, newAchievements); if (currentStreak >= 365) TryGrant(AchievementDefinitions.YearOfDiscipline, user, earned, newAchievements); + if (currentStreak >= 500) + TryGrant(AchievementDefinitions.StreakTitan, user, earned, newAchievements); } public static void CheckVolumeAchievements( diff --git a/src/Orbit.Application/Gamification/AchievementDefinitions.cs b/src/Orbit.Application/Gamification/AchievementDefinitions.cs index a514611e..b1bf8ed9 100644 --- a/src/Orbit.Application/Gamification/AchievementDefinitions.cs +++ b/src/Orbit.Application/Gamification/AchievementDefinitions.cs @@ -8,12 +8,15 @@ public static class AchievementDefinitions public const string FirstOrbit = "first_orbit"; public const string Liftoff = "liftoff"; public const string MissionControl = "mission_control"; + public const string OnboardingComplete = "onboarding_complete"; public const string WeekWarrior = "week_warrior"; public const string FortnightFocus = "fortnight_focus"; public const string MonthlyMaster = "monthly_master"; public const string QuarterChampion = "quarter_champion"; public const string Centurion = "centurion"; public const string YearOfDiscipline = "year_of_discipline"; + public const string HalfYearHero = "half_year_hero"; + public const string StreakTitan = "streak_titan"; public const string GettingMomentum = "getting_momentum"; public const string BuildingHabits = "building_habits"; public const string Dedicated = "dedicated"; @@ -30,18 +33,22 @@ public static class AchievementDefinitions public const string NightOwl = "night_owl"; public const string Comeback = "comeback"; public const string BadHabitBreaker = "bad_habit_breaker"; + public const string FirstCheer = "first_cheer"; private static readonly List _all = [ new(FirstOrbit, "First Orbit", "Create your first habit", AchievementCategory.GettingStarted, AchievementRarity.Common, 25, "first_orbit"), new(Liftoff, "Liftoff", "Complete your first habit", AchievementCategory.GettingStarted, AchievementRarity.Common, 25, "liftoff"), new(MissionControl, "Mission Control", "Create your first goal", AchievementCategory.GettingStarted, AchievementRarity.Common, 25, "mission_control"), + new(OnboardingComplete, "All Systems Go", "Complete your setup checklist", AchievementCategory.GettingStarted, AchievementRarity.Common, 50, "onboarding_complete"), new(WeekWarrior, "Week Warrior", "Achieve a 7-day streak on any habit", AchievementCategory.Consistency, AchievementRarity.Uncommon, 50, "week_warrior"), new(FortnightFocus, "Fortnight Focus", "Achieve a 14-day streak", AchievementCategory.Consistency, AchievementRarity.Uncommon, 75, "fortnight_focus"), new(MonthlyMaster, "Monthly Master", "Achieve a 30-day streak", AchievementCategory.Consistency, AchievementRarity.Rare, 100, "monthly_master"), new(QuarterChampion, "Quarter Champion", "Achieve a 90-day streak", AchievementCategory.Consistency, AchievementRarity.Epic, 250, "quarter_champion"), new(Centurion, "Centurion", "Achieve a 100-day streak", AchievementCategory.Consistency, AchievementRarity.Epic, 250, "centurion"), new(YearOfDiscipline, "Year of Discipline", "Achieve a 365-day streak", AchievementCategory.Consistency, AchievementRarity.Legendary, 500, "year_of_discipline"), + new(HalfYearHero, "Half-Year Hero", "Achieve a 180-day streak", AchievementCategory.Consistency, AchievementRarity.Epic, 350, "half_year_hero"), + new(StreakTitan, "Streak Titan", "Achieve a 500-day streak", AchievementCategory.Consistency, AchievementRarity.Legendary, 750, "streak_titan"), new(GettingMomentum, "Getting Momentum", "Complete 10 habits total", AchievementCategory.Volume, AchievementRarity.Common, 25, "getting_momentum"), new(BuildingHabits, "Building Habits", "Complete 50 habits total", AchievementCategory.Volume, AchievementRarity.Uncommon, 50, "building_habits"), new(Dedicated, "Dedicated", "Complete 100 habits total", AchievementCategory.Volume, AchievementRarity.Rare, 100, "dedicated"), @@ -58,6 +65,7 @@ public static class AchievementDefinitions new(NightOwl, "Night Owl", "Complete a habit after 10 PM (10 times)", AchievementCategory.Special, AchievementRarity.Rare, 100, "night_owl"), new(Comeback, "Comeback", "Resume after 7+ days of inactivity", AchievementCategory.Special, AchievementRarity.Uncommon, 50, "comeback"), new(BadHabitBreaker, "Bad Habit Breaker", "Achieve a 30-day streak on a bad habit", AchievementCategory.Special, AchievementRarity.Rare, 150, "bad_habit_breaker"), + new(FirstCheer, "Good Vibes", "Send or receive your first cheer", AchievementCategory.Special, AchievementRarity.Common, 50, "first_cheer"), ]; public static IReadOnlyList All => _all; diff --git a/src/Orbit.Application/Gamification/LevelDefinitions.cs b/src/Orbit.Application/Gamification/LevelDefinitions.cs index 258c5483..61ea8fbf 100644 --- a/src/Orbit.Application/Gamification/LevelDefinitions.cs +++ b/src/Orbit.Application/Gamification/LevelDefinitions.cs @@ -2,8 +2,18 @@ namespace Orbit.Application.Gamification; +/// +/// The level ladder. Levels 1–10 use the hand-tuned anchor table; levels past 10 follow the +/// steady-climb quadratic XpRequired(L) = 100·L², which is value-continuous with the table +/// at level 10 (both equal 10,000) and grows forever, so there is no level cap. Titles past 10 +/// reuse level 10's "Legend"; the numeric level differentiates. +/// public static class LevelDefinitions { + public const int TableMaxLevel = 10; + private const int QuadraticXpCoefficient = 100; + private const string LegendTitle = "Legend"; + private static readonly List _all = [ new(1, "Starter", 0), @@ -20,20 +30,55 @@ public static class LevelDefinitions public static IReadOnlyList All => _all; + /// + /// Total XP required to reach . Levels at or below the anchor table use + /// its thresholds; past it, 100·level² (which equals the table at level 10, so the curve + /// is continuous). Levels below 1 require 0 XP. + /// + public static int XpRequiredForLevel(int level) + { + if (level <= 1) return 0; + if (level <= TableMaxLevel) return _all[level - 1].XpRequired; + return QuadraticXpCoefficient * level * level; + } + + /// + /// The display title for : the anchor table's title up to level 10, + /// then "Legend" for every level beyond. + /// + public static string TitleForLevel(int level) + { + if (level < 1) return _all[0].Title; + if (level <= TableMaxLevel) return _all[level - 1].Title; + return LegendTitle; + } + public static LevelDefinition GetLevelForXp(int totalXp) { - for (var i = _all.Count - 1; i >= 0; i--) + if (totalXp < _all[TableMaxLevel - 1].XpRequired) { - if (totalXp >= _all[i].XpRequired) - return _all[i]; + for (var i = _all.Count - 1; i >= 0; i--) + { + if (totalXp >= _all[i].XpRequired) + return _all[i]; + } + return _all[0]; } - return _all[0]; + + var level = (int)Math.Floor(Math.Sqrt(totalXp / (double)QuadraticXpCoefficient)); + if (level < TableMaxLevel) level = TableMaxLevel; + while (XpRequiredForLevel(level + 1) <= totalXp) level++; + while (XpRequiredForLevel(level) > totalXp) level--; + return new LevelDefinition(level, TitleForLevel(level), XpRequiredForLevel(level)); } - public static int? GetXpToNextLevel(int totalXp) + /// + /// XP remaining until the next level. With infinite levels this is never null — the curve always + /// has a next threshold. + /// + public static int GetXpToNextLevel(int totalXp) { var current = GetLevelForXp(totalXp); - if (current.Level >= 10) return null; - var next = _all[current.Level]; return next.XpRequired - totalXp; + return XpRequiredForLevel(current.Level + 1) - totalXp; } } diff --git a/src/Orbit.Application/Gamification/Queries/GetGamificationProfileQuery.cs b/src/Orbit.Application/Gamification/Queries/GetGamificationProfileQuery.cs index 89e44603..0ab5ec5c 100644 --- a/src/Orbit.Application/Gamification/Queries/GetGamificationProfileQuery.cs +++ b/src/Orbit.Application/Gamification/Queries/GetGamificationProfileQuery.cs @@ -19,15 +19,27 @@ public record GamificationProfileResponse( IReadOnlyList UserAchievements, int CurrentStreak, int LongestStreak, - DateOnly? LastActiveDate); + DateOnly? LastActiveDate, + bool IsPro, + bool AchievementsLocked, + NextRewardCarrot NextReward); public record UserAchievementDto(string AchievementId, DateTime EarnedAtUtc); +public record GamificationProTeaser(string Kind, bool Locked); + +public record NextRewardCarrot( + int NextLevel, + string NextLevelTitle, + int XpToNextLevel, + GamificationProTeaser? ProTeaser); + public record GetGamificationProfileQuery(Guid UserId) : IRequest>; public class GetGamificationProfileQueryHandler( IGenericRepository userRepository, - IGenericRepository achievementRepository) : IRequestHandler> + IGenericRepository achievementRepository, + IFeatureFlagService featureFlagService) : IRequestHandler> { public async Task> Handle(GetGamificationProfileQuery request, CancellationToken cancellationToken) { @@ -35,42 +47,68 @@ public async Task> Handle(GetGamificationPro if (user is null) return Result.Failure(ErrorMessages.UserNotFound); - if (!user.HasProAccess) + var enabledFlags = await featureFlagService.GetEnabledKeysForUserAsync(request.UserId, cancellationToken); + var unlocked = user.HasProAccess || enabledFlags.Contains(FeatureFlagKeys.GamificationFreeTier); + if (!unlocked) return Result.PayGateFailure("Gamification is a Pro feature. Upgrade to unlock!"); var currentLevel = LevelDefinitions.GetLevelForXp(user.TotalXp); var xpToNext = LevelDefinitions.GetXpToNextLevel(user.TotalXp); - var nextLevel = currentLevel.Level < 10 - ? LevelDefinitions.All[currentLevel.Level] - : currentLevel; - var earned = await achievementRepository.FindAsync(a => a.UserId == request.UserId, cancellationToken); - var earnedMap = earned.ToDictionary(a => a.AchievementId, a => a.EarnedAtUtc); + var nextLevelNumber = currentLevel.Level + 1; + var nextLevelXpRequired = LevelDefinitions.XpRequiredForLevel(nextLevelNumber); - var achievements = AchievementDefinitions.All.Select(def => - { - var isEarned = earnedMap.TryGetValue(def.Id, out var earnedAt); - return new AchievementDto( - def.Id, def.Name, def.Description, - def.Category.ToString(), def.Rarity.ToString(), - def.XpReward, def.IconKey, isEarned, isEarned ? earnedAt : null); - }).ToList(); + var achievementsLocked = !user.HasProAccess; + var (achievements, userAchievements, achievementsEarned) = + achievementsLocked + ? (new List(), new List(), 0) + : await BuildAchievementsAsync(request.UserId, cancellationToken); - var userAchievements = earned.Select(e => - new UserAchievementDto(e.AchievementId, e.EarnedAtUtc)).ToList(); + var proTeaser = user.HasProAccess + ? null + : new GamificationProTeaser("achievements", true); + var nextReward = new NextRewardCarrot( + nextLevelNumber, + LevelDefinitions.TitleForLevel(nextLevelNumber), + xpToNext, + proTeaser); return Result.Success(new GamificationProfileResponse( user.TotalXp, currentLevel.Level, currentLevel.Title, currentLevel.XpRequired, - nextLevel.XpRequired, + nextLevelXpRequired, xpToNext, - earned.Count, + achievementsEarned, AchievementDefinitions.All.Count, achievements, userAchievements, user.CurrentStreak, user.LongestStreak, - user.LastActiveDate)); + user.LastActiveDate, + user.HasProAccess, + achievementsLocked, + nextReward)); + } + + private async Task<(List Achievements, List UserAchievements, int EarnedCount)> BuildAchievementsAsync( + Guid userId, CancellationToken cancellationToken) + { + var earned = await achievementRepository.FindAsync(a => a.UserId == userId, cancellationToken); + var earnedMap = earned.ToDictionary(a => a.AchievementId, a => a.EarnedAtUtc); + + var achievements = AchievementDefinitions.All.Select(def => + { + var isEarned = earnedMap.TryGetValue(def.Id, out var earnedAt); + return new AchievementDto( + def.Id, def.Name, def.Description, + def.Category.ToString(), def.Rarity.ToString(), + def.XpReward, def.IconKey, isEarned, isEarned ? earnedAt : null); + }).ToList(); + + var userAchievements = earned.Select(e => + new UserAchievementDto(e.AchievementId, e.EarnedAtUtc)).ToList(); + + return (achievements, userAchievements, earned.Count); } } diff --git a/src/Orbit.Application/Gamification/Queries/GetRecapQuery.cs b/src/Orbit.Application/Gamification/Queries/GetRecapQuery.cs new file mode 100644 index 00000000..a7bfcb5e --- /dev/null +++ b/src/Orbit.Application/Gamification/Queries/GetRecapQuery.cs @@ -0,0 +1,62 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Orbit.Application.Common; +using Orbit.Application.Habits.Queries; +using Orbit.Application.Habits.Services; +using Orbit.Application.Referrals.Commands; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Gamification.Queries; + +public record RecapResponse( + string Period, + RetrospectiveMetrics Metrics, + string ShareDeepLink); + +public record GetRecapQuery( + Guid UserId, + DateOnly DateFrom, + DateOnly DateTo, + string Period) : IRequest>; + +/// +/// Builds a shareable, metrics-only recap for the given period by reusing +/// (no AI narrative). Free / ungated. Ensures the +/// user has a referral code (generating one if missing) so the returned ShareDeepLink can +/// carry it for attribution. +/// +public class GetRecapQueryHandler( + IGenericRepository habitRepository, + IUserStreakService userStreakService, + IOptions frontendSettings, + IMediator mediator) : IRequestHandler> +{ + public async Task> Handle(GetRecapQuery request, CancellationToken cancellationToken) + { + var codeResult = await mediator.Send(new GetOrCreateReferralCodeCommand(request.UserId), cancellationToken); + if (!codeResult.IsSuccess) + return codeResult.PropagateError(); + + var habits = await habitRepository.FindAsync( + h => h.UserId == request.UserId, + q => q.Include(h => h.Logs.Where(l => l.Date >= request.DateFrom && l.Date <= request.DateTo)), + cancellationToken); + + var streakState = await userStreakService.RecalculateAsync( + request.UserId, cancellationToken, awardFreezeIfEligible: false); + + var metrics = RetrospectiveMetricsCalculator.Compute( + habits.ToList(), + request.DateFrom, + request.DateTo, + streakState?.CurrentStreak ?? 0, + streakState?.LongestStreak ?? 0); + + var shareDeepLink = $"{frontendSettings.Value.BaseUrl}/r/{codeResult.Value}?recap={request.Period}"; + + return Result.Success(new RecapResponse(request.Period, metrics, shareDeepLink)); + } +} diff --git a/src/Orbit.Application/Gamification/Queries/GetRecapQueryValidator.cs b/src/Orbit.Application/Gamification/Queries/GetRecapQueryValidator.cs new file mode 100644 index 00000000..9c3bf6d3 --- /dev/null +++ b/src/Orbit.Application/Gamification/Queries/GetRecapQueryValidator.cs @@ -0,0 +1,19 @@ +using FluentValidation; +using Orbit.Application.Habits.Queries; + +namespace Orbit.Application.Gamification.Queries; + +public class GetRecapQueryValidator : AbstractValidator +{ + public GetRecapQueryValidator() + { + RuleFor(x => x.Period) + .NotEmpty() + .Must(period => RetrospectivePeriodRange.IsKnownPeriod(period)) + .WithMessage("Period must be one of: week, month, quarter, semester, year."); + + RuleFor(x => x.DateFrom) + .LessThanOrEqualTo(x => x.DateTo) + .WithMessage("DateFrom must be on or before DateTo."); + } +} diff --git a/src/Orbit.Application/Gamification/Queries/GetStreakInfoQuery.cs b/src/Orbit.Application/Gamification/Queries/GetStreakInfoQuery.cs index cdd6a8c3..c43643e5 100644 --- a/src/Orbit.Application/Gamification/Queries/GetStreakInfoQuery.cs +++ b/src/Orbit.Application/Gamification/Queries/GetStreakInfoQuery.cs @@ -29,6 +29,7 @@ public class GetStreakInfoQueryHandler( IGenericRepository streakFreezeRepository, IUserDateService userDateService, IUserStreakService userStreakService, + IFeatureFlagService featureFlagService, IUnitOfWork unitOfWork) : IRequestHandler> { public async Task> Handle(GetStreakInfoQuery request, CancellationToken cancellationToken) @@ -37,7 +38,9 @@ public async Task> Handle(GetStreakInfoQuery request, if (user is null) return Result.Failure(ErrorMessages.UserNotFound); - if (!user.HasProAccess) + var enabledFlags = await featureFlagService.GetEnabledKeysForUserAsync(request.UserId, cancellationToken); + var unlocked = user.HasProAccess || enabledFlags.Contains(FeatureFlagKeys.GamificationFreeTier); + if (!unlocked) return Result.PayGateFailure("Streak insights are a Pro feature. Upgrade to unlock!"); var recalculatedStreak = await userStreakService.RecalculateAsync( diff --git a/src/Orbit.Application/Gamification/Services/GamificationService.cs b/src/Orbit.Application/Gamification/Services/GamificationService.cs index f72c9037..8366552c 100644 --- a/src/Orbit.Application/Gamification/Services/GamificationService.cs +++ b/src/Orbit.Application/Gamification/Services/GamificationService.cs @@ -3,7 +3,9 @@ using Orbit.Application.Common; using Orbit.Application.Gamification.Models; using Orbit.Application.Habits.Services; +using Orbit.Application.Social.Services; using Orbit.Domain.Entities; +using Orbit.Domain.Enums; using Orbit.Domain.Interfaces; namespace Orbit.Application.Gamification.Services; @@ -23,6 +25,7 @@ public partial class GamificationService( GamificationRepositories repos, IPushNotificationService pushService, IUserDateService userDateService, + IFriendFeedEventEmitter friendFeedEventEmitter, IUnitOfWork unitOfWork, ILogger logger) : IGamificationService { @@ -164,8 +167,11 @@ private async Task ProcessLoggedHabit( AchievementChecks.TryGrant(AchievementDefinitions.BadHabitBreaker, user, earned, newAchievements); } - foreach (var (entity, _) in newAchievements) + foreach (var (entity, definition) in newAchievements) + { await repos.AchievementRepository.AddAsync(entity, ct); + await EmitAchievementFeedEventAsync(user, entity, definition, ct); + } UpdateLevel(user); @@ -267,6 +273,99 @@ await ProcessGamificationEventAsync(userId, async (user, earned, newAchievements }, ct); } + /// + /// Advances the onboarding setup-checklist flags from a single signal and, once all three + /// (habit created, habit logged, Astra used) are set, marks the checklist complete. The signal + /// and completion flags apply to every user un-gated so the client card hides consistently; + /// the achievement is granted only to + /// users with Pro access (#186). Short-circuits once the checklist is already complete. + /// + public async Task ProcessOnboardingChecklistAsync( + Guid userId, OnboardingChecklistSignal signal, CancellationToken ct = default) + { + for (var attempt = 1; ; attempt++) + { + if (attempt > 1) + unitOfWork.ResetTracking(); + + var pushes = new List(); + var shouldSave = await ComputeOnboardingChecklistAsync(userId, signal, pushes, ct); + if (!shouldSave) + return; + + try + { + await unitOfWork.SaveChangesAsync(ct); + } + catch (DbUpdateConcurrencyException) when (attempt < MaxConcurrencyAttempts) + { + continue; + } + + await FlushPushesAsync(pushes, ct); + return; + } + } + + private async Task ComputeOnboardingChecklistAsync( + Guid userId, OnboardingChecklistSignal signal, List pushes, CancellationToken ct) + { + var user = await repos.UserRepository.FindOneTrackedAsync(u => u.Id == userId, cancellationToken: ct); + if (user is null || user.HasCompletedOnboardingChecklist) + return false; + + ApplyOnboardingSignal(user, signal); + + if (!(user.HasCreatedFirstHabit && user.HasLoggedFirstHabit && user.HasTriedAstra)) + return true; + + user.CompleteOnboardingChecklist(); + + if (!user.HasProAccess) + return true; + + var earned = await LoadEarnedAchievementIds(userId, ct); + var newAchievements = new List<(UserAchievement Entity, AchievementDefinition Definition)>(); + var previousLevel = user.Level; + + AchievementChecks.CheckOnboardingChecklist(user, earned, newAchievements); + + foreach (var (entity, definition) in newAchievements) + { + await repos.AchievementRepository.AddAsync(entity, ct); + await EmitAchievementFeedEventAsync(user, entity, definition, ct); + } + + UpdateLevel(user); + + foreach (var (_, definition) in newAchievements) + await QueueAchievementNotification(userId, definition, user.Language, pushes, ct); + + if (user.Level > previousLevel) + { + var newLevel = LevelDefinitions.GetLevelForXp(user.TotalXp); + await QueueLevelUpNotification(userId, newLevel, user.Language, pushes, ct); + } + + return true; + } + + private static void ApplyOnboardingSignal(User user, OnboardingChecklistSignal signal) + { + switch (signal) + { + case OnboardingChecklistSignal.HabitCreated: + user.MarkFirstHabitCreated(); + break; + case OnboardingChecklistSignal.HabitLogged: + user.MarkFirstHabitLogged(); + break; + case OnboardingChecklistSignal.AstraUsed: + user.MarkAstraUsed(); + break; + } + } + /// /// Template method that handles the common gamification scaffold: /// load user, check Pro, load earned achievements, run domain-specific checks, @@ -316,8 +415,11 @@ private async Task ComputeGamificationEventAsync( await checkAchievements(user, earned, newAchievements); - foreach (var (entity, _) in newAchievements) + foreach (var (entity, definition) in newAchievements) + { await repos.AchievementRepository.AddAsync(entity, ct); + await EmitAchievementFeedEventAsync(user, entity, definition, ct); + } UpdateLevel(user); @@ -346,17 +448,34 @@ private static void UpdateLevel(User user) user.SetLevel(newLevel.Level); } + /// + /// Streams a non-streak achievement into friends' feeds. Consistency (streak-tier) achievements are + /// skipped because the streak hook already emits a StreakMilestone for the same moment, so emitting + /// here too would double the feed row. + /// + private async Task EmitAchievementFeedEventAsync( + User user, UserAchievement entity, AchievementDefinition definition, CancellationToken ct) + { + if (definition.Category == AchievementCategory.Consistency) + return; + + await friendFeedEventEmitter.EmitAchievementEventAsync(user, entity.AchievementId, definition.Category, ct); + } + private static readonly Dictionary AchievementTranslationsPt = new() { ["first_orbit"] = ("Primeira Órbita", "Crie seu primeiro hábito"), ["liftoff"] = ("Decolagem", "Complete seu primeiro hábito"), ["mission_control"] = ("Controle de Missão", "Crie sua primeira meta"), + ["onboarding_complete"] = ("Tudo Pronto", "Conclua sua lista de configuração"), ["week_warrior"] = ("Guerreiro da Semana", "Alcance uma sequência de 7 dias"), ["fortnight_focus"] = ("Foco Quinzenal", "Alcance uma sequência de 14 dias"), ["monthly_master"] = ("Mestre Mensal", "Alcance uma sequência de 30 dias"), ["quarter_champion"] = ("Campeão Trimestral", "Alcance uma sequência de 90 dias"), ["centurion"] = ("Centurião", "Alcance uma sequência de 100 dias"), ["year_of_discipline"] = ("Ano de Disciplina", "Alcance uma sequência de 365 dias"), + ["half_year_hero"] = ("Herói do Semestre", "Alcance uma sequência de 180 dias"), + ["streak_titan"] = ("Titã da Sequência", "Alcance uma sequência de 500 dias"), ["getting_momentum"] = ("Ganhando Ritmo", "Complete 10 hábitos no total"), ["building_habits"] = ("Construindo Hábitos", "Complete 50 hábitos no total"), ["dedicated"] = ("Dedicado", "Complete 100 hábitos no total"), @@ -373,6 +492,7 @@ private static void UpdateLevel(User user) ["night_owl"] = ("Coruja Noturna", "Complete um hábito após as 22h (10 vezes)"), ["comeback"] = ("Retorno", "Retome após 7+ dias de inatividade"), ["bad_habit_breaker"] = ("Quebrador de Maus Hábitos", "Resista a um mau hábito por 30 dias consecutivos"), + ["first_cheer"] = ("Boas Energias", "Envie ou receba seu primeiro incentivo"), }; private static readonly Dictionary LevelTranslationsPt = new() @@ -424,7 +544,7 @@ private async Task QueueLevelUpNotification( var title = isPt ? $"Subiu de nível! Agora você está no nível {newLevel.Level}" : $"Level Up! You're now Level {newLevel.Level}"; - var levelTitle = isPt && LevelTranslationsPt.TryGetValue(newLevel.Level, out var ptTitle) + var levelTitle = isPt && LevelTranslationsPt.TryGetValue(Math.Min(newLevel.Level, LevelDefinitions.TableMaxLevel), out var ptTitle) ? ptTitle : newLevel.Title; var body = isPt ? $"Você alcançou {levelTitle}! Continue assim!" diff --git a/src/Orbit.Application/Habits/Commands/BulkCreateHabitsCommand.cs b/src/Orbit.Application/Habits/Commands/BulkCreateHabitsCommand.cs index 7638b3f0..bf85028f 100644 --- a/src/Orbit.Application/Habits/Commands/BulkCreateHabitsCommand.cs +++ b/src/Orbit.Application/Habits/Commands/BulkCreateHabitsCommand.cs @@ -34,7 +34,8 @@ public record BulkHabitItem( IReadOnlyList? ScheduledReminders = null, IReadOnlyList? ChecklistItems = null, string? GoogleEventId = null, - string? Emoji = null); + string? Emoji = null, + IReadOnlyList? Tags = null); public record BulkCreateResult(IReadOnlyList Results); @@ -51,12 +52,15 @@ public enum BulkItemStatus { Success, Failed } public partial class BulkCreateHabitsCommandHandler( IGenericRepository habitRepository, IGenericRepository suggestionRepository, + IGenericRepository tagRepository, IPayGateService payGate, IUserDateService userDateService, IUnitOfWork unitOfWork, IMemoryCache cache, ILogger logger) : IRequestHandler> { + private const string DefaultTagColor = "#7c3aed"; + public async Task> Handle(BulkCreateHabitsCommand request, CancellationToken cancellationToken) { var parentCount = request.Habits.Count; @@ -82,12 +86,14 @@ public async Task> Handle(BulkCreateHabitsCommand reque ? 0 : existingRoots.Max(h => h.Position ?? -1) + 1; + var tagsByName = await LoadTagCacheAsync(request, cancellationToken); + await unitOfWork.ExecuteInTransactionAsync(async ct => { for (int i = 0; i < request.Habits.Count; i++) { var itemResult = await CreateSingleHabit( - request.UserId, request.Habits[i], i, userToday, rootPositionCursor + i, ct); + request.UserId, request.Habits[i], i, userToday, rootPositionCursor + i, tagsByName, ct); results.Add(itemResult); } @@ -107,7 +113,7 @@ await unitOfWork.ExecuteInTransactionAsync(async ct => private async Task CreateSingleHabit( Guid userId, BulkHabitItem item, int index, DateOnly userToday, int rootPosition, - CancellationToken cancellationToken) + Dictionary tagsByName, CancellationToken cancellationToken) { try { @@ -145,6 +151,8 @@ private async Task CreateSingleHabit( var parentHabit = habitResult.Value; await habitRepository.AddAsync(parentHabit, cancellationToken); + await AttachTagsAsync(parentHabit, userId, item.Tags, tagsByName, cancellationToken); + if (item.SubHabits is { Count: > 0 }) { var subPositionCursor = 0; @@ -197,6 +205,48 @@ private async Task CreateSingleHabit( } } + private async Task> LoadTagCacheAsync( + BulkCreateHabitsCommand request, CancellationToken cancellationToken) + { + var tagsByName = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (!request.Habits.Any(h => h.Tags is { Count: > 0 })) + return tagsByName; + + var existingTags = await tagRepository.FindTrackedAsync(t => t.UserId == request.UserId, cancellationToken); + foreach (var tag in existingTags) + tagsByName[tag.Name] = tag; + + return tagsByName; + } + + private async Task AttachTagsAsync( + Habit habit, Guid userId, IReadOnlyList? tagNames, + Dictionary tagsByName, CancellationToken cancellationToken) + { + if (tagNames is not { Count: > 0 }) + return; + + foreach (var rawName in tagNames) + { + var trimmed = rawName.Trim(); + if (trimmed.Length == 0) + continue; + + if (!tagsByName.TryGetValue(trimmed, out var tag)) + { + var created = Tag.Create(userId, trimmed, DefaultTagColor); + if (created.IsFailure) + continue; + + tag = created.Value; + await tagRepository.AddAsync(tag, cancellationToken); + tagsByName[tag.Name] = tag; + } + + habit.AddTag(tag); + } + } + private static string? DetermineFieldFromError(string error) { if (error.Contains("title", StringComparison.OrdinalIgnoreCase)) diff --git a/src/Orbit.Application/Habits/Commands/CreateHabitCommand.cs b/src/Orbit.Application/Habits/Commands/CreateHabitCommand.cs index 152c8853..a61af261 100644 --- a/src/Orbit.Application/Habits/Commands/CreateHabitCommand.cs +++ b/src/Orbit.Application/Habits/Commands/CreateHabitCommand.cs @@ -123,6 +123,7 @@ public async Task> Handle(CreateHabitCommand request, CancellationT await unitOfWork.SaveChangesAsync(cancellationToken); await ProcessGamificationSafeAsync(request.UserId, cancellationToken); + await ProcessOnboardingChecklistSafeAsync(request.UserId, OnboardingChecklistSignal.HabitCreated, cancellationToken); CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId); @@ -206,6 +207,22 @@ private async Task ProcessGamificationSafeAsync(Guid userId, CancellationToken c } } + private async Task ProcessOnboardingChecklistSafeAsync( + Guid userId, OnboardingChecklistSignal signal, CancellationToken cancellationToken) + { + try + { + await gamificationService.ProcessOnboardingChecklistAsync(userId, signal, cancellationToken); + } + catch (Exception ex) + { + LogOnboardingChecklistFailed(logger, ex, userId); + } + } + [LoggerMessage(EventId = 1, Level = LogLevel.Warning, Message = "Gamification processing failed for habit creation by user {UserId}")] private static partial void LogGamificationHabitCreationFailed(ILogger logger, Exception ex, Guid userId); + + [LoggerMessage(EventId = 2, Level = LogLevel.Warning, Message = "Onboarding checklist processing failed for user {UserId}")] + private static partial void LogOnboardingChecklistFailed(ILogger logger, Exception ex, Guid userId); } diff --git a/src/Orbit.Application/Habits/Commands/LogHabitCommand.cs b/src/Orbit.Application/Habits/Commands/LogHabitCommand.cs index 61754797..e6fe0bbd 100644 --- a/src/Orbit.Application/Habits/Commands/LogHabitCommand.cs +++ b/src/Orbit.Application/Habits/Commands/LogHabitCommand.cs @@ -191,6 +191,7 @@ private async Task> HandleLogAsync( var streakState = await services.UserStreakService.RecalculateAsync(request.UserId, cancellationToken); var gamificationResult = await ProcessGamificationSafeAsync(request.UserId, request.HabitId, cancellationToken); + await ProcessOnboardingChecklistSafeAsync(request.UserId, OnboardingChecklistSignal.HabitLogged, cancellationToken); if (goalSync.AnyJustCompleted) await ProcessGoalCompletionSafeAsync(request.UserId, cancellationToken); @@ -282,6 +283,19 @@ private static bool IsUniqueViolation(Exception exception) } } + private async Task ProcessOnboardingChecklistSafeAsync( + Guid userId, OnboardingChecklistSignal signal, CancellationToken cancellationToken) + { + try + { + await services.GamificationService.ProcessOnboardingChecklistAsync(userId, signal, cancellationToken); + } + catch (Exception ex) + { + LogOnboardingChecklistFailed(logger, ex, userId); + } + } + private async Task CheckReferralCompletionSafeAsync(Guid userId, CancellationToken cancellationToken) { try @@ -351,6 +365,9 @@ private async Task ProcessGoalCompletionSafeAsync(Guid userId, CancellationToken [LoggerMessage(EventId = 3, Level = LogLevel.Warning, Message = "Gamification processing failed for linked goal completion by user {UserId}")] private static partial void LogGamificationGoalCompletionFailed(ILogger logger, Exception ex, Guid userId); + + [LoggerMessage(EventId = 4, Level = LogLevel.Warning, Message = "Onboarding checklist processing failed for user {UserId}")] + private static partial void LogOnboardingChecklistFailed(ILogger logger, Exception ex, Guid userId); } internal record LinkedGoalSyncResult(IReadOnlyList? Updates, bool AnyJustCompleted) diff --git a/src/Orbit.Application/Habits/Queries/RetrospectivePeriodRange.cs b/src/Orbit.Application/Habits/Queries/RetrospectivePeriodRange.cs index 3f4b9f6c..bd6f7b9f 100644 --- a/src/Orbit.Application/Habits/Queries/RetrospectivePeriodRange.cs +++ b/src/Orbit.Application/Habits/Queries/RetrospectivePeriodRange.cs @@ -10,6 +10,15 @@ namespace Orbit.Application.Habits.Queries; /// public static class RetrospectivePeriodRange { + private static readonly string[] Known = ["week", "month", "quarter", "semester", "year"]; + + /// + /// True when is one understands (case-insensitive), + /// so callers can reject unknown periods at the trust boundary before resolving. + /// + public static bool IsKnownPeriod(string? period) => + period is not null && Known.Contains(period, StringComparer.OrdinalIgnoreCase); + public static (DateOnly DateFrom, DateOnly DateTo) Resolve(string period, DateOnly today, int weekStartDay) { var normalized = period.ToLowerInvariant(); @@ -20,7 +29,7 @@ public static (DateOnly DateFrom, DateOnly DateTo) Resolve(string period, DateOn "quarter" => today.AddDays(-90), "semester" => today.AddDays(-180), "year" => today.AddDays(-365), - _ => WeekMath.WeekStart(today, weekStartDay) + _ => throw new ArgumentOutOfRangeException(nameof(period), period, "Unknown retrospective period.") }; return (dateFrom, today); diff --git a/src/Orbit.Application/Habits/Validators/BulkCreateHabitsCommandValidator.cs b/src/Orbit.Application/Habits/Validators/BulkCreateHabitsCommandValidator.cs index d858f081..4caace05 100644 --- a/src/Orbit.Application/Habits/Validators/BulkCreateHabitsCommandValidator.cs +++ b/src/Orbit.Application/Habits/Validators/BulkCreateHabitsCommandValidator.cs @@ -31,6 +31,15 @@ public BulkCreateHabitsCommandValidator() .NotNull() .WithMessage("Frequency quantity is required when frequency unit is set") .When(h => h.FrequencyUnit is not null); + + habit.RuleFor(h => h.Tags) + .Must(tags => tags!.Count <= AppConstants.MaxTagsPerHabit) + .WithMessage($"Cannot assign more than {AppConstants.MaxTagsPerHabit} tags per habit") + .When(h => h.Tags is not null); + + habit.RuleForEach(h => h.Tags) + .MaximumLength(50) + .When(h => h.Tags is not null); }); } } diff --git a/src/Orbit.Application/Profile/Commands/SetHandleCommand.cs b/src/Orbit.Application/Profile/Commands/SetHandleCommand.cs new file mode 100644 index 00000000..74240bb4 --- /dev/null +++ b/src/Orbit.Application/Profile/Commands/SetHandleCommand.cs @@ -0,0 +1,49 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using Orbit.Application.Common; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Profile.Commands; + +public record SetHandleCommand(Guid UserId, string Handle) : IRequest; + +public class SetHandleCommandHandler( + IGenericRepository userRepository, + IUnitOfWork unitOfWork) : IRequestHandler +{ + public async Task Handle(SetHandleCommand request, CancellationToken cancellationToken) + { + var user = await userRepository.FindOneTrackedAsync( + u => u.Id == request.UserId, + cancellationToken: cancellationToken); + + if (user is null) + return Result.Failure(ErrorMessages.UserNotFound); + + var normalized = request.Handle.Trim(); + var lowered = normalized.ToLowerInvariant(); + + var taken = await userRepository.AnyAsync( + u => u.Id != request.UserId && u.Handle != null && u.Handle.ToLower() == lowered, + cancellationToken); + if (taken) + return Result.Failure(ErrorMessages.HandleTaken); + + var setResult = user.SetHandle(normalized); + if (setResult.IsFailure) + return setResult; + + try + { + await unitOfWork.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException exception) when (DbUniqueViolation.IsUniqueViolation(exception)) + { + return Result.Failure(ErrorMessages.HandleTaken); + } + + return Result.Success(); + } +} diff --git a/src/Orbit.Application/Profile/Commands/SetSocialOptInCommand.cs b/src/Orbit.Application/Profile/Commands/SetSocialOptInCommand.cs new file mode 100644 index 00000000..c07b81bc --- /dev/null +++ b/src/Orbit.Application/Profile/Commands/SetSocialOptInCommand.cs @@ -0,0 +1,29 @@ +using MediatR; +using Orbit.Application.Common; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Profile.Commands; + +public record SetSocialOptInCommand(Guid UserId, bool Enabled) : IRequest; + +public class SetSocialOptInCommandHandler( + IGenericRepository userRepository, + IUnitOfWork unitOfWork) : IRequestHandler +{ + public async Task Handle(SetSocialOptInCommand request, CancellationToken cancellationToken) + { + var user = await userRepository.FindOneTrackedAsync( + u => u.Id == request.UserId, + cancellationToken: cancellationToken); + + if (user is null) + return Result.Failure(ErrorMessages.UserNotFound); + + user.SetSocialOptIn(request.Enabled); + await unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/src/Orbit.Application/Profile/Models/UserDataExport.cs b/src/Orbit.Application/Profile/Models/UserDataExport.cs index 25bf343f..6c83b1ec 100644 --- a/src/Orbit.Application/Profile/Models/UserDataExport.cs +++ b/src/Orbit.Application/Profile/Models/UserDataExport.cs @@ -21,7 +21,12 @@ public sealed record UserDataExport( IReadOnlyList Achievements, IReadOnlyList StreakFreezes, IReadOnlyList Referrals, - IReadOnlyList ApiKeys); + IReadOnlyList ApiKeys, + IReadOnlyList Friendships, + IReadOnlyList Cheers, + IReadOnlyList BlockedUsers, + IReadOnlyList Reports, + IReadOnlyList FriendFeedEvents); public sealed record ExportedAccount( string Name, @@ -127,6 +132,39 @@ public sealed record ExportedReferral( DateTime? CompletedAtUtc, DateTime? RewardGrantedAtUtc); +public sealed record ExportedFriendship( + Guid RequesterId, + Guid AddresseeId, + string Status, + DateTime CreatedAtUtc, + DateTime? RespondedAtUtc); + +public sealed record ExportedCheer( + Guid SenderId, + Guid RecipientId, + Guid HabitId, + string? Note, + DateTime CreatedAtUtc); + +public sealed record ExportedBlockedUser( + Guid BlockerId, + Guid BlockedId, + DateTime CreatedAtUtc); + +public sealed record ExportedReport( + Guid ReportedUserId, + string Reason, + string? Details, + Guid? CheerId, + string Status, + DateTime CreatedAtUtc); + +public sealed record ExportedFriendFeedEvent( + string Type, + int? Value, + string? AchievementId, + DateTime CreatedAtUtc); + /// /// API key metadata only. The bcrypt secret hash (KeyHash) is intentionally never exported; /// KeyPrefix is the non-secret display prefix already shown to the owner in the UI. diff --git a/src/Orbit.Application/Profile/Queries/ExportUserDataQuery.cs b/src/Orbit.Application/Profile/Queries/ExportUserDataQuery.cs index bbf41d99..6cb64ba9 100644 --- a/src/Orbit.Application/Profile/Queries/ExportUserDataQuery.cs +++ b/src/Orbit.Application/Profile/Queries/ExportUserDataQuery.cs @@ -24,6 +24,11 @@ public class ExportUserDataQueryHandler( IGenericRepository streakFreezeRepository, IGenericRepository referralRepository, IGenericRepository apiKeyRepository, + IGenericRepository friendshipRepository, + IGenericRepository cheerRepository, + IGenericRepository blockedUserRepository, + IGenericRepository reportRepository, + IGenericRepository friendFeedEventRepository, IUserDateService userDateService, IStreakGoalReadSyncer streakGoalReadSyncer) : IRequestHandler> @@ -60,6 +65,16 @@ public async Task> Handle(ExportUserDataQuery request, Ca var streakFreezes = await streakFreezeRepository.FindAsync(s => s.UserId == request.UserId, cancellationToken); var referrals = await referralRepository.FindAsync(r => r.ReferrerId == request.UserId, cancellationToken); var apiKeys = await apiKeyRepository.FindAsync(k => k.UserId == request.UserId, cancellationToken); + var friendships = await friendshipRepository.FindAsync( + f => f.RequesterId == request.UserId || f.AddresseeId == request.UserId, cancellationToken); + var cheers = await cheerRepository.FindAsync( + c => c.SenderId == request.UserId || c.RecipientId == request.UserId, cancellationToken); + var blockedUsers = await blockedUserRepository.FindAsync( + b => b.BlockerId == request.UserId, cancellationToken); + var reports = await reportRepository.FindAsync( + r => r.ReporterId == request.UserId, cancellationToken); + var friendFeedEvents = await friendFeedEventRepository.FindAsync( + e => e.ActorUserId == request.UserId, cancellationToken); var exportedAtUtc = DateTime.UtcNow; var export = new UserDataExport( @@ -107,6 +122,26 @@ public async Task> Handle(ExportUserDataQuery request, Ca apiKeys .OrderBy(k => k.CreatedAtUtc) .Select(MapApiKey) + .ToList(), + friendships + .OrderBy(f => f.CreatedAtUtc) + .Select(f => new ExportedFriendship(f.RequesterId, f.AddresseeId, f.Status.ToString(), f.CreatedAtUtc, f.RespondedAtUtc)) + .ToList(), + cheers + .OrderBy(c => c.CreatedAtUtc) + .Select(c => new ExportedCheer(c.SenderId, c.RecipientId, c.HabitId, c.Note, c.CreatedAtUtc)) + .ToList(), + blockedUsers + .OrderBy(b => b.CreatedAtUtc) + .Select(b => new ExportedBlockedUser(b.BlockerId, b.BlockedId, b.CreatedAtUtc)) + .ToList(), + reports + .OrderBy(r => r.CreatedAtUtc) + .Select(r => new ExportedReport(r.ReportedUserId, r.Reason.ToString(), r.Details, r.CheerId, r.Status.ToString(), r.CreatedAtUtc)) + .ToList(), + friendFeedEvents + .OrderBy(e => e.CreatedAtUtc) + .Select(e => new ExportedFriendFeedEvent(e.Type.ToString(), e.Value, e.AchievementId, e.CreatedAtUtc)) .ToList()); return Result.Success(export); diff --git a/src/Orbit.Application/Profile/Queries/GetProfileQuery.cs b/src/Orbit.Application/Profile/Queries/GetProfileQuery.cs index dd0b1222..b7b59068 100644 --- a/src/Orbit.Application/Profile/Queries/GetProfileQuery.cs +++ b/src/Orbit.Application/Profile/Queries/GetProfileQuery.cs @@ -16,6 +16,10 @@ public record ProfileResponse( bool AiSummaryEnabled, bool HasCompletedOnboarding, bool HasCompletedTour, + bool HasCreatedFirstHabit, + bool HasLoggedFirstHabit, + bool HasTriedAstra, + bool HasCompletedOnboardingChecklist, string? Language, string Plan, bool HasProAccess, @@ -42,6 +46,7 @@ public record ProfileResponse( bool GoogleCalendarAutoSyncEnabled, GoogleCalendarAutoSyncStatus GoogleCalendarAutoSyncStatus, DateTime? GoogleCalendarLastSyncedAt, + bool CanViewGamification, bool Uses24HourClock = true); public record GetProfileQuery(Guid UserId) : IRequest>; @@ -50,6 +55,7 @@ public class GetProfileQueryHandler( IGenericRepository userRepository, IGenericRepository streakFreezeRepository, IUserDateService userDateService, + IFeatureFlagService featureFlagService, IPayGateService payGate) : IRequestHandler> { public async Task> Handle(GetProfileQuery request, CancellationToken cancellationToken) @@ -61,7 +67,11 @@ public async Task> Handle(GetProfileQuery request, Cance var aiMessageLimit = await payGate.GetAiMessageLimit(request.UserId, cancellationToken); - var levelTitle = LevelDefinitions.GetLevelForXp(user.TotalXp).Title; + var currentLevel = LevelDefinitions.GetLevelForXp(user.TotalXp); + var levelTitle = currentLevel.Title; + + var enabledFlags = await featureFlagService.GetEnabledKeysForUserAsync(request.UserId, cancellationToken); + var canViewGamification = user.HasProAccess || enabledFlags.Contains(FeatureFlagKeys.GamificationFreeTier); var today = await userDateService.GetUserTodayAsync(request.UserId, cancellationToken); var windowStart = today.AddDays(-29); @@ -78,6 +88,10 @@ public async Task> Handle(GetProfileQuery request, Cance user.AiSummaryEnabled, user.HasCompletedOnboarding, user.HasCompletedTour, + user.HasCreatedFirstHabit, + user.HasLoggedFirstHabit, + user.HasTriedAstra, + user.HasCompletedOnboardingChecklist, user.Language, user.HasProAccess ? "pro" : "free", user.HasProAccess, @@ -93,7 +107,7 @@ public async Task> Handle(GetProfileQuery request, Cance user.IsLifetimePro, user.WeekStartDay, user.TotalXp, - user.Level, + currentLevel.Level, levelTitle, user.LastAdRewardLocalDate == today ? user.AdRewardsClaimedToday @@ -106,6 +120,7 @@ public async Task> Handle(GetProfileQuery request, Cance user.GoogleCalendarAutoSyncEnabled, user.GoogleCalendarAutoSyncStatus ?? GoogleCalendarAutoSyncStatus.Idle, user.GoogleCalendarLastSyncedAt, + canViewGamification, TimeFormatResolver.Uses24HourClock(user.TimeZone))); } } diff --git a/src/Orbit.Application/Profile/Validators/SetHandleCommandValidator.cs b/src/Orbit.Application/Profile/Validators/SetHandleCommandValidator.cs new file mode 100644 index 00000000..bc489b36 --- /dev/null +++ b/src/Orbit.Application/Profile/Validators/SetHandleCommandValidator.cs @@ -0,0 +1,16 @@ +using FluentValidation; +using Orbit.Application.Profile.Commands; + +namespace Orbit.Application.Profile.Validators; + +public class SetHandleCommandValidator : AbstractValidator +{ + public SetHandleCommandValidator() + { + RuleFor(x => x.UserId).NotEmpty(); + RuleFor(x => x.Handle) + .NotEmpty() + .Matches("^[A-Za-z0-9_]{3,20}$") + .WithMessage("Handle must be 3-20 characters using only letters, numbers, or underscores."); + } +} diff --git a/src/Orbit.Application/Profile/Validators/SetSocialOptInCommandValidator.cs b/src/Orbit.Application/Profile/Validators/SetSocialOptInCommandValidator.cs new file mode 100644 index 00000000..d36c1082 --- /dev/null +++ b/src/Orbit.Application/Profile/Validators/SetSocialOptInCommandValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; +using Orbit.Application.Profile.Commands; + +namespace Orbit.Application.Profile.Validators; + +public class SetSocialOptInCommandValidator : AbstractValidator +{ + public SetSocialOptInCommandValidator() + { + RuleFor(x => x.UserId).NotEmpty(); + } +} diff --git a/src/Orbit.Application/Social/Commands/AcceptFriendRequestCommand.cs b/src/Orbit.Application/Social/Commands/AcceptFriendRequestCommand.cs new file mode 100644 index 00000000..9eae412a --- /dev/null +++ b/src/Orbit.Application/Social/Commands/AcceptFriendRequestCommand.cs @@ -0,0 +1,72 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using Orbit.Application.Common; +using Orbit.Application.Social.Services; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Social.Commands; + +public record AcceptFriendRequestCommand(Guid UserId, Guid FriendshipId) : IRequest; + +public partial class AcceptFriendRequestCommandHandler( + SocialAccessGuard socialAccessGuard, + IGenericRepository friendshipRepository, + IGenericRepository userRepository, + IUnitOfWork unitOfWork, + IPushNotificationService pushNotificationService, + ILogger logger) : IRequestHandler +{ + public async Task Handle(AcceptFriendRequestCommand request, CancellationToken cancellationToken) + { + var access = await socialAccessGuard.EnsureEnabledAsync(request.UserId, cancellationToken); + if (access.IsFailure) + return access.PropagateError(); + + var friendship = await friendshipRepository.FindOneTrackedAsync( + f => f.Id == request.FriendshipId && f.AddresseeId == request.UserId, + cancellationToken: cancellationToken); + + if (friendship is null) + return Result.Failure(ErrorMessages.FriendRequestNotFound); + + var acceptResult = friendship.Accept(); + if (acceptResult.IsFailure) + return acceptResult; + + await unitOfWork.SaveChangesAsync(cancellationToken); + + await NotifyRequesterAsync(friendship.RequesterId, access.Value, cancellationToken); + + return Result.Success(); + } + + private async Task NotifyRequesterAsync(Guid requesterId, User accepter, CancellationToken cancellationToken) + { + var requester = await userRepository.FindOneTrackedAsync( + u => u.Id == requesterId, + cancellationToken: cancellationToken); + + if (requester is null || !requester.SocialOptIn) + return; + + var isPortuguese = LocaleHelper.IsPortuguese(requester.Language); + var title = isPortuguese ? "Pedido de amizade aceito" : "Friend request accepted"; + var body = isPortuguese + ? $"{accepter.Name} aceitou seu pedido de amizade." + : $"{accepter.Name} accepted your friend request."; + + try + { + await pushNotificationService.SendToUserAsync(requesterId, title, body, cancellationToken: cancellationToken); + } + catch (Exception ex) + { + LogPushNotificationFailed(logger, ex, requesterId); + } + } + + [LoggerMessage(EventId = 1, Level = LogLevel.Warning, Message = "Friend-accepted push failed for user {UserId}")] + private static partial void LogPushNotificationFailed(ILogger logger, Exception ex, Guid userId); +} diff --git a/src/Orbit.Application/Social/Commands/BlockUserCommand.cs b/src/Orbit.Application/Social/Commands/BlockUserCommand.cs new file mode 100644 index 00000000..bd49d385 --- /dev/null +++ b/src/Orbit.Application/Social/Commands/BlockUserCommand.cs @@ -0,0 +1,54 @@ +using MediatR; +using Orbit.Application.Common; +using Orbit.Application.Social.Services; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Social.Commands; + +/// +/// Blocks a user. Blocking is idempotent and also tears down any existing friendship between the two +/// (a block must immediately stop feed visibility, pushes, and future requests in both directions). +/// +public record BlockUserCommand(Guid UserId, Guid BlockedUserId) : IRequest; + +public class BlockUserCommandHandler( + SocialAccessGuard socialAccessGuard, + FriendGraphService friendGraphService, + IGenericRepository userRepository, + IGenericRepository blockedUserRepository, + IGenericRepository friendshipRepository, + IUnitOfWork unitOfWork) : IRequestHandler +{ + public async Task Handle(BlockUserCommand request, CancellationToken cancellationToken) + { + var access = await socialAccessGuard.EnsureEnabledAsync(request.UserId, cancellationToken); + if (access.IsFailure) + return access.PropagateError(); + + var alreadyBlocked = await blockedUserRepository.AnyAsync( + b => b.BlockerId == request.UserId && b.BlockedId == request.BlockedUserId, + cancellationToken); + if (alreadyBlocked) + return Result.Success(); + + var targetExists = await userRepository.AnyAsync(u => u.Id == request.BlockedUserId, cancellationToken); + if (!targetExists) + return Result.Failure(ErrorMessages.UserNotFound); + + var createResult = BlockedUser.Create(request.UserId, request.BlockedUserId); + if (createResult.IsFailure) + return createResult; + + await blockedUserRepository.AddAsync(createResult.Value, cancellationToken); + + var friendship = await friendGraphService.FindFriendshipAsync(request.UserId, request.BlockedUserId, cancellationToken); + if (friendship is not null) + friendshipRepository.Remove(friendship); + + await unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/src/Orbit.Application/Social/Commands/RemoveFriendCommand.cs b/src/Orbit.Application/Social/Commands/RemoveFriendCommand.cs new file mode 100644 index 00000000..588d62d5 --- /dev/null +++ b/src/Orbit.Application/Social/Commands/RemoveFriendCommand.cs @@ -0,0 +1,37 @@ +using MediatR; +using Orbit.Application.Common; +using Orbit.Application.Social.Services; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Social.Commands; + +/// +/// Removes the single friendship row between the caller and the target regardless of status, covering +/// unfriend, decline-incoming, and cancel-outgoing. Idempotent: a missing row is a successful no-op. +/// +public record RemoveFriendCommand(Guid UserId, Guid FriendUserId) : IRequest; + +public class RemoveFriendCommandHandler( + SocialAccessGuard socialAccessGuard, + FriendGraphService friendGraphService, + IGenericRepository friendshipRepository, + IUnitOfWork unitOfWork) : IRequestHandler +{ + public async Task Handle(RemoveFriendCommand request, CancellationToken cancellationToken) + { + var access = await socialAccessGuard.EnsureEnabledAsync(request.UserId, cancellationToken); + if (access.IsFailure) + return access.PropagateError(); + + var friendship = await friendGraphService.FindFriendshipAsync(request.UserId, request.FriendUserId, cancellationToken); + if (friendship is null) + return Result.Success(); + + friendshipRepository.Remove(friendship); + await unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/src/Orbit.Application/Social/Commands/ReportUserCommand.cs b/src/Orbit.Application/Social/Commands/ReportUserCommand.cs new file mode 100644 index 00000000..4c10ccfa --- /dev/null +++ b/src/Orbit.Application/Social/Commands/ReportUserCommand.cs @@ -0,0 +1,57 @@ +using MediatR; +using Orbit.Application.Common; +using Orbit.Application.Social.Services; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Enums; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Social.Commands; + +public record ReportUserCommand( + Guid UserId, + Guid ReportedUserId, + ReportReason Reason, + string? Details, + Guid? CheerId) : IRequest>; + +public class ReportUserCommandHandler( + SocialAccessGuard socialAccessGuard, + IGenericRepository userRepository, + IGenericRepository cheerRepository, + IGenericRepository reportRepository, + IUnitOfWork unitOfWork) : IRequestHandler> +{ + public async Task> Handle(ReportUserCommand request, CancellationToken cancellationToken) + { + var access = await socialAccessGuard.EnsureEnabledAsync(request.UserId, cancellationToken); + if (access.IsFailure) + return access.PropagateError(); + + var targetExists = await userRepository.AnyAsync(u => u.Id == request.ReportedUserId, cancellationToken); + if (!targetExists) + return Result.Failure(ErrorMessages.UserNotFound); + + if (request.CheerId.HasValue) + { + var cheer = await cheerRepository.FindOneTrackedAsync( + c => c.Id == request.CheerId.Value, cancellationToken: cancellationToken); + if (cheer is null || (cheer.SenderId != request.ReportedUserId && cheer.RecipientId != request.ReportedUserId)) + return Result.Failure(ErrorMessages.CheerNotFound); + } + + var createResult = Report.Create( + request.UserId, + request.ReportedUserId, + request.Reason, + request.Details, + request.CheerId); + if (createResult.IsFailure) + return createResult.PropagateError(); + + await reportRepository.AddAsync(createResult.Value, cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(createResult.Value.Id); + } +} diff --git a/src/Orbit.Application/Social/Commands/SendCheerCommand.cs b/src/Orbit.Application/Social/Commands/SendCheerCommand.cs new file mode 100644 index 00000000..0accc59c --- /dev/null +++ b/src/Orbit.Application/Social/Commands/SendCheerCommand.cs @@ -0,0 +1,140 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using Orbit.Application.Common; +using Orbit.Application.Gamification; +using Orbit.Application.Gamification.Models; +using Orbit.Application.Social.Services; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Social.Commands; + +/// Groups the repositories a cheer touches to keep the handler constructor small. +public record SendCheerRepositories( + IGenericRepository Users, + IGenericRepository Habits, + IGenericRepository Cheers, + IGenericRepository Achievements); + +public record SendCheerCommand(Guid UserId, Guid RecipientId, Guid HabitId, string? Note) : IRequest>; + +public partial class SendCheerCommandHandler( + SocialAccessGuard socialAccessGuard, + FriendGraphService friendGraphService, + SendCheerRepositories repositories, + IContentModerationService contentModerationService, + IPushNotificationService pushNotificationService, + IUnitOfWork unitOfWork, + ILogger logger) : IRequestHandler> +{ + public async Task> Handle(SendCheerCommand request, CancellationToken cancellationToken) + { + var access = await socialAccessGuard.EnsureEnabledAsync(request.UserId, cancellationToken); + if (access.IsFailure) + return access.PropagateError(); + var sender = access.Value; + + var recipient = await repositories.Users.FindOneTrackedAsync( + u => u.Id == request.RecipientId, + cancellationToken: cancellationToken); + if (recipient is null || !recipient.SocialOptIn) + return Result.Failure(ErrorMessages.NotFriends); + + if (!await friendGraphService.AreAcceptedFriendsAsync(request.UserId, request.RecipientId, cancellationToken)) + return Result.Failure(ErrorMessages.NotFriends); + + if (await friendGraphService.IsBlockedBetweenAsync(request.UserId, request.RecipientId, cancellationToken)) + return Result.Failure(ErrorMessages.Blocked); + + var habitBelongsToRecipient = await repositories.Habits.AnyAsync( + h => h.Id == request.HabitId && h.UserId == request.RecipientId, + cancellationToken); + if (!habitBelongsToRecipient) + return Result.Failure(ErrorMessages.HabitNotFound); + + var note = string.IsNullOrWhiteSpace(request.Note) ? null : request.Note.Trim(); + var moderation = await ModerateNoteAsync(note, request.UserId, cancellationToken); + if (moderation.IsFailure) + return moderation.PropagateError(); + + var createResult = Cheer.Create(request.UserId, request.RecipientId, request.HabitId, note); + if (createResult.IsFailure) + return createResult.PropagateError(); + + await repositories.Cheers.AddAsync(createResult.Value, cancellationToken); + await AwardFirstCheerAsync(sender, cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); + + await NotifyRecipientAsync(recipient, sender, cancellationToken); + + return Result.Success(createResult.Value.Id); + } + + /// + /// Screens the note before persistence. A definitive flag rejects the cheer (fail closed); a + /// moderation outage proceeds and is logged at warning (fail open), since cheer notes are visible + /// only to an accepted friend and are already covered by block + report. An empty note skips the call. + /// + private async Task ModerateNoteAsync(string? note, Guid senderId, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(note)) + return Result.Success(); + + var moderation = await contentModerationService.CheckTextAsync(note, cancellationToken); + + if (moderation.Flagged && !moderation.Unavailable) + return Result.Failure(ErrorMessages.ContentRejected); + + if (moderation.Unavailable) + LogModerationUnavailable(logger, senderId); + + return Result.Success(); + } + + private async Task AwardFirstCheerAsync(User sender, CancellationToken cancellationToken) + { + var alreadyEarned = await repositories.Achievements.AnyAsync( + a => a.UserId == sender.Id && a.AchievementId == AchievementDefinitions.FirstCheer, + cancellationToken); + if (alreadyEarned) + return; + + var earned = new HashSet(); + var newAchievements = new List<(UserAchievement Entity, AchievementDefinition Definition)>(); + AchievementChecks.TryGrant(AchievementDefinitions.FirstCheer, sender, earned, newAchievements); + + if (newAchievements.Count == 0) + return; + + await repositories.Achievements.AddAsync(newAchievements[0].Entity, cancellationToken); + + var newLevel = LevelDefinitions.GetLevelForXp(sender.TotalXp); + if (newLevel.Level != sender.Level) + sender.SetLevel(newLevel.Level); + } + + private async Task NotifyRecipientAsync(User recipient, User sender, CancellationToken cancellationToken) + { + var isPortuguese = LocaleHelper.IsPortuguese(recipient.Language); + var title = isPortuguese ? "Novo incentivo" : "New cheer"; + var body = isPortuguese + ? $"{sender.Name} torceu por você!" + : $"{sender.Name} cheered you on!"; + + try + { + await pushNotificationService.SendToUserAsync(recipient.Id, title, body, cancellationToken: cancellationToken); + } + catch (Exception ex) + { + LogPushNotificationFailed(logger, ex, recipient.Id); + } + } + + [LoggerMessage(EventId = 1, Level = LogLevel.Warning, Message = "Cheer note moderation unavailable for sender {UserId}; allowing note (fail open)")] + private static partial void LogModerationUnavailable(ILogger logger, Guid userId); + + [LoggerMessage(EventId = 2, Level = LogLevel.Warning, Message = "Cheer push failed for user {UserId}")] + private static partial void LogPushNotificationFailed(ILogger logger, Exception ex, Guid userId); +} diff --git a/src/Orbit.Application/Social/Commands/SendFriendRequestCommand.cs b/src/Orbit.Application/Social/Commands/SendFriendRequestCommand.cs new file mode 100644 index 00000000..d8a31a6f --- /dev/null +++ b/src/Orbit.Application/Social/Commands/SendFriendRequestCommand.cs @@ -0,0 +1,48 @@ +using MediatR; +using Orbit.Application.Common; +using Orbit.Application.Social.Services; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Social.Commands; + +public record SendFriendRequestCommand(Guid UserId, string? Handle, string? ReferralCode) : IRequest>; + +public class SendFriendRequestCommandHandler( + SocialAccessGuard socialAccessGuard, + FriendGraphService friendGraphService, + IGenericRepository friendshipRepository, + IUnitOfWork unitOfWork) : IRequestHandler> +{ + public async Task> Handle(SendFriendRequestCommand request, CancellationToken cancellationToken) + { + var access = await socialAccessGuard.EnsureEnabledAsync(request.UserId, cancellationToken); + if (access.IsFailure) + return access.PropagateError(); + + var target = await friendGraphService.ResolveTargetAsync(request.Handle, request.ReferralCode, cancellationToken); + if (target is null || target.Id == request.UserId || !target.SocialOptIn) + return Result.Failure(ErrorMessages.UserNotFound); + + if (await friendGraphService.IsBlockedBetweenAsync(request.UserId, target.Id, cancellationToken)) + return Result.Failure(ErrorMessages.UserNotFound); + + var existing = await friendGraphService.FindFriendshipAsync(request.UserId, target.Id, cancellationToken); + if (existing is not null) + return Result.Failure(ErrorMessages.AlreadyFriends); + + var friendCount = await friendGraphService.CountAcceptedFriendsAsync(request.UserId, cancellationToken); + if (friendCount >= AppConstants.MaxFriends) + return Result.Failure(ErrorMessages.FriendLimitReached.Format(AppConstants.MaxFriends)); + + var createResult = Friendship.Create(request.UserId, target.Id); + if (createResult.IsFailure) + return createResult.PropagateError(); + + await friendshipRepository.AddAsync(createResult.Value, cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(createResult.Value.Id); + } +} diff --git a/src/Orbit.Application/Social/Commands/UnblockUserCommand.cs b/src/Orbit.Application/Social/Commands/UnblockUserCommand.cs new file mode 100644 index 00000000..4fbd1d41 --- /dev/null +++ b/src/Orbit.Application/Social/Commands/UnblockUserCommand.cs @@ -0,0 +1,38 @@ +using MediatR; +using Orbit.Application.Common; +using Orbit.Application.Social.Services; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Social.Commands; + +/// +/// Lifts a block. Idempotent. Does not restore the prior friendship — the users must reconnect. +/// +public record UnblockUserCommand(Guid UserId, Guid BlockedUserId) : IRequest; + +public class UnblockUserCommandHandler( + SocialAccessGuard socialAccessGuard, + IGenericRepository blockedUserRepository, + IUnitOfWork unitOfWork) : IRequestHandler +{ + public async Task Handle(UnblockUserCommand request, CancellationToken cancellationToken) + { + var access = await socialAccessGuard.EnsureEnabledAsync(request.UserId, cancellationToken); + if (access.IsFailure) + return access.PropagateError(); + + var block = await blockedUserRepository.FindOneTrackedAsync( + b => b.BlockerId == request.UserId && b.BlockedId == request.BlockedUserId, + cancellationToken: cancellationToken); + + if (block is null) + return Result.Success(); + + blockedUserRepository.Remove(block); + await unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/src/Orbit.Application/Social/Queries/GetCheersQuery.cs b/src/Orbit.Application/Social/Queries/GetCheersQuery.cs new file mode 100644 index 00000000..db4434a7 --- /dev/null +++ b/src/Orbit.Application/Social/Queries/GetCheersQuery.cs @@ -0,0 +1,74 @@ +using MediatR; +using Orbit.Application.Common; +using Orbit.Application.Social.Services; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Social.Queries; + +public record CheerDto( + Guid Id, + Guid SenderId, + Guid RecipientId, + Guid HabitId, + string? Note, + DateTime CreatedAtUtc, + string SenderHandle, + string SenderDisplayName); + +public record CheersPage(IReadOnlyList Items); + +public record GetCheersQuery(Guid UserId, string Direction) : IRequest>; + +public class GetCheersQueryHandler( + SocialAccessGuard socialAccessGuard, + IGenericRepository cheerRepository, + IGenericRepository blockedUserRepository, + IGenericRepository userRepository) : IRequestHandler> +{ + public const string ReceivedDirection = "received"; + + public async Task> Handle(GetCheersQuery request, CancellationToken cancellationToken) + { + var access = await socialAccessGuard.EnsureEnabledAsync(request.UserId, cancellationToken); + if (access.IsFailure) + return access.PropagateError(); + + var blocks = await blockedUserRepository.FindAsync( + b => b.BlockerId == request.UserId || b.BlockedId == request.UserId, + cancellationToken); + var blockedIds = blocks + .Select(b => b.BlockerId == request.UserId ? b.BlockedId : b.BlockerId) + .ToHashSet(); + + var isReceived = string.Equals(request.Direction, ReceivedDirection, StringComparison.OrdinalIgnoreCase); + + var cheers = isReceived + ? await cheerRepository.FindAsync(c => c.RecipientId == request.UserId && !blockedIds.Contains(c.SenderId), cancellationToken) + : await cheerRepository.FindAsync(c => c.SenderId == request.UserId && !blockedIds.Contains(c.RecipientId), cancellationToken); + + var senderIds = cheers.Select(c => c.SenderId).ToHashSet(); + var senders = await userRepository.FindAsync(u => senderIds.Contains(u.Id), cancellationToken); + var sendersById = senders.ToDictionary(u => u.Id); + + var items = cheers + .OrderByDescending(c => c.CreatedAtUtc) + .Select(c => + { + sendersById.TryGetValue(c.SenderId, out var sender); + return new CheerDto( + c.Id, + c.SenderId, + c.RecipientId, + c.HabitId, + c.Note, + c.CreatedAtUtc, + sender?.Handle ?? string.Empty, + sender?.Name ?? string.Empty); + }) + .ToList(); + + return Result.Success(new CheersPage(items)); + } +} diff --git a/src/Orbit.Application/Social/Queries/GetFriendFeedQuery.cs b/src/Orbit.Application/Social/Queries/GetFriendFeedQuery.cs new file mode 100644 index 00000000..bd26f770 --- /dev/null +++ b/src/Orbit.Application/Social/Queries/GetFriendFeedQuery.cs @@ -0,0 +1,170 @@ +using System.Text; +using MediatR; +using Orbit.Application.Common; +using Orbit.Application.Social.Services; +using Orbit.Domain.Common; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Social.Queries; + +public record FriendFeedItem( + Guid Id, + Guid ActorUserId, + string ActorHandle, + string ActorDisplayName, + string Type, + int? Value, + string? AchievementId, + DateTime CreatedAtUtc); + +public record FriendFeedPage(IReadOnlyList Items, string? NextCursor); + +public record GetFriendFeedQuery(Guid UserId, string? Cursor, int? PageSize) : IRequest>; + +public class GetFriendFeedQueryHandler( + SocialAccessGuard socialAccessGuard, + FriendGraphService friendGraphService, + IGenericRepository blockedUserRepository, + IGenericRepository userRepository, + IFriendFeedReader friendFeedReader) : IRequestHandler> +{ + public async Task> Handle(GetFriendFeedQuery request, CancellationToken cancellationToken) + { + var access = await socialAccessGuard.EnsureEnabledAsync(request.UserId, cancellationToken); + if (access.IsFailure) + return access.PropagateError(); + + var actorMap = await ResolveVisibleActorsAsync(request.UserId, cancellationToken); + if (actorMap.Count == 0) + return Result.Success(new FriendFeedPage([], null)); + + var pageSize = Math.Clamp( + request.PageSize ?? AppConstants.FriendFeedPageSize, + 1, + AppConstants.MaxFriendFeedPageSize); + + DateTime? cursorCreatedAtUtc = null; + Guid? cursorId = null; + if (!string.IsNullOrEmpty(request.Cursor) && FeedCursor.TryDecode(request.Cursor, out var time, out var id)) + { + cursorCreatedAtUtc = time; + cursorId = id; + } + + var rows = await friendFeedReader.ReadFeedPageAsync( + actorMap.Keys.ToList(), + cursorCreatedAtUtc, + cursorId, + pageSize + 1, + cancellationToken); + + var hasMore = rows.Count > pageSize; + var pageRows = hasMore ? rows.Take(pageSize).ToList() : rows; + + var items = pageRows + .Where(e => actorMap.ContainsKey(e.ActorUserId)) + .Select(e => + { + actorMap.TryGetValue(e.ActorUserId, out var actor); + return new FriendFeedItem( + e.Id, + e.ActorUserId, + actor.Handle ?? string.Empty, + actor.DisplayName ?? string.Empty, + e.Type.ToString(), + e.Value, + e.AchievementId, + e.CreatedAtUtc); + }) + .ToList(); + + var nextCursor = hasMore && pageRows.Count > 0 + ? FeedCursor.Encode(pageRows[^1].CreatedAtUtc, pageRows[^1].Id) + : null; + + return Result.Success(new FriendFeedPage(items, nextCursor)); + } + + private async Task> ResolveVisibleActorsAsync( + Guid userId, CancellationToken cancellationToken) + { + var friendIds = await friendGraphService.GetAcceptedFriendIdsAsync(userId, cancellationToken); + if (friendIds.Count == 0) + return []; + + var blocks = await blockedUserRepository.FindAsync( + b => b.BlockerId == userId || b.BlockedId == userId, + cancellationToken); + var blockedIds = blocks + .Select(b => b.BlockerId == userId ? b.BlockedId : b.BlockerId) + .ToHashSet(); + + var candidateIds = friendIds.Where(id => !blockedIds.Contains(id)).ToHashSet(); + if (candidateIds.Count == 0) + return []; + + var users = await userRepository.FindAsync(u => candidateIds.Contains(u.Id), cancellationToken); + + return users + .Where(u => u.SocialOptIn) + .ToDictionary(u => u.Id, u => (u.Handle ?? string.Empty, u.Name)); + } +} + +/// +/// Opaque, URL-safe keyset cursor pairing a row's CreatedAtUtc ticks with its Id, so the feed paginates +/// stably under concurrent inserts (page boundaries are anchored to a row, not an offset). +/// +internal static class FeedCursor +{ + public static string Encode(DateTime createdAtUtc, Guid id) => + Base64UrlEncode($"{createdAtUtc.Ticks}:{id:N}"); + + public static bool TryDecode(string cursor, out DateTime createdAtUtc, out Guid id) + { + createdAtUtc = default; + id = default; + + var decoded = Base64UrlDecode(cursor); + if (decoded is null) + return false; + + var parts = decoded.Split(':'); + if (parts.Length != 2) + return false; + + if (!long.TryParse(parts[0], out var ticks) || ticks < 0 || ticks > DateTime.MaxValue.Ticks) + return false; + + if (!Guid.TryParseExact(parts[1], "N", out id)) + return false; + + createdAtUtc = new DateTime(ticks, DateTimeKind.Utc); + return true; + } + + private static string Base64UrlEncode(string value) + { + var bytes = Encoding.UTF8.GetBytes(value); + return Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + } + + private static string? Base64UrlDecode(string value) + { + try + { + var padded = value.Replace('-', '+').Replace('_', '/'); + padded += (padded.Length % 4) switch + { + 2 => "==", + 3 => "=", + _ => string.Empty + }; + return Encoding.UTF8.GetString(Convert.FromBase64String(padded)); + } + catch (FormatException) + { + return null; + } + } +} diff --git a/src/Orbit.Application/Social/Queries/GetFriendsQuery.cs b/src/Orbit.Application/Social/Queries/GetFriendsQuery.cs new file mode 100644 index 00000000..8158cbed --- /dev/null +++ b/src/Orbit.Application/Social/Queries/GetFriendsQuery.cs @@ -0,0 +1,87 @@ +using MediatR; +using Orbit.Application.Common; +using Orbit.Application.Social.Services; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Enums; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Social.Queries; + +public record FriendSummary(Guid UserId, string Handle, string DisplayName, int CurrentStreak); + +public record FriendRequestSummary(Guid Id, Guid UserId, string Handle, string DisplayName, DateTime CreatedAtUtc); + +public record FriendsResponse( + IReadOnlyList Friends, + IReadOnlyList IncomingRequests, + IReadOnlyList OutgoingRequests); + +public record GetFriendsQuery(Guid UserId) : IRequest>; + +public class GetFriendsQueryHandler( + SocialAccessGuard socialAccessGuard, + IGenericRepository friendshipRepository, + IGenericRepository blockedUserRepository, + IGenericRepository userRepository) : IRequestHandler> +{ + public async Task> Handle(GetFriendsQuery request, CancellationToken cancellationToken) + { + var access = await socialAccessGuard.EnsureEnabledAsync(request.UserId, cancellationToken); + if (access.IsFailure) + return access.PropagateError(); + + var friendships = await friendshipRepository.FindAsync( + f => f.RequesterId == request.UserId || f.AddresseeId == request.UserId, + cancellationToken); + + var blocks = await blockedUserRepository.FindAsync( + b => b.BlockerId == request.UserId || b.BlockedId == request.UserId, + cancellationToken); + var blockedIds = blocks + .Select(b => b.BlockerId == request.UserId ? b.BlockedId : b.BlockerId) + .ToHashSet(); + + var visible = friendships + .Where(f => !blockedIds.Contains(OtherId(f, request.UserId))) + .ToList(); + + var otherIds = visible.Select(f => OtherId(f, request.UserId)).ToHashSet(); + var users = await userRepository.FindAsync(u => otherIds.Contains(u.Id), cancellationToken); + var usersById = users.ToDictionary(u => u.Id); + + var friends = new List(); + var incoming = new List(); + var outgoing = new List(); + + foreach (var friendship in visible) + { + var otherId = OtherId(friendship, request.UserId); + if (!usersById.TryGetValue(otherId, out var other)) + continue; + + if (friendship.Status == FriendshipStatus.Accepted) + { + friends.Add(new FriendSummary(otherId, other.Handle ?? string.Empty, other.Name, other.CurrentStreak)); + } + else if (friendship.AddresseeId == request.UserId) + { + incoming.Add(new FriendRequestSummary(friendship.Id, otherId, other.Handle ?? string.Empty, other.Name, friendship.CreatedAtUtc)); + } + else + { + outgoing.Add(new FriendRequestSummary(friendship.Id, otherId, other.Handle ?? string.Empty, other.Name, friendship.CreatedAtUtc)); + } + } + + var response = new FriendsResponse( + friends.OrderBy(f => f.DisplayName).ToList(), + incoming.OrderByDescending(r => r.CreatedAtUtc).ToList(), + outgoing.OrderByDescending(r => r.CreatedAtUtc).ToList()); + + return Result.Success(response); + } + + private static Guid OtherId(Friendship friendship, Guid userId) => + friendship.RequesterId == userId ? friendship.AddresseeId : friendship.RequesterId; +} diff --git a/src/Orbit.Application/Social/Services/FriendFeedEmitter.cs b/src/Orbit.Application/Social/Services/FriendFeedEmitter.cs new file mode 100644 index 00000000..2be08d21 --- /dev/null +++ b/src/Orbit.Application/Social/Services/FriendFeedEmitter.cs @@ -0,0 +1,75 @@ +using Orbit.Application.Common; +using Orbit.Application.Gamification; +using Orbit.Domain.Entities; +using Orbit.Domain.Enums; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Social.Services; + +/// +/// Classifies a gamification milestone into a and appends it. Streak +/// milestones fire for every user as their streak crosses a tier; achievement-backed events arrive +/// only from the Pro achievement path. Emission is gated on the actor's social opt-in and de-duped +/// against already-stored events (an in-memory pre-check; the partial unique indexes are the backstop), +/// so an ordinary daily log that crosses no new tier writes nothing. +/// +public class FriendFeedEmitter(IGenericRepository feedEventRepository) : IFriendFeedEventEmitter +{ + private static readonly Dictionary VolumeCompletionCounts = new() + { + [AchievementDefinitions.GettingMomentum] = 10, + [AchievementDefinitions.BuildingHabits] = 50, + [AchievementDefinitions.Dedicated] = 100, + [AchievementDefinitions.Relentless] = 500, + [AchievementDefinitions.LegendaryVolume] = 1000, + }; + + public async Task EmitStreakMilestonesAsync(User actor, int previousStreak, CancellationToken cancellationToken = default) + { + if (!actor.SocialOptIn) + return; + + var crossedTiers = AppConstants.StreakMilestoneTiers + .Where(tier => previousStreak < tier && tier <= actor.CurrentStreak) + .ToList(); + + if (crossedTiers.Count == 0) + return; + + var existing = await feedEventRepository.FindAsync( + e => e.ActorUserId == actor.Id && e.Type == FriendFeedEventType.StreakMilestone, + cancellationToken); + var alreadyEmitted = existing.Select(e => e.Value).ToHashSet(); + + foreach (var tier in crossedTiers) + { + if (!alreadyEmitted.Add(tier)) + continue; + + await feedEventRepository.AddAsync(FriendFeedEvent.StreakMilestone(actor.Id, tier), cancellationToken); + } + } + + public async Task EmitAchievementEventAsync( + User actor, + string achievementId, + AchievementCategory category, + CancellationToken cancellationToken = default) + { + if (!actor.SocialOptIn) + return; + + var alreadyEmitted = await feedEventRepository.AnyAsync( + e => e.ActorUserId == actor.Id && e.AchievementId == achievementId, + cancellationToken); + if (alreadyEmitted) + return; + + var feedEvent = category == AchievementCategory.Volume + && VolumeCompletionCounts.TryGetValue(achievementId, out var completions) + ? FriendFeedEvent.HabitCompletedMilestone(actor.Id, achievementId, completions) + : FriendFeedEvent.AchievementUnlocked(actor.Id, achievementId); + + await feedEventRepository.AddAsync(feedEvent, cancellationToken); + } +} diff --git a/src/Orbit.Application/Social/Services/FriendGraphService.cs b/src/Orbit.Application/Social/Services/FriendGraphService.cs new file mode 100644 index 00000000..c45aa5e9 --- /dev/null +++ b/src/Orbit.Application/Social/Services/FriendGraphService.cs @@ -0,0 +1,84 @@ +using Orbit.Domain.Entities; +using Orbit.Domain.Enums; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Social.Services; + +/// +/// Reusable friend-graph reads shared by the social handlers: resolving a target user from a handle +/// (case-insensitive) or referral code, block detection in both directions, locating the single +/// friendship row between two users regardless of direction, and listing a user's accepted-friend ids. +/// Resolution returns null on a miss so callers can map it to a uniform not-found (no enumeration). +/// +public class FriendGraphService( + IGenericRepository userRepository, + IGenericRepository friendshipRepository, + IGenericRepository blockedUserRepository) +{ + public async Task ResolveTargetAsync(string? handle, string? referralCode, CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(handle)) + { + var normalized = handle.Trim().ToLowerInvariant(); + var matches = await userRepository.FindAsync( + u => u.Handle != null && u.Handle.ToLower() == normalized, + cancellationToken); + return matches.FirstOrDefault(); + } + + if (!string.IsNullOrWhiteSpace(referralCode)) + { + var normalized = referralCode.Trim(); + var matches = await userRepository.FindAsync( + u => u.ReferralCode == normalized, + cancellationToken); + return matches.FirstOrDefault(); + } + + return null; + } + + public async Task IsBlockedBetweenAsync(Guid first, Guid second, CancellationToken cancellationToken) + { + return await blockedUserRepository.AnyAsync( + x => (x.BlockerId == first && x.BlockedId == second) + || (x.BlockerId == second && x.BlockedId == first), + cancellationToken); + } + + public async Task FindFriendshipAsync(Guid first, Guid second, CancellationToken cancellationToken) + { + return await friendshipRepository.FindOneTrackedAsync( + f => (f.RequesterId == first && f.AddresseeId == second) + || (f.RequesterId == second && f.AddresseeId == first), + cancellationToken: cancellationToken); + } + + public async Task AreAcceptedFriendsAsync(Guid first, Guid second, CancellationToken cancellationToken) + { + return await friendshipRepository.AnyAsync( + f => f.Status == FriendshipStatus.Accepted + && ((f.RequesterId == first && f.AddresseeId == second) + || (f.RequesterId == second && f.AddresseeId == first)), + cancellationToken); + } + + public async Task CountAcceptedFriendsAsync(Guid userId, CancellationToken cancellationToken) + { + return await friendshipRepository.CountAsync( + f => f.Status == FriendshipStatus.Accepted && (f.RequesterId == userId || f.AddresseeId == userId), + cancellationToken); + } + + public async Task> GetAcceptedFriendIdsAsync(Guid userId, CancellationToken cancellationToken) + { + var friendships = await friendshipRepository.FindAsync( + f => f.Status == FriendshipStatus.Accepted && (f.RequesterId == userId || f.AddresseeId == userId), + cancellationToken); + + return friendships + .Select(f => f.RequesterId == userId ? f.AddresseeId : f.RequesterId) + .Distinct() + .ToList(); + } +} diff --git a/src/Orbit.Application/Social/Services/IFriendFeedEventEmitter.cs b/src/Orbit.Application/Social/Services/IFriendFeedEventEmitter.cs new file mode 100644 index 00000000..f18114c7 --- /dev/null +++ b/src/Orbit.Application/Social/Services/IFriendFeedEventEmitter.cs @@ -0,0 +1,20 @@ +using Orbit.Domain.Entities; +using Orbit.Domain.Enums; + +namespace Orbit.Application.Social.Services; + +/// +/// The friend-feed write pipeline injected into the two gamification hooks. Emission is opt-in-gated on +/// the actor and idempotent (de-duped against prior events). Rows are added to the active unit of work; +/// the caller persists them with its own SaveChanges so emission shares the milestone's transaction. +/// +public interface IFriendFeedEventEmitter +{ + Task EmitStreakMilestonesAsync(User actor, int previousStreak, CancellationToken cancellationToken = default); + + Task EmitAchievementEventAsync( + User actor, + string achievementId, + AchievementCategory category, + CancellationToken cancellationToken = default); +} diff --git a/src/Orbit.Application/Social/Services/SocialAccessGuard.cs b/src/Orbit.Application/Social/Services/SocialAccessGuard.cs new file mode 100644 index 00000000..99f6daae --- /dev/null +++ b/src/Orbit.Application/Social/Services/SocialAccessGuard.cs @@ -0,0 +1,30 @@ +using Orbit.Application.Common; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Social.Services; + +/// +/// Loads the calling user and enforces the social opt-in gate shared by every social handler. On +/// success returns the tracked so the caller can mutate it without re-loading; on +/// a disabled account returns SOCIAL_DISABLED (403). The two profile toggles bypass this guard so they +/// remain reachable while social is off. +/// +public class SocialAccessGuard(IGenericRepository userRepository) +{ + public async Task> EnsureEnabledAsync(Guid userId, CancellationToken cancellationToken) + { + var user = await userRepository.FindOneTrackedAsync( + u => u.Id == userId, + cancellationToken: cancellationToken); + + if (user is null) + return Result.Failure(ErrorMessages.UserNotFound); + + if (!user.SocialOptIn) + return Result.Failure(ErrorMessages.SocialDisabled); + + return Result.Success(user); + } +} diff --git a/src/Orbit.Application/Social/Validators/AcceptFriendRequestCommandValidator.cs b/src/Orbit.Application/Social/Validators/AcceptFriendRequestCommandValidator.cs new file mode 100644 index 00000000..72338f7a --- /dev/null +++ b/src/Orbit.Application/Social/Validators/AcceptFriendRequestCommandValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; +using Orbit.Application.Social.Commands; + +namespace Orbit.Application.Social.Validators; + +public class AcceptFriendRequestCommandValidator : AbstractValidator +{ + public AcceptFriendRequestCommandValidator() + { + RuleFor(x => x.UserId).NotEmpty(); + RuleFor(x => x.FriendshipId).NotEmpty(); + } +} diff --git a/src/Orbit.Application/Social/Validators/BlockUserCommandValidator.cs b/src/Orbit.Application/Social/Validators/BlockUserCommandValidator.cs new file mode 100644 index 00000000..b90184b7 --- /dev/null +++ b/src/Orbit.Application/Social/Validators/BlockUserCommandValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; +using Orbit.Application.Social.Commands; + +namespace Orbit.Application.Social.Validators; + +public class BlockUserCommandValidator : AbstractValidator +{ + public BlockUserCommandValidator() + { + RuleFor(x => x.UserId).NotEmpty(); + RuleFor(x => x.BlockedUserId).NotEmpty().NotEqual(x => x.UserId); + } +} diff --git a/src/Orbit.Application/Social/Validators/GetCheersQueryValidator.cs b/src/Orbit.Application/Social/Validators/GetCheersQueryValidator.cs new file mode 100644 index 00000000..373729f9 --- /dev/null +++ b/src/Orbit.Application/Social/Validators/GetCheersQueryValidator.cs @@ -0,0 +1,17 @@ +using FluentValidation; +using Orbit.Application.Social.Queries; + +namespace Orbit.Application.Social.Validators; + +public class GetCheersQueryValidator : AbstractValidator +{ + private static readonly string[] AllowedDirections = ["received", "sent"]; + + public GetCheersQueryValidator() + { + RuleFor(x => x.UserId).NotEmpty(); + RuleFor(x => x.Direction) + .Must(direction => AllowedDirections.Contains(direction)) + .WithMessage("Direction must be 'received' or 'sent'."); + } +} diff --git a/src/Orbit.Application/Social/Validators/GetFriendFeedQueryValidator.cs b/src/Orbit.Application/Social/Validators/GetFriendFeedQueryValidator.cs new file mode 100644 index 00000000..3a8d68d7 --- /dev/null +++ b/src/Orbit.Application/Social/Validators/GetFriendFeedQueryValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using Orbit.Application.Common; +using Orbit.Application.Social.Queries; + +namespace Orbit.Application.Social.Validators; + +public class GetFriendFeedQueryValidator : AbstractValidator +{ + public GetFriendFeedQueryValidator() + { + RuleFor(x => x.UserId).NotEmpty(); + + When(x => x.PageSize.HasValue, () => + RuleFor(x => x.PageSize!.Value).InclusiveBetween(1, AppConstants.MaxFriendFeedPageSize)); + + When(x => !string.IsNullOrEmpty(x.Cursor), () => + RuleFor(x => x.Cursor!) + .Must(cursor => FeedCursor.TryDecode(cursor, out _, out _)) + .WithMessage("Cursor is malformed.")); + } +} diff --git a/src/Orbit.Application/Social/Validators/GetFriendsQueryValidator.cs b/src/Orbit.Application/Social/Validators/GetFriendsQueryValidator.cs new file mode 100644 index 00000000..c9bcdb46 --- /dev/null +++ b/src/Orbit.Application/Social/Validators/GetFriendsQueryValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; +using Orbit.Application.Social.Queries; + +namespace Orbit.Application.Social.Validators; + +public class GetFriendsQueryValidator : AbstractValidator +{ + public GetFriendsQueryValidator() + { + RuleFor(x => x.UserId).NotEmpty(); + } +} diff --git a/src/Orbit.Application/Social/Validators/RemoveFriendCommandValidator.cs b/src/Orbit.Application/Social/Validators/RemoveFriendCommandValidator.cs new file mode 100644 index 00000000..857de425 --- /dev/null +++ b/src/Orbit.Application/Social/Validators/RemoveFriendCommandValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; +using Orbit.Application.Social.Commands; + +namespace Orbit.Application.Social.Validators; + +public class RemoveFriendCommandValidator : AbstractValidator +{ + public RemoveFriendCommandValidator() + { + RuleFor(x => x.UserId).NotEmpty(); + RuleFor(x => x.FriendUserId).NotEmpty().NotEqual(x => x.UserId); + } +} diff --git a/src/Orbit.Application/Social/Validators/ReportUserCommandValidator.cs b/src/Orbit.Application/Social/Validators/ReportUserCommandValidator.cs new file mode 100644 index 00000000..d25d4373 --- /dev/null +++ b/src/Orbit.Application/Social/Validators/ReportUserCommandValidator.cs @@ -0,0 +1,16 @@ +using FluentValidation; +using Orbit.Application.Common; +using Orbit.Application.Social.Commands; + +namespace Orbit.Application.Social.Validators; + +public class ReportUserCommandValidator : AbstractValidator +{ + public ReportUserCommandValidator() + { + RuleFor(x => x.UserId).NotEmpty(); + RuleFor(x => x.ReportedUserId).NotEmpty().NotEqual(x => x.UserId); + RuleFor(x => x.Reason).IsInEnum(); + RuleFor(x => x.Details).MaximumLength(AppConstants.MaxReportDetailsLength); + } +} diff --git a/src/Orbit.Application/Social/Validators/SendCheerCommandValidator.cs b/src/Orbit.Application/Social/Validators/SendCheerCommandValidator.cs new file mode 100644 index 00000000..93e8666e --- /dev/null +++ b/src/Orbit.Application/Social/Validators/SendCheerCommandValidator.cs @@ -0,0 +1,16 @@ +using FluentValidation; +using Orbit.Application.Common; +using Orbit.Application.Social.Commands; + +namespace Orbit.Application.Social.Validators; + +public class SendCheerCommandValidator : AbstractValidator +{ + public SendCheerCommandValidator() + { + RuleFor(x => x.UserId).NotEmpty(); + RuleFor(x => x.RecipientId).NotEmpty().NotEqual(x => x.UserId); + RuleFor(x => x.HabitId).NotEmpty(); + RuleFor(x => x.Note).MaximumLength(AppConstants.MaxCheerNoteLength); + } +} diff --git a/src/Orbit.Application/Social/Validators/SendFriendRequestCommandValidator.cs b/src/Orbit.Application/Social/Validators/SendFriendRequestCommandValidator.cs new file mode 100644 index 00000000..48b2f3d7 --- /dev/null +++ b/src/Orbit.Application/Social/Validators/SendFriendRequestCommandValidator.cs @@ -0,0 +1,23 @@ +using FluentValidation; +using Orbit.Application.Common; +using Orbit.Application.Social.Commands; + +namespace Orbit.Application.Social.Validators; + +public class SendFriendRequestCommandValidator : AbstractValidator +{ + public SendFriendRequestCommandValidator() + { + RuleFor(x => x.UserId).NotEmpty(); + + RuleFor(x => x.Handle).MaximumLength(AppConstants.HandleMaxLength); + RuleFor(x => x.ReferralCode).MaximumLength(64); + + RuleFor(x => x) + .Must(HaveExactlyOneIdentifier) + .WithMessage("Provide exactly one of handle or referralCode."); + } + + private static bool HaveExactlyOneIdentifier(SendFriendRequestCommand command) => + !string.IsNullOrWhiteSpace(command.Handle) ^ !string.IsNullOrWhiteSpace(command.ReferralCode); +} diff --git a/src/Orbit.Application/Social/Validators/UnblockUserCommandValidator.cs b/src/Orbit.Application/Social/Validators/UnblockUserCommandValidator.cs new file mode 100644 index 00000000..f3fb7e06 --- /dev/null +++ b/src/Orbit.Application/Social/Validators/UnblockUserCommandValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; +using Orbit.Application.Social.Commands; + +namespace Orbit.Application.Social.Validators; + +public class UnblockUserCommandValidator : AbstractValidator +{ + public UnblockUserCommandValidator() + { + RuleFor(x => x.UserId).NotEmpty(); + RuleFor(x => x.BlockedUserId).NotEmpty().NotEqual(x => x.UserId); + } +} diff --git a/src/Orbit.Domain/Common/DomainConstants.cs b/src/Orbit.Domain/Common/DomainConstants.cs index c9747da5..034aa29d 100644 --- a/src/Orbit.Domain/Common/DomainConstants.cs +++ b/src/Orbit.Domain/Common/DomainConstants.cs @@ -7,4 +7,8 @@ public static class DomainConstants public const int MaxReminderMinutesBefore = 10080; public const int MaxHabitEmojiLength = 32; public const int MaxUserNameLength = 50; + public const int HandleMinLength = 3; + public const int HandleMaxLength = 20; + public const int MaxCheerNoteLength = 200; + public const int MaxReportDetailsLength = 500; } diff --git a/src/Orbit.Domain/Common/DomainErrors.cs b/src/Orbit.Domain/Common/DomainErrors.cs index 3a763c69..16c67ab9 100644 --- a/src/Orbit.Domain/Common/DomainErrors.cs +++ b/src/Orbit.Domain/Common/DomainErrors.cs @@ -10,6 +10,14 @@ public static class DomainErrors public static readonly AppError TokenHashRequired = new("TOKEN_HASH_REQUIRED", "Token hash is required."); public static readonly AppError NameRequired = new("NAME_REQUIRED", "Name is required"); + public static readonly AppError InvalidHandle = new("INVALID_HANDLE", "Handle must be 3-20 characters using only letters, numbers, or underscores."); + public static readonly AppError CannotFriendSelf = new("CANNOT_FRIEND_SELF", "You cannot send a friend request to yourself."); + public static readonly AppError FriendshipNotPending = new("FRIENDSHIP_NOT_PENDING", "This friend request is no longer pending."); + public static readonly AppError CannotCheerSelf = new("CANNOT_CHEER_SELF", "You cannot cheer yourself."); + public static readonly AppError CheerNoteTooLong = new("CHEER_NOTE_TOO_LONG", "Cheer note must be at most {0} characters."); + public static readonly AppError CannotBlockSelf = new("CANNOT_BLOCK_SELF", "You cannot block yourself."); + public static readonly AppError CannotReportSelf = new("CANNOT_REPORT_SELF", "You cannot report yourself."); + public static readonly AppError ReportDetailsTooLong = new("REPORT_DETAILS_TOO_LONG", "Report details must be at most {0} characters."); public static readonly AppError NameTooLong = new("NAME_TOO_LONG", "Name must be at most {0} characters"); public static readonly AppError EmailRequired = new("EMAIL_REQUIRED", "Email is required"); public static readonly AppError InvalidEmailFormat = new("INVALID_EMAIL_FORMAT", "Invalid email format"); diff --git a/src/Orbit.Domain/Entities/BlockedUser.cs b/src/Orbit.Domain/Entities/BlockedUser.cs new file mode 100644 index 00000000..db4e02e8 --- /dev/null +++ b/src/Orbit.Domain/Entities/BlockedUser.cs @@ -0,0 +1,25 @@ +using Orbit.Domain.Common; + +namespace Orbit.Domain.Entities; + +public class BlockedUser : Entity +{ + public Guid BlockerId { get; private set; } + public Guid BlockedId { get; private set; } + public DateTime CreatedAtUtc { get; private set; } + + private BlockedUser() { } + + public static Result Create(Guid blockerId, Guid blockedId) + { + if (blockerId == blockedId) + return Result.Failure(DomainErrors.CannotBlockSelf); + + return Result.Success(new BlockedUser + { + BlockerId = blockerId, + BlockedId = blockedId, + CreatedAtUtc = DateTime.UtcNow + }); + } +} diff --git a/src/Orbit.Domain/Entities/Cheer.cs b/src/Orbit.Domain/Entities/Cheer.cs new file mode 100644 index 00000000..d0201eed --- /dev/null +++ b/src/Orbit.Domain/Entities/Cheer.cs @@ -0,0 +1,33 @@ +using Orbit.Domain.Common; + +namespace Orbit.Domain.Entities; + +public class Cheer : Entity +{ + public Guid SenderId { get; private set; } + public Guid RecipientId { get; private set; } + public Guid HabitId { get; private set; } + public string? Note { get; private set; } + public DateTime CreatedAtUtc { get; private set; } + + private Cheer() { } + + public static Result Create(Guid senderId, Guid recipientId, Guid habitId, string? note) + { + if (senderId == recipientId) + return Result.Failure(DomainErrors.CannotCheerSelf); + + var trimmedNote = string.IsNullOrWhiteSpace(note) ? null : note.Trim(); + if (trimmedNote is not null && trimmedNote.Length > DomainConstants.MaxCheerNoteLength) + return Result.Failure(DomainErrors.CheerNoteTooLong.Format(DomainConstants.MaxCheerNoteLength)); + + return Result.Success(new Cheer + { + SenderId = senderId, + RecipientId = recipientId, + HabitId = habitId, + Note = trimmedNote, + CreatedAtUtc = DateTime.UtcNow + }); + } +} diff --git a/src/Orbit.Domain/Entities/FriendFeedEvent.cs b/src/Orbit.Domain/Entities/FriendFeedEvent.cs new file mode 100644 index 00000000..f22f3026 --- /dev/null +++ b/src/Orbit.Domain/Entities/FriendFeedEvent.cs @@ -0,0 +1,50 @@ +using Orbit.Domain.Common; +using Orbit.Domain.Enums; + +namespace Orbit.Domain.Entities; + +/// +/// A single milestone moment in the warm friend feed, written one row per actor at the moment +/// the milestone occurs. The feed query fans these out on read to a caller's accepted, opted-in, +/// non-blocked friends, so there is no per-friend write amplification and new friends retroactively +/// see past events. Inputs come from trusted internal gamification code, so the factories are plain +/// (no validation), mirroring . +/// +public class FriendFeedEvent : Entity +{ + public Guid ActorUserId { get; private set; } + public FriendFeedEventType Type { get; private set; } + public int? Value { get; private set; } + public string? AchievementId { get; private set; } + public DateTime CreatedAtUtc { get; private set; } + + private FriendFeedEvent() { } + + public static FriendFeedEvent StreakMilestone(Guid actorUserId, int streakDays) => + new() + { + ActorUserId = actorUserId, + Type = FriendFeedEventType.StreakMilestone, + Value = streakDays, + CreatedAtUtc = DateTime.UtcNow + }; + + public static FriendFeedEvent AchievementUnlocked(Guid actorUserId, string achievementId) => + new() + { + ActorUserId = actorUserId, + Type = FriendFeedEventType.AchievementUnlocked, + AchievementId = achievementId, + CreatedAtUtc = DateTime.UtcNow + }; + + public static FriendFeedEvent HabitCompletedMilestone(Guid actorUserId, string achievementId, int completions) => + new() + { + ActorUserId = actorUserId, + Type = FriendFeedEventType.HabitCompletedMilestone, + Value = completions, + AchievementId = achievementId, + CreatedAtUtc = DateTime.UtcNow + }; +} diff --git a/src/Orbit.Domain/Entities/Friendship.cs b/src/Orbit.Domain/Entities/Friendship.cs new file mode 100644 index 00000000..0c8d17fd --- /dev/null +++ b/src/Orbit.Domain/Entities/Friendship.cs @@ -0,0 +1,39 @@ +using Orbit.Domain.Common; +using Orbit.Domain.Enums; + +namespace Orbit.Domain.Entities; + +public class Friendship : Entity +{ + public Guid RequesterId { get; private set; } + public Guid AddresseeId { get; private set; } + public FriendshipStatus Status { get; private set; } + public DateTime CreatedAtUtc { get; private set; } + public DateTime? RespondedAtUtc { get; private set; } + + private Friendship() { } + + public static Result Create(Guid requesterId, Guid addresseeId) + { + if (requesterId == addresseeId) + return Result.Failure(DomainErrors.CannotFriendSelf); + + return Result.Success(new Friendship + { + RequesterId = requesterId, + AddresseeId = addresseeId, + Status = FriendshipStatus.Pending, + CreatedAtUtc = DateTime.UtcNow + }); + } + + public Result Accept() + { + if (Status != FriendshipStatus.Pending) + return Result.Failure(DomainErrors.FriendshipNotPending); + + Status = FriendshipStatus.Accepted; + RespondedAtUtc = DateTime.UtcNow; + return Result.Success(); + } +} diff --git a/src/Orbit.Domain/Entities/Report.cs b/src/Orbit.Domain/Entities/Report.cs new file mode 100644 index 00000000..07e063bd --- /dev/null +++ b/src/Orbit.Domain/Entities/Report.cs @@ -0,0 +1,44 @@ +using Orbit.Domain.Common; +using Orbit.Domain.Enums; + +namespace Orbit.Domain.Entities; + +public class Report : Entity +{ + public Guid ReporterId { get; private set; } + public Guid ReportedUserId { get; private set; } + public ReportReason Reason { get; private set; } + public string? Details { get; private set; } + public Guid? CheerId { get; private set; } + public ReportStatus Status { get; private set; } + public DateTime CreatedAtUtc { get; private set; } + public DateTime? ReviewedAtUtc { get; private set; } + + private Report() { } + + public static Result Create( + Guid reporterId, + Guid reportedUserId, + ReportReason reason, + string? details, + Guid? cheerId) + { + if (reporterId == reportedUserId) + return Result.Failure(DomainErrors.CannotReportSelf); + + var trimmedDetails = string.IsNullOrWhiteSpace(details) ? null : details.Trim(); + if (trimmedDetails is not null && trimmedDetails.Length > DomainConstants.MaxReportDetailsLength) + return Result.Failure(DomainErrors.ReportDetailsTooLong.Format(DomainConstants.MaxReportDetailsLength)); + + return Result.Success(new Report + { + ReporterId = reporterId, + ReportedUserId = reportedUserId, + Reason = reason, + Details = trimmedDetails, + CheerId = cheerId, + Status = ReportStatus.Pending, + CreatedAtUtc = DateTime.UtcNow + }); + } +} diff --git a/src/Orbit.Domain/Entities/User.cs b/src/Orbit.Domain/Entities/User.cs index d6c5109e..47daa06f 100644 --- a/src/Orbit.Domain/Entities/User.cs +++ b/src/Orbit.Domain/Entities/User.cs @@ -10,6 +10,9 @@ public partial class User : Entity [GeneratedRegex(@"^[^@\s]+@[^@\s]+\.[^@\s]+$", RegexOptions.IgnoreCase, matchTimeoutMilliseconds: 1000)] private static partial Regex EmailRegex(); + [GeneratedRegex(@"^[A-Za-z0-9_]{3,20}$", RegexOptions.None, matchTimeoutMilliseconds: 1000)] + private static partial Regex HandleRegex(); + public string Name { get; private set; } = null!; public string Email { get; private set; } = null!; public string? TimeZone { get; private set; } @@ -17,6 +20,10 @@ public partial class User : Entity public bool AiSummaryEnabled { get; private set; } = true; public bool HasCompletedOnboarding { get; private set; } = false; public bool HasCompletedTour { get; private set; } = false; + public bool HasCreatedFirstHabit { get; private set; } = false; + public bool HasLoggedFirstHabit { get; private set; } = false; + public bool HasTriedAstra { get; private set; } = false; + public bool HasCompletedOnboardingChecklist { get; private set; } = false; public string? Language { get; private set; } public UserPlan Plan { get; private set; } = UserPlan.Free; public string? StripeCustomerId { get; private set; } @@ -45,6 +52,8 @@ public partial class User : Entity public int WeekStartDay { get; private set; } = 1; public string? ReferralCode { get; private set; } public Guid? ReferredByUserId { get; private set; } + public string? Handle { get; private set; } + public bool SocialOptIn { get; private set; } public int TotalXp { get; private set; } = 0; public int Level { get; private set; } = 1; public string? ReferralCouponId { get; private set; } @@ -191,6 +200,14 @@ public Result SetColorScheme(string? colorScheme) public void CompleteOnboarding() => HasCompletedOnboarding = true; + public void MarkFirstHabitCreated() => HasCreatedFirstHabit = true; + + public void MarkFirstHabitLogged() => HasLoggedFirstHabit = true; + + public void MarkAstraUsed() => HasTriedAstra = true; + + public void CompleteOnboardingChecklist() => HasCompletedOnboardingChecklist = true; + public void CompleteTour() => HasCompletedTour = true; public void ResetTour() => HasCompletedTour = false; @@ -354,6 +371,24 @@ public Result SetWeekStartDay(int day) public void SetReferralCode(string code) => ReferralCode = code; + public Result SetHandle(string handle) + { + if (string.IsNullOrWhiteSpace(handle) || !HandleRegex().IsMatch(handle)) + return Result.Failure(DomainErrors.InvalidHandle); + + Handle = handle; + return Result.Success(); + } + + /// + /// Assigns the deterministic, collision-free default handle (user_ + the first 12 hex of + /// the user's id) for a freshly created account. Bypasses format validation because the result is + /// provably valid (17 chars, alphanumeric + underscore); the same formula backfills existing rows. + /// + public void SeedDefaultHandle() => Handle = $"user_{Id:N}"[..17]; + + public void SetSocialOptIn(bool enabled) => SocialOptIn = enabled; + public void SetReferredBy(Guid referrerUserId) => ReferredByUserId = referrerUserId; public void ExtendTrial(int days) @@ -374,7 +409,7 @@ public void AddXp(int amount) public void SetLevel(int level) { - if (level < 1 || level > 10) return; + if (level < 1) return; Level = level; } diff --git a/src/Orbit.Domain/Enums/FriendFeedEventType.cs b/src/Orbit.Domain/Enums/FriendFeedEventType.cs new file mode 100644 index 00000000..6d851d95 --- /dev/null +++ b/src/Orbit.Domain/Enums/FriendFeedEventType.cs @@ -0,0 +1,8 @@ +namespace Orbit.Domain.Enums; + +public enum FriendFeedEventType +{ + StreakMilestone, + AchievementUnlocked, + HabitCompletedMilestone +} diff --git a/src/Orbit.Domain/Enums/FriendshipStatus.cs b/src/Orbit.Domain/Enums/FriendshipStatus.cs new file mode 100644 index 00000000..8ff8684c --- /dev/null +++ b/src/Orbit.Domain/Enums/FriendshipStatus.cs @@ -0,0 +1,7 @@ +namespace Orbit.Domain.Enums; + +public enum FriendshipStatus +{ + Pending, + Accepted +} diff --git a/src/Orbit.Domain/Enums/OnboardingChecklistSignal.cs b/src/Orbit.Domain/Enums/OnboardingChecklistSignal.cs new file mode 100644 index 00000000..99021dfc --- /dev/null +++ b/src/Orbit.Domain/Enums/OnboardingChecklistSignal.cs @@ -0,0 +1,8 @@ +namespace Orbit.Domain.Enums; + +public enum OnboardingChecklistSignal +{ + HabitCreated, + HabitLogged, + AstraUsed +} diff --git a/src/Orbit.Domain/Enums/ReportReason.cs b/src/Orbit.Domain/Enums/ReportReason.cs new file mode 100644 index 00000000..8afcc5c4 --- /dev/null +++ b/src/Orbit.Domain/Enums/ReportReason.cs @@ -0,0 +1,10 @@ +namespace Orbit.Domain.Enums; + +public enum ReportReason +{ + Spam, + Harassment, + InappropriateContent, + Impersonation, + Other +} diff --git a/src/Orbit.Domain/Enums/ReportStatus.cs b/src/Orbit.Domain/Enums/ReportStatus.cs new file mode 100644 index 00000000..3b725df9 --- /dev/null +++ b/src/Orbit.Domain/Enums/ReportStatus.cs @@ -0,0 +1,8 @@ +namespace Orbit.Domain.Enums; + +public enum ReportStatus +{ + Pending, + Reviewed, + Dismissed +} diff --git a/src/Orbit.Domain/Interfaces/IContentModerationService.cs b/src/Orbit.Domain/Interfaces/IContentModerationService.cs new file mode 100644 index 00000000..654b8733 --- /dev/null +++ b/src/Orbit.Domain/Interfaces/IContentModerationService.cs @@ -0,0 +1,17 @@ +namespace Orbit.Domain.Interfaces; + +/// +/// Screens free-text (currently cheer notes) before it is persisted. Implementations MUST NOT throw: +/// a transport/timeout/non-success outcome is surfaced as +/// so callers can fail open, while a definitive provider flag sets . +/// +public interface IContentModerationService +{ + Task CheckTextAsync(string text, CancellationToken cancellationToken = default); +} + +/// +/// Outcome of a moderation check. is a definitive provider rejection; +/// means the check could not be completed (callers fail open). +/// +public sealed record ModerationResult(bool Flagged, bool Unavailable, IReadOnlyList Categories); diff --git a/src/Orbit.Domain/Interfaces/IFriendFeedReader.cs b/src/Orbit.Domain/Interfaces/IFriendFeedReader.cs new file mode 100644 index 00000000..5a3ca06d --- /dev/null +++ b/src/Orbit.Domain/Interfaces/IFriendFeedReader.cs @@ -0,0 +1,20 @@ +using Orbit.Domain.Entities; + +namespace Orbit.Domain.Interfaces; + +/// +/// Keyset-paginated read over for a fixed set of actor ids, ordered +/// newest-first by (CreatedAtUtc, Id). The cursor is the last row of the previous page; passing null +/// reads the first page. Returns at most rows (callers fetch one extra to +/// detect "has next"). Set-based and friend-count-independent — the actor join for display fields is +/// done by the caller against its already-loaded friend set, so there is no N+1. +/// +public interface IFriendFeedReader +{ + Task> ReadFeedPageAsync( + IReadOnlyCollection actorUserIds, + DateTime? cursorCreatedAtUtc, + Guid? cursorId, + int limit, + CancellationToken cancellationToken = default); +} diff --git a/src/Orbit.Domain/Interfaces/IGamificationService.cs b/src/Orbit.Domain/Interfaces/IGamificationService.cs index 540f4681..f93dbffd 100644 --- a/src/Orbit.Domain/Interfaces/IGamificationService.cs +++ b/src/Orbit.Domain/Interfaces/IGamificationService.cs @@ -1,3 +1,5 @@ +using Orbit.Domain.Enums; + namespace Orbit.Domain.Interfaces; public record HabitLogGamificationResult(int XpEarned, IReadOnlyList NewAchievementIds); @@ -9,4 +11,5 @@ public interface IGamificationService Task ProcessHabitCreated(Guid userId, CancellationToken ct = default); Task ProcessGoalCreated(Guid userId, CancellationToken ct = default); Task ProcessGoalCompleted(Guid userId, CancellationToken ct = default); + Task ProcessOnboardingChecklistAsync(Guid userId, OnboardingChecklistSignal signal, CancellationToken ct = default); } diff --git a/src/Orbit.Domain/Models/AgentContracts.cs b/src/Orbit.Domain/Models/AgentContracts.cs index 283956d9..f43b7664 100644 --- a/src/Orbit.Domain/Models/AgentContracts.cs +++ b/src/Orbit.Domain/Models/AgentContracts.cs @@ -282,6 +282,7 @@ public static class AgentCapabilityIds public const string SyncWrite = "sync.write"; public const string AccountManage = "account.manage"; public const string AuthManage = "auth.manage"; + public const string SocialManage = "social.manage"; public const string MediaUpload = "media.upload"; } @@ -324,6 +325,7 @@ public static class AgentScopes public const string WriteSync = "write_sync"; public const string ManageAccount = "manage_account"; public const string ManageAuth = "manage_auth"; + public const string ManageSocial = "manage_social"; public const string UploadMedia = "upload_media"; public static readonly IReadOnlySet All = new HashSet(StringComparer.OrdinalIgnoreCase) @@ -365,6 +367,7 @@ public static class AgentScopes WriteSync, ManageAccount, ManageAuth, + ManageSocial, UploadMedia }; diff --git a/src/Orbit.Infrastructure/AI/ContentModerationService.cs b/src/Orbit.Infrastructure/AI/ContentModerationService.cs new file mode 100644 index 00000000..f242b09c --- /dev/null +++ b/src/Orbit.Infrastructure/AI/ContentModerationService.cs @@ -0,0 +1,87 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Orbit.Domain.Interfaces; +using Orbit.Infrastructure.Configuration; + +namespace Orbit.Infrastructure.AI; + +/// +/// Screens text via the OpenAI moderation endpoint (free, not metered), reusing the existing AI +/// credential. The OpenAI SDK exposes no moderation client, so this calls the REST endpoint directly. +/// Never throws: any transport, timeout, non-success, or parse failure is surfaced as +/// so the caller can fail open, while a definitive provider +/// decision sets . +/// +public partial class ContentModerationService( + HttpClient httpClient, + IOptions aiSettings, + ILogger logger) : IContentModerationService +{ + private const string ModerationModel = "omni-moderation-latest"; + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); + + public async Task CheckTextAsync(string text, CancellationToken cancellationToken = default) + { + var settings = aiSettings.Value; + + try + { + using var request = new HttpRequestMessage(HttpMethod.Post, $"{settings.BaseUrl.TrimEnd('/')}/moderations") + { + Content = JsonContent.Create(new ModerationRequest(ModerationModel, text), options: SerializerOptions) + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", settings.ApiKey); + + using var response = await httpClient.SendAsync(request, cancellationToken); + if (!response.IsSuccessStatusCode) + { + LogModerationUnavailable(logger, (int)response.StatusCode); + return Unavailable; + } + + var payload = await response.Content.ReadFromJsonAsync(SerializerOptions, cancellationToken); + var result = payload?.Results?.FirstOrDefault(); + if (result is null) + return Unavailable; + + var flaggedCategories = result.Categories? + .Where(category => category.Value) + .Select(category => category.Key) + .ToList() ?? []; + + return new ModerationResult(result.Flagged, Unavailable: false, flaggedCategories); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) when (exception is HttpRequestException or OperationCanceledException or JsonException) + { + LogModerationFailed(logger, exception); + return Unavailable; + } + } + + private static ModerationResult Unavailable => new(false, true, []); + + private sealed record ModerationRequest( + [property: JsonPropertyName("model")] string Model, + [property: JsonPropertyName("input")] string Input); + + private sealed record ModerationResponse( + [property: JsonPropertyName("results")] IReadOnlyList? Results); + + private sealed record ModerationResultPayload( + [property: JsonPropertyName("flagged")] bool Flagged, + [property: JsonPropertyName("categories")] Dictionary? Categories); + + [LoggerMessage(EventId = 1, Level = LogLevel.Warning, Message = "Content moderation returned non-success status {StatusCode}; treating as unavailable")] + private static partial void LogModerationUnavailable(ILogger logger, int statusCode); + + [LoggerMessage(EventId = 2, Level = LogLevel.Warning, Message = "Content moderation call failed; treating as unavailable")] + private static partial void LogModerationFailed(ILogger logger, Exception exception); +} diff --git a/src/Orbit.Infrastructure/Migrations/20260627021548_SeedGamificationFreeTierFlag.Designer.cs b/src/Orbit.Infrastructure/Migrations/20260627021548_SeedGamificationFreeTierFlag.Designer.cs new file mode 100644 index 00000000..6dfc88ca --- /dev/null +++ b/src/Orbit.Infrastructure/Migrations/20260627021548_SeedGamificationFreeTierFlag.Designer.cs @@ -0,0 +1,1779 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Orbit.Infrastructure.Persistence; + +#nullable disable + +namespace Orbit.Infrastructure.Migrations +{ + [DbContext(typeof(OrbitDbContext))] + [Migration("20260627021548_SeedGamificationFreeTierFlag")] + partial class SeedGamificationFreeTierFlag + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("HabitGoals", b => + { + b.Property("GoalId") + .HasColumnType("uuid"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.HasKey("GoalId", "HabitId"); + + b.HasIndex("HabitId"); + + b.ToTable("HabitGoals"); + }); + + modelBuilder.Entity("HabitTags", b => + { + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("TagId") + .HasColumnType("uuid"); + + b.HasKey("HabitId", "TagId"); + + b.HasIndex("TagId"); + + b.ToTable("HabitTags"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AgentAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthMethod") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CapabilityId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CorrelationId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Error") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("OutcomeStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("PolicyDecision") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RedactedArguments") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RiskClass") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ShadowPolicyDecision") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ShadowReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("SourceName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Summary") + .HasColumnType("text"); + + b.Property("Surface") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TargetId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TargetName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CapabilityId", "CreatedAtUtc"); + + b.HasIndex("UserId", "CreatedAtUtc"); + + b.ToTable("AgentAuditLogs"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AgentStepUpChallengeState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttemptCount") + .HasColumnType("integer"); + + b.Property("CodeHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PendingOperationId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("VerifiedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PendingOperationId", "CreatedAtUtc"); + + b.ToTable("AgentStepUpChallenges"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AiFactExtractionBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BatchId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("InputFileId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("OutputFileId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("UserId"); + + b.ToTable("AiFactExtractionBatches"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsReadOnly") + .HasColumnType("boolean"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("KeyHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("KeyPrefix") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("character varying(12)"); + + b.Property("LastUsedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Scopes") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("KeyPrefix"); + + b.HasIndex("UserId"); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AppConfig", b => + { + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Key"); + + b.ToTable("AppConfigs"); + + b.HasData( + new + { + Key = "MaxUserFacts", + Description = "Maximum number of facts the AI can remember per user", + Value = "50" + }, + new + { + Key = "MaxHabitDepth", + Description = "Maximum nesting depth for sub-habits", + Value = "5" + }, + new + { + Key = "MaxTagsPerHabit", + Description = "Maximum number of tags per habit", + Value = "5" + }, + new + { + Key = "ReferralRewardDays", + Description = "Days of Pro added per successful referral", + Value = "10" + }, + new + { + Key = "MaxReferrals", + Description = "Maximum successful referrals per user", + Value = "10" + }, + new + { + Key = "MinSupportedVersion", + Description = "Minimum supported client app version; clients below this receive HTTP 426", + Value = "0.0.0" + }); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AppFeatureFlag", b => + { + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("PlanRequirement") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Key"); + + b.ToTable("AppFeatureFlags"); + + b.HasData( + new + { + Key = "offline_mode", + Description = "Enable offline mode with background sync", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "ai_chat", + Description = "AI chat assistant", + Enabled = true, + PlanRequirement = "Free", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "ai_summary", + Description = "AI daily summary", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "ai_retrospective", + Description = "AI retrospective analysis", + Enabled = true, + PlanRequirement = "YearlyPro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "sub_habits", + Description = "Sub-habit nesting", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "goal_tracking", + Description = "Goal tracking with progress", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "push_notifications", + Description = "Push notification reminders", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "scheduled_reminders", + Description = "Custom scheduled reminders", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "slip_alerts", + Description = "Slip detection alerts", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "checklist_templates", + Description = "Reusable checklist templates", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "habit_duplication", + Description = "Duplicate habits", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "bulk_operations", + Description = "Bulk create/delete/log habits", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "calendar_integration", + Description = "Google Calendar integration", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "api_keys", + Description = "Personal API keys", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ChecklistTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Items") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "IsDeleted"); + + b.ToTable("ChecklistTemplates"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ContentBlock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Key", "Locale") + .IsUnique(); + + b.ToTable("ContentBlocks"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.DistributedRateLimitBucket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Count") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PartitionKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PolicyName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WindowEndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WindowStartUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PolicyName", "PartitionKey", "WindowStartUtc") + .IsUnique(); + + b.ToTable("DistributedRateLimitBuckets"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Goal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentValue") + .HasColumnType("numeric"); + + b.Property("Deadline") + .HasColumnType("date"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("StreakSyncedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TargetValue") + .HasColumnType("numeric"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("Unit") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "IsDeleted"); + + b.ToTable("Goals"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.GoalProgressLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GoalId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("PreviousValue") + .HasColumnType("numeric"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("GoalId"); + + b.HasIndex("GoalId", "IsDeleted"); + + b.ToTable("GoalProgressLogs"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.GoogleCalendarSyncSuggestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DiscoveredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DismissedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GoogleEventId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ImportedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ImportedHabitId") + .HasColumnType("uuid"); + + b.Property("RawEventJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartDateUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "GoogleEventId") + .IsUnique(); + + b.HasIndex("UserId", "DismissedAtUtc", "ImportedAtUtc"); + + b.ToTable("GoogleCalendarSyncSuggestions"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Habit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChecklistItems") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Days") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DueDate") + .HasColumnType("date"); + + b.Property("DueEndTime") + .HasColumnType("time without time zone"); + + b.Property("DueTime") + .HasColumnType("time without time zone"); + + b.Property("Emoji") + .HasColumnType("text"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("FrequencyQuantity") + .HasColumnType("integer"); + + b.Property("FrequencyUnit") + .HasColumnType("integer"); + + b.Property("GoogleEventId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsBadHabit") + .HasColumnType("boolean"); + + b.Property("IsCompleted") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsFlexible") + .HasColumnType("boolean"); + + b.Property("IsGeneral") + .HasColumnType("boolean"); + + b.Property("OriginalDayOfMonth") + .HasColumnType("integer"); + + b.Property("ParentHabitId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ReminderEnabled") + .HasColumnType("boolean"); + + b.Property("ReminderTimes") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[15]'::jsonb"); + + b.Property("ScheduledReminders") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("SlipAlertEnabled") + .HasColumnType("boolean"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ParentHabitId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "GoogleEventId") + .IsUnique() + .HasFilter("\"GoogleEventId\" IS NOT NULL AND \"IsDeleted\" = FALSE"); + + b.HasIndex("UserId", "IsDeleted"); + + b.ToTable("Habits"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.HabitLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "HabitId", "Date" }, "IX_HabitLogs_HabitId_Date"); + + b.HasIndex(new[] { "HabitId", "Date" }, "IX_HabitLogs_HabitId_Date_Completed") + .IsUnique() + .HasFilter("\"Value\" > 0 AND NOT \"IsDeleted\""); + + b.ToTable("HabitLogs"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsRead") + .HasColumnType("boolean"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Url") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Url") + .HasFilter("\"Url\" IS NOT NULL"); + + b.HasIndex("UserId", "CreatedAtUtc") + .IsDescending(false, true); + + b.HasIndex("UserId", "IsDeleted"); + + b.HasIndex("UserId", "IsRead"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.PendingAgentOperationState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ArgumentsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CapabilityId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfirmationRequirement") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ConfirmationTokenHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ConfirmedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OperationFingerprint") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OperationId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RiskClass") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("StepUpSatisfiedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Surface") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CapabilityId"); + + b.HasIndex("UserId", "OperationFingerprint"); + + b.ToTable("PendingAgentOperations"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.PendingClarification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MissingArgumentKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PartialArgumentsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("QuickActionsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ResolvedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ToolName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("UserId", "CreatedAtUtc"); + + b.ToTable("PendingClarifications"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ProcessedPlayNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MessageId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ProcessedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("MessageId") + .IsUnique(); + + b.ToTable("ProcessedPlayNotifications"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ProcessedStripeEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EventId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ProcessedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("EventId") + .IsUnique(); + + b.ToTable("ProcessedStripeEvents"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.PushSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Auth") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Endpoint") + .IsRequired() + .HasColumnType("text"); + + b.Property("P256dh") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Endpoint") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("PushSubscriptions"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Referral", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferredUserId") + .HasColumnType("uuid"); + + b.Property("ReferrerId") + .HasColumnType("uuid"); + + b.Property("RewardGrantedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("ReferredUserId") + .IsUnique(); + + b.HasIndex("ReferrerId"); + + b.ToTable("Referrals"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.SentReminder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("MinutesBefore") + .HasColumnType("integer"); + + b.Property("ReminderTimeUtc") + .HasColumnType("time without time zone"); + + b.Property("SentAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("When") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("HabitId", "Date", "MinutesBefore", "ReminderTimeUtc", "When") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("HabitId", "Date", "MinutesBefore", "ReminderTimeUtc", "When"), false); + + b.ToTable("SentReminders"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.SentSlipAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("SentAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WeekStart") + .HasColumnType("date"); + + b.HasKey("Id"); + + b.HasIndex("HabitId", "WeekStart") + .IsUnique(); + + b.ToTable("SentSlipAlerts"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.SentStreakFreezeAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("FrozenDate") + .HasColumnType("date"); + + b.Property("SentAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "FrozenDate") + .IsUnique(); + + b.ToTable("SentStreakFreezeAlerts"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.StreakFreeze", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UsedOnDate") + .HasColumnType("date"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "UsedOnDate") + .IsUnique(); + + b.ToTable("StreakFreezes"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Color") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "IsDeleted"); + + b.HasIndex("UserId", "Name") + .IsUnique(); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AdRewardBonusMessages") + .HasColumnType("integer"); + + b.Property("AdRewardsClaimedToday") + .HasColumnType("integer"); + + b.Property("AiMemoryEnabled") + .HasColumnType("boolean"); + + b.Property("AiMessagesResetAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AiMessagesUsedThisMonth") + .HasColumnType("integer"); + + b.Property("AiSummaryEnabled") + .HasColumnType("boolean"); + + b.Property("ColorScheme") + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentStreak") + .HasColumnType("integer"); + + b.Property("DeactivatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("GoogleAccessToken") + .HasColumnType("text"); + + b.Property("GoogleCalendarAutoSyncEnabled") + .HasColumnType("boolean"); + + b.Property("GoogleCalendarAutoSyncStatus") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("GoogleCalendarLastSyncError") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("GoogleCalendarLastSyncedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GoogleCalendarSelectedIds") + .HasColumnType("text"); + + b.Property("GoogleCalendarSyncReconciledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GoogleRefreshToken") + .HasColumnType("text"); + + b.Property("HasCompletedOnboarding") + .HasColumnType("boolean"); + + b.Property("HasCompletedTour") + .HasColumnType("boolean"); + + b.Property("HasImportedCalendar") + .HasColumnType("boolean"); + + b.Property("IsDeactivated") + .HasColumnType("boolean"); + + b.Property("IsLifetimePro") + .HasColumnType("boolean"); + + b.Property("Language") + .HasColumnType("text"); + + b.Property("LastActiveDate") + .HasColumnType("date"); + + b.Property("LastAdRewardAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastAdRewardLocalDate") + .HasColumnType("date"); + + b.Property("LastFreezeAwardStreak") + .HasColumnType("integer"); + + b.Property("Level") + .HasColumnType("integer"); + + b.Property("LongestStreak") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Plan") + .HasColumnType("integer"); + + b.Property("PlanExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayPurchaseToken") + .HasColumnType("text"); + + b.Property("ReferralCode") + .HasColumnType("text"); + + b.Property("ReferralCouponId") + .HasColumnType("text"); + + b.Property("ReferredByUserId") + .HasColumnType("uuid"); + + b.Property("ScheduledDeletionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("StreakFreezesAccumulated") + .HasColumnType("integer"); + + b.Property("StripeCustomerId") + .HasColumnType("text"); + + b.Property("StripeSubscriptionId") + .HasColumnType("text"); + + b.Property("SubscriptionInterval") + .HasColumnType("integer"); + + b.Property("SubscriptionSource") + .HasColumnType("integer"); + + b.Property("ThemePreference") + .HasColumnType("text"); + + b.Property("TimeZone") + .HasColumnType("text"); + + b.Property("TotalXp") + .HasColumnType("integer"); + + b.Property("TrialEndsAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WeekStartDay") + .HasColumnType("integer"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("PlayPurchaseToken") + .IsUnique() + .HasFilter("\"PlayPurchaseToken\" IS NOT NULL"); + + b.HasIndex("ReferralCode") + .IsUnique() + .HasFilter("\"ReferralCode\" IS NOT NULL"); + + b.HasIndex("GoogleCalendarAutoSyncEnabled", "GoogleCalendarLastSyncedAt") + .HasFilter("\"GoogleCalendarAutoSyncEnabled\" = TRUE"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.UserAchievement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AchievementId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EarnedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "AchievementId") + .IsUnique(); + + b.ToTable("UserAchievements"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.UserFact", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Category") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExtractedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FactText") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "IsDeleted"); + + b.ToTable("UserFacts"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUsedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("UserSessions"); + }); + + modelBuilder.Entity("HabitGoals", b => + { + b.HasOne("Orbit.Domain.Entities.Goal", null) + .WithMany() + .HasForeignKey("GoalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany() + .HasForeignKey("HabitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("HabitTags", b => + { + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany() + .HasForeignKey("HabitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AiFactExtractionBatch", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ApiKey", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ChecklistTemplate", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.GoalProgressLog", b => + { + b.HasOne("Orbit.Domain.Entities.Goal", null) + .WithMany("ProgressLogs") + .HasForeignKey("GoalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.GoogleCalendarSyncSuggestion", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Habit", b => + { + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany("Children") + .HasForeignKey("ParentHabitId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.HabitLog", b => + { + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany("Logs") + .HasForeignKey("HabitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.PendingClarification", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.PushSubscription", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.SentStreakFreezeAlert", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.StreakFreeze", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.UserSession", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Goal", b => + { + b.Navigation("ProgressLogs"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Habit", b => + { + b.Navigation("Children"); + + b.Navigation("Logs"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Orbit.Infrastructure/Migrations/20260627021548_SeedGamificationFreeTierFlag.cs b/src/Orbit.Infrastructure/Migrations/20260627021548_SeedGamificationFreeTierFlag.cs new file mode 100644 index 00000000..5f91b13f --- /dev/null +++ b/src/Orbit.Infrastructure/Migrations/20260627021548_SeedGamificationFreeTierFlag.cs @@ -0,0 +1,36 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Orbit.Infrastructure.Migrations +{ + /// + public partial class SeedGamificationFreeTierFlag : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.InsertData( + table: "AppFeatureFlags", + columns: new[] { "Key", "Description", "Enabled", "PlanRequirement", "UpdatedAtUtc" }, + values: new object[] + { + "gamification_free_tier", + "Unlocks the free gamification tier (streak, XP, level, streak-freeze auto-apply) for non-Pro users when enabled", + false, + null, + new DateTime(2026, 6, 27, 0, 0, 0, 0, DateTimeKind.Utc) + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DeleteData( + table: "AppFeatureFlags", + keyColumn: "Key", + keyValue: "gamification_free_tier"); + } + } +} diff --git a/src/Orbit.Infrastructure/Migrations/20260627043108_AddSocialFoundation.Designer.cs b/src/Orbit.Infrastructure/Migrations/20260627043108_AddSocialFoundation.Designer.cs new file mode 100644 index 00000000..fb5fc8a3 --- /dev/null +++ b/src/Orbit.Infrastructure/Migrations/20260627043108_AddSocialFoundation.Designer.cs @@ -0,0 +1,2044 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Orbit.Infrastructure.Persistence; + +#nullable disable + +namespace Orbit.Infrastructure.Migrations +{ + [DbContext(typeof(OrbitDbContext))] + [Migration("20260627043108_AddSocialFoundation")] + partial class AddSocialFoundation + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("HabitGoals", b => + { + b.Property("GoalId") + .HasColumnType("uuid"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.HasKey("GoalId", "HabitId"); + + b.HasIndex("HabitId"); + + b.ToTable("HabitGoals"); + }); + + modelBuilder.Entity("HabitTags", b => + { + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("TagId") + .HasColumnType("uuid"); + + b.HasKey("HabitId", "TagId"); + + b.HasIndex("TagId"); + + b.ToTable("HabitTags"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AgentAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthMethod") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CapabilityId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CorrelationId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Error") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("OutcomeStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("PolicyDecision") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RedactedArguments") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RiskClass") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ShadowPolicyDecision") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ShadowReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("SourceName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Summary") + .HasColumnType("text"); + + b.Property("Surface") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TargetId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TargetName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CapabilityId", "CreatedAtUtc"); + + b.HasIndex("UserId", "CreatedAtUtc"); + + b.ToTable("AgentAuditLogs"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AgentStepUpChallengeState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttemptCount") + .HasColumnType("integer"); + + b.Property("CodeHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PendingOperationId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("VerifiedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PendingOperationId", "CreatedAtUtc"); + + b.ToTable("AgentStepUpChallenges"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AiFactExtractionBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BatchId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("InputFileId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("OutputFileId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("UserId"); + + b.ToTable("AiFactExtractionBatches"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsReadOnly") + .HasColumnType("boolean"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("KeyHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("KeyPrefix") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("character varying(12)"); + + b.Property("LastUsedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Scopes") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("KeyPrefix"); + + b.HasIndex("UserId"); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AppConfig", b => + { + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Key"); + + b.ToTable("AppConfigs"); + + b.HasData( + new + { + Key = "MaxUserFacts", + Description = "Maximum number of facts the AI can remember per user", + Value = "50" + }, + new + { + Key = "MaxHabitDepth", + Description = "Maximum nesting depth for sub-habits", + Value = "5" + }, + new + { + Key = "MaxTagsPerHabit", + Description = "Maximum number of tags per habit", + Value = "5" + }, + new + { + Key = "ReferralRewardDays", + Description = "Days of Pro added per successful referral", + Value = "10" + }, + new + { + Key = "MaxReferrals", + Description = "Maximum successful referrals per user", + Value = "10" + }, + new + { + Key = "MinSupportedVersion", + Description = "Minimum supported client app version; clients below this receive HTTP 426", + Value = "0.0.0" + }); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AppFeatureFlag", b => + { + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("PlanRequirement") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Key"); + + b.ToTable("AppFeatureFlags"); + + b.HasData( + new + { + Key = "offline_mode", + Description = "Enable offline mode with background sync", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "ai_chat", + Description = "AI chat assistant", + Enabled = true, + PlanRequirement = "Free", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "ai_summary", + Description = "AI daily summary", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "ai_retrospective", + Description = "AI retrospective analysis", + Enabled = true, + PlanRequirement = "YearlyPro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "sub_habits", + Description = "Sub-habit nesting", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "goal_tracking", + Description = "Goal tracking with progress", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "push_notifications", + Description = "Push notification reminders", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "scheduled_reminders", + Description = "Custom scheduled reminders", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "slip_alerts", + Description = "Slip detection alerts", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "checklist_templates", + Description = "Reusable checklist templates", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "habit_duplication", + Description = "Duplicate habits", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "bulk_operations", + Description = "Bulk create/delete/log habits", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "calendar_integration", + Description = "Google Calendar integration", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "api_keys", + Description = "Personal API keys", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.BlockedUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BlockedId") + .HasColumnType("uuid"); + + b.Property("BlockerId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("BlockedId"); + + b.HasIndex("BlockerId", "BlockedId") + .IsUnique(); + + b.ToTable("BlockedUsers"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ChecklistTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Items") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "IsDeleted"); + + b.ToTable("ChecklistTemplates"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Cheer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("Note") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RecipientId") + .HasColumnType("uuid"); + + b.Property("SenderId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("HabitId"); + + b.HasIndex("RecipientId"); + + b.HasIndex("SenderId", "CreatedAtUtc"); + + b.ToTable("Cheers"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ContentBlock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Key", "Locale") + .IsUnique(); + + b.ToTable("ContentBlocks"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.DistributedRateLimitBucket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Count") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PartitionKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PolicyName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WindowEndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WindowStartUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PolicyName", "PartitionKey", "WindowStartUtc") + .IsUnique(); + + b.ToTable("DistributedRateLimitBuckets"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.FriendFeedEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AchievementId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ActorUserId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Value") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId", "AchievementId") + .IsUnique() + .HasFilter("\"AchievementId\" IS NOT NULL"); + + b.HasIndex("ActorUserId", "CreatedAtUtc", "Id") + .IsDescending(false, true, true); + + b.HasIndex("ActorUserId", "Type", "Value") + .IsUnique() + .HasFilter("\"AchievementId\" IS NULL"); + + b.ToTable("FriendFeedEvents"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Friendship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddresseeId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("RespondedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("AddresseeId"); + + b.HasIndex("RequesterId"); + + b.ToTable("Friendships"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Goal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentValue") + .HasColumnType("numeric"); + + b.Property("Deadline") + .HasColumnType("date"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("StreakSyncedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TargetValue") + .HasColumnType("numeric"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("Unit") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "IsDeleted"); + + b.ToTable("Goals"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.GoalProgressLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GoalId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("PreviousValue") + .HasColumnType("numeric"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("GoalId"); + + b.HasIndex("GoalId", "IsDeleted"); + + b.ToTable("GoalProgressLogs"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.GoogleCalendarSyncSuggestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DiscoveredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DismissedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GoogleEventId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ImportedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ImportedHabitId") + .HasColumnType("uuid"); + + b.Property("RawEventJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartDateUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "GoogleEventId") + .IsUnique(); + + b.HasIndex("UserId", "DismissedAtUtc", "ImportedAtUtc"); + + b.ToTable("GoogleCalendarSyncSuggestions"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Habit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChecklistItems") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Days") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DueDate") + .HasColumnType("date"); + + b.Property("DueEndTime") + .HasColumnType("time without time zone"); + + b.Property("DueTime") + .HasColumnType("time without time zone"); + + b.Property("Emoji") + .HasColumnType("text"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("FrequencyQuantity") + .HasColumnType("integer"); + + b.Property("FrequencyUnit") + .HasColumnType("integer"); + + b.Property("GoogleEventId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsBadHabit") + .HasColumnType("boolean"); + + b.Property("IsCompleted") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsFlexible") + .HasColumnType("boolean"); + + b.Property("IsGeneral") + .HasColumnType("boolean"); + + b.Property("OriginalDayOfMonth") + .HasColumnType("integer"); + + b.Property("ParentHabitId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ReminderEnabled") + .HasColumnType("boolean"); + + b.Property("ReminderTimes") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[15]'::jsonb"); + + b.Property("ScheduledReminders") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("SlipAlertEnabled") + .HasColumnType("boolean"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ParentHabitId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "GoogleEventId") + .IsUnique() + .HasFilter("\"GoogleEventId\" IS NOT NULL AND \"IsDeleted\" = FALSE"); + + b.HasIndex("UserId", "IsDeleted"); + + b.ToTable("Habits"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.HabitLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "HabitId", "Date" }, "IX_HabitLogs_HabitId_Date"); + + b.HasIndex(new[] { "HabitId", "Date" }, "IX_HabitLogs_HabitId_Date_Completed") + .IsUnique() + .HasFilter("\"Value\" > 0 AND NOT \"IsDeleted\""); + + b.ToTable("HabitLogs"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsRead") + .HasColumnType("boolean"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Url") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Url") + .HasFilter("\"Url\" IS NOT NULL"); + + b.HasIndex("UserId", "CreatedAtUtc") + .IsDescending(false, true); + + b.HasIndex("UserId", "IsDeleted"); + + b.HasIndex("UserId", "IsRead"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.PendingAgentOperationState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ArgumentsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CapabilityId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfirmationRequirement") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ConfirmationTokenHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ConfirmedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OperationFingerprint") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OperationId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RiskClass") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("StepUpSatisfiedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Surface") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CapabilityId"); + + b.HasIndex("UserId", "OperationFingerprint"); + + b.ToTable("PendingAgentOperations"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.PendingClarification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MissingArgumentKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PartialArgumentsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("QuickActionsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ResolvedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ToolName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("UserId", "CreatedAtUtc"); + + b.ToTable("PendingClarifications"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ProcessedPlayNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MessageId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ProcessedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("MessageId") + .IsUnique(); + + b.ToTable("ProcessedPlayNotifications"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ProcessedStripeEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EventId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ProcessedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("EventId") + .IsUnique(); + + b.ToTable("ProcessedStripeEvents"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.PushSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Auth") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Endpoint") + .IsRequired() + .HasColumnType("text"); + + b.Property("P256dh") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Endpoint") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("PushSubscriptions"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Referral", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferredUserId") + .HasColumnType("uuid"); + + b.Property("ReferrerId") + .HasColumnType("uuid"); + + b.Property("RewardGrantedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("ReferredUserId") + .IsUnique(); + + b.HasIndex("ReferrerId"); + + b.ToTable("Referrals"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Report", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CheerId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Details") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ReportedUserId") + .HasColumnType("uuid"); + + b.Property("ReporterId") + .HasColumnType("uuid"); + + b.Property("ReviewedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("CheerId"); + + b.HasIndex("ReportedUserId"); + + b.HasIndex("ReporterId"); + + b.HasIndex("Status"); + + b.ToTable("Reports"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.SentReminder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("MinutesBefore") + .HasColumnType("integer"); + + b.Property("ReminderTimeUtc") + .HasColumnType("time without time zone"); + + b.Property("SentAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("When") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("HabitId", "Date", "MinutesBefore", "ReminderTimeUtc", "When") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("HabitId", "Date", "MinutesBefore", "ReminderTimeUtc", "When"), false); + + b.ToTable("SentReminders"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.SentSlipAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("SentAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WeekStart") + .HasColumnType("date"); + + b.HasKey("Id"); + + b.HasIndex("HabitId", "WeekStart") + .IsUnique(); + + b.ToTable("SentSlipAlerts"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.SentStreakFreezeAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("FrozenDate") + .HasColumnType("date"); + + b.Property("SentAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "FrozenDate") + .IsUnique(); + + b.ToTable("SentStreakFreezeAlerts"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.StreakFreeze", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UsedOnDate") + .HasColumnType("date"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "UsedOnDate") + .IsUnique(); + + b.ToTable("StreakFreezes"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Color") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "IsDeleted"); + + b.HasIndex("UserId", "Name") + .IsUnique(); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AdRewardBonusMessages") + .HasColumnType("integer"); + + b.Property("AdRewardsClaimedToday") + .HasColumnType("integer"); + + b.Property("AiMemoryEnabled") + .HasColumnType("boolean"); + + b.Property("AiMessagesResetAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AiMessagesUsedThisMonth") + .HasColumnType("integer"); + + b.Property("AiSummaryEnabled") + .HasColumnType("boolean"); + + b.Property("ColorScheme") + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentStreak") + .HasColumnType("integer"); + + b.Property("DeactivatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("GoogleAccessToken") + .HasColumnType("text"); + + b.Property("GoogleCalendarAutoSyncEnabled") + .HasColumnType("boolean"); + + b.Property("GoogleCalendarAutoSyncStatus") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("GoogleCalendarLastSyncError") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("GoogleCalendarLastSyncedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GoogleCalendarSelectedIds") + .HasColumnType("text"); + + b.Property("GoogleCalendarSyncReconciledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GoogleRefreshToken") + .HasColumnType("text"); + + b.Property("Handle") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("HasCompletedOnboarding") + .HasColumnType("boolean"); + + b.Property("HasCompletedTour") + .HasColumnType("boolean"); + + b.Property("HasImportedCalendar") + .HasColumnType("boolean"); + + b.Property("IsDeactivated") + .HasColumnType("boolean"); + + b.Property("IsLifetimePro") + .HasColumnType("boolean"); + + b.Property("Language") + .HasColumnType("text"); + + b.Property("LastActiveDate") + .HasColumnType("date"); + + b.Property("LastAdRewardAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastAdRewardLocalDate") + .HasColumnType("date"); + + b.Property("LastFreezeAwardStreak") + .HasColumnType("integer"); + + b.Property("Level") + .HasColumnType("integer"); + + b.Property("LongestStreak") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Plan") + .HasColumnType("integer"); + + b.Property("PlanExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayPurchaseToken") + .HasColumnType("text"); + + b.Property("ReferralCode") + .HasColumnType("text"); + + b.Property("ReferralCouponId") + .HasColumnType("text"); + + b.Property("ReferredByUserId") + .HasColumnType("uuid"); + + b.Property("ScheduledDeletionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SocialOptIn") + .HasColumnType("boolean"); + + b.Property("StreakFreezesAccumulated") + .HasColumnType("integer"); + + b.Property("StripeCustomerId") + .HasColumnType("text"); + + b.Property("StripeSubscriptionId") + .HasColumnType("text"); + + b.Property("SubscriptionInterval") + .HasColumnType("integer"); + + b.Property("SubscriptionSource") + .HasColumnType("integer"); + + b.Property("ThemePreference") + .HasColumnType("text"); + + b.Property("TimeZone") + .HasColumnType("text"); + + b.Property("TotalXp") + .HasColumnType("integer"); + + b.Property("TrialEndsAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WeekStartDay") + .HasColumnType("integer"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("PlayPurchaseToken") + .IsUnique() + .HasFilter("\"PlayPurchaseToken\" IS NOT NULL"); + + b.HasIndex("ReferralCode") + .IsUnique() + .HasFilter("\"ReferralCode\" IS NOT NULL"); + + b.HasIndex("GoogleCalendarAutoSyncEnabled", "GoogleCalendarLastSyncedAt") + .HasFilter("\"GoogleCalendarAutoSyncEnabled\" = TRUE"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.UserAchievement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AchievementId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EarnedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "AchievementId") + .IsUnique(); + + b.ToTable("UserAchievements"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.UserFact", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Category") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExtractedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FactText") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "IsDeleted"); + + b.ToTable("UserFacts"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUsedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("UserSessions"); + }); + + modelBuilder.Entity("HabitGoals", b => + { + b.HasOne("Orbit.Domain.Entities.Goal", null) + .WithMany() + .HasForeignKey("GoalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany() + .HasForeignKey("HabitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("HabitTags", b => + { + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany() + .HasForeignKey("HabitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AiFactExtractionBatch", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ApiKey", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.BlockedUser", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("BlockedId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("BlockerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ChecklistTemplate", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Cheer", b => + { + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany() + .HasForeignKey("HabitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("RecipientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("SenderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.FriendFeedEvent", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("ActorUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Friendship", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("AddresseeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.GoalProgressLog", b => + { + b.HasOne("Orbit.Domain.Entities.Goal", null) + .WithMany("ProgressLogs") + .HasForeignKey("GoalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.GoogleCalendarSyncSuggestion", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Habit", b => + { + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany("Children") + .HasForeignKey("ParentHabitId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.HabitLog", b => + { + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany("Logs") + .HasForeignKey("HabitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.PendingClarification", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.PushSubscription", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Report", b => + { + b.HasOne("Orbit.Domain.Entities.Cheer", null) + .WithMany() + .HasForeignKey("CheerId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("ReportedUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("ReporterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.SentStreakFreezeAlert", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.StreakFreeze", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.UserSession", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Goal", b => + { + b.Navigation("ProgressLogs"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Habit", b => + { + b.Navigation("Children"); + + b.Navigation("Logs"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Orbit.Infrastructure/Migrations/20260627043108_AddSocialFoundation.cs b/src/Orbit.Infrastructure/Migrations/20260627043108_AddSocialFoundation.cs new file mode 100644 index 00000000..b87f07ec --- /dev/null +++ b/src/Orbit.Infrastructure/Migrations/20260627043108_AddSocialFoundation.cs @@ -0,0 +1,288 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Orbit.Infrastructure.Migrations +{ + /// + public partial class AddSocialFoundation : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Handle", + table: "Users", + type: "character varying(20)", + maxLength: 20, + nullable: true); + + migrationBuilder.AddColumn( + name: "SocialOptIn", + table: "Users", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.CreateTable( + name: "BlockedUsers", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + BlockerId = table.Column(type: "uuid", nullable: false), + BlockedId = table.Column(type: "uuid", nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_BlockedUsers", x => x.Id); + table.ForeignKey( + name: "FK_BlockedUsers_Users_BlockedId", + column: x => x.BlockedId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_BlockedUsers_Users_BlockerId", + column: x => x.BlockerId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "Cheers", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + SenderId = table.Column(type: "uuid", nullable: false), + RecipientId = table.Column(type: "uuid", nullable: false), + HabitId = table.Column(type: "uuid", nullable: false), + Note = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Cheers", x => x.Id); + table.ForeignKey( + name: "FK_Cheers_Habits_HabitId", + column: x => x.HabitId, + principalTable: "Habits", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Cheers_Users_RecipientId", + column: x => x.RecipientId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_Cheers_Users_SenderId", + column: x => x.SenderId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "FriendFeedEvents", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ActorUserId = table.Column(type: "uuid", nullable: false), + Type = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + Value = table.Column(type: "integer", nullable: true), + AchievementId = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_FriendFeedEvents", x => x.Id); + table.ForeignKey( + name: "FK_FriendFeedEvents_Users_ActorUserId", + column: x => x.ActorUserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "Friendships", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + RequesterId = table.Column(type: "uuid", nullable: false), + AddresseeId = table.Column(type: "uuid", nullable: false), + Status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + RespondedAtUtc = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Friendships", x => x.Id); + table.ForeignKey( + name: "FK_Friendships_Users_AddresseeId", + column: x => x.AddresseeId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_Friendships_Users_RequesterId", + column: x => x.RequesterId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "Reports", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ReporterId = table.Column(type: "uuid", nullable: false), + ReportedUserId = table.Column(type: "uuid", nullable: false), + Reason = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + Details = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + CheerId = table.Column(type: "uuid", nullable: true), + Status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + ReviewedAtUtc = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Reports", x => x.Id); + table.ForeignKey( + name: "FK_Reports_Cheers_CheerId", + column: x => x.CheerId, + principalTable: "Cheers", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_Reports_Users_ReportedUserId", + column: x => x.ReportedUserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_Reports_Users_ReporterId", + column: x => x.ReporterId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_BlockedUsers_BlockedId", + table: "BlockedUsers", + column: "BlockedId"); + + migrationBuilder.CreateIndex( + name: "IX_BlockedUsers_BlockerId_BlockedId", + table: "BlockedUsers", + columns: new[] { "BlockerId", "BlockedId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Cheers_HabitId", + table: "Cheers", + column: "HabitId"); + + migrationBuilder.CreateIndex( + name: "IX_Cheers_RecipientId", + table: "Cheers", + column: "RecipientId"); + + migrationBuilder.CreateIndex( + name: "IX_Cheers_SenderId_CreatedAtUtc", + table: "Cheers", + columns: new[] { "SenderId", "CreatedAtUtc" }); + + migrationBuilder.CreateIndex( + name: "IX_FriendFeedEvents_ActorUserId_AchievementId", + table: "FriendFeedEvents", + columns: new[] { "ActorUserId", "AchievementId" }, + unique: true, + filter: "\"AchievementId\" IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_FriendFeedEvents_ActorUserId_CreatedAtUtc_Id", + table: "FriendFeedEvents", + columns: new[] { "ActorUserId", "CreatedAtUtc", "Id" }, + descending: new[] { false, true, true }); + + migrationBuilder.CreateIndex( + name: "IX_FriendFeedEvents_ActorUserId_Type_Value", + table: "FriendFeedEvents", + columns: new[] { "ActorUserId", "Type", "Value" }, + unique: true, + filter: "\"AchievementId\" IS NULL"); + + migrationBuilder.CreateIndex( + name: "IX_Friendships_AddresseeId", + table: "Friendships", + column: "AddresseeId"); + + migrationBuilder.CreateIndex( + name: "IX_Friendships_RequesterId", + table: "Friendships", + column: "RequesterId"); + + migrationBuilder.CreateIndex( + name: "IX_Reports_CheerId", + table: "Reports", + column: "CheerId"); + + migrationBuilder.CreateIndex( + name: "IX_Reports_ReportedUserId", + table: "Reports", + column: "ReportedUserId"); + + migrationBuilder.CreateIndex( + name: "IX_Reports_ReporterId", + table: "Reports", + column: "ReporterId"); + + migrationBuilder.CreateIndex( + name: "IX_Reports_Status", + table: "Reports", + column: "Status"); + + migrationBuilder.Sql(@"UPDATE ""Users"" SET ""Handle"" = 'user_' || LEFT(REPLACE(CAST(""Id"" AS TEXT), '-', ''), 12) WHERE ""Handle"" IS NULL;"); + + migrationBuilder.Sql(@"CREATE UNIQUE INDEX ""IX_Users_Handle_Lower"" ON ""Users"" (lower(""Handle"")) WHERE ""Handle"" IS NOT NULL;"); + + migrationBuilder.Sql(@"CREATE UNIQUE INDEX ""IX_Friendships_Pair"" ON ""Friendships"" (LEAST(""RequesterId"", ""AddresseeId""), GREATEST(""RequesterId"", ""AddresseeId""));"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(@"DROP INDEX IF EXISTS ""IX_Friendships_Pair"";"); + migrationBuilder.Sql(@"DROP INDEX IF EXISTS ""IX_Users_Handle_Lower"";"); + + migrationBuilder.DropTable( + name: "BlockedUsers"); + + migrationBuilder.DropTable( + name: "FriendFeedEvents"); + + migrationBuilder.DropTable( + name: "Friendships"); + + migrationBuilder.DropTable( + name: "Reports"); + + migrationBuilder.DropTable( + name: "Cheers"); + + migrationBuilder.DropColumn( + name: "Handle", + table: "Users"); + + migrationBuilder.DropColumn( + name: "SocialOptIn", + table: "Users"); + } + } +} diff --git a/src/Orbit.Infrastructure/Migrations/20260627054025_AddOnboardingChecklistFlags.Designer.cs b/src/Orbit.Infrastructure/Migrations/20260627054025_AddOnboardingChecklistFlags.Designer.cs new file mode 100644 index 00000000..0d23dd86 --- /dev/null +++ b/src/Orbit.Infrastructure/Migrations/20260627054025_AddOnboardingChecklistFlags.Designer.cs @@ -0,0 +1,2056 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Orbit.Infrastructure.Persistence; + +#nullable disable + +namespace Orbit.Infrastructure.Migrations +{ + [DbContext(typeof(OrbitDbContext))] + [Migration("20260627054025_AddOnboardingChecklistFlags")] + partial class AddOnboardingChecklistFlags + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("HabitGoals", b => + { + b.Property("GoalId") + .HasColumnType("uuid"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.HasKey("GoalId", "HabitId"); + + b.HasIndex("HabitId"); + + b.ToTable("HabitGoals"); + }); + + modelBuilder.Entity("HabitTags", b => + { + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("TagId") + .HasColumnType("uuid"); + + b.HasKey("HabitId", "TagId"); + + b.HasIndex("TagId"); + + b.ToTable("HabitTags"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AgentAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthMethod") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CapabilityId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CorrelationId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Error") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("OutcomeStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("PolicyDecision") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RedactedArguments") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RiskClass") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ShadowPolicyDecision") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ShadowReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("SourceName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Summary") + .HasColumnType("text"); + + b.Property("Surface") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TargetId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TargetName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CapabilityId", "CreatedAtUtc"); + + b.HasIndex("UserId", "CreatedAtUtc"); + + b.ToTable("AgentAuditLogs"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AgentStepUpChallengeState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttemptCount") + .HasColumnType("integer"); + + b.Property("CodeHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PendingOperationId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("VerifiedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PendingOperationId", "CreatedAtUtc"); + + b.ToTable("AgentStepUpChallenges"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AiFactExtractionBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BatchId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("InputFileId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("OutputFileId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("UserId"); + + b.ToTable("AiFactExtractionBatches"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsReadOnly") + .HasColumnType("boolean"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("KeyHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("KeyPrefix") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("character varying(12)"); + + b.Property("LastUsedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Scopes") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("KeyPrefix"); + + b.HasIndex("UserId"); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AppConfig", b => + { + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Key"); + + b.ToTable("AppConfigs"); + + b.HasData( + new + { + Key = "MaxUserFacts", + Description = "Maximum number of facts the AI can remember per user", + Value = "50" + }, + new + { + Key = "MaxHabitDepth", + Description = "Maximum nesting depth for sub-habits", + Value = "5" + }, + new + { + Key = "MaxTagsPerHabit", + Description = "Maximum number of tags per habit", + Value = "5" + }, + new + { + Key = "ReferralRewardDays", + Description = "Days of Pro added per successful referral", + Value = "10" + }, + new + { + Key = "MaxReferrals", + Description = "Maximum successful referrals per user", + Value = "10" + }, + new + { + Key = "MinSupportedVersion", + Description = "Minimum supported client app version; clients below this receive HTTP 426", + Value = "0.0.0" + }); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AppFeatureFlag", b => + { + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("PlanRequirement") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Key"); + + b.ToTable("AppFeatureFlags"); + + b.HasData( + new + { + Key = "offline_mode", + Description = "Enable offline mode with background sync", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "ai_chat", + Description = "AI chat assistant", + Enabled = true, + PlanRequirement = "Free", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "ai_summary", + Description = "AI daily summary", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "ai_retrospective", + Description = "AI retrospective analysis", + Enabled = true, + PlanRequirement = "YearlyPro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "sub_habits", + Description = "Sub-habit nesting", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "goal_tracking", + Description = "Goal tracking with progress", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "push_notifications", + Description = "Push notification reminders", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "scheduled_reminders", + Description = "Custom scheduled reminders", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "slip_alerts", + Description = "Slip detection alerts", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "checklist_templates", + Description = "Reusable checklist templates", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "habit_duplication", + Description = "Duplicate habits", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "bulk_operations", + Description = "Bulk create/delete/log habits", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "calendar_integration", + Description = "Google Calendar integration", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "api_keys", + Description = "Personal API keys", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.BlockedUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BlockedId") + .HasColumnType("uuid"); + + b.Property("BlockerId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("BlockedId"); + + b.HasIndex("BlockerId", "BlockedId") + .IsUnique(); + + b.ToTable("BlockedUsers"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ChecklistTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Items") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "IsDeleted"); + + b.ToTable("ChecklistTemplates"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Cheer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("Note") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RecipientId") + .HasColumnType("uuid"); + + b.Property("SenderId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("HabitId"); + + b.HasIndex("RecipientId"); + + b.HasIndex("SenderId", "CreatedAtUtc"); + + b.ToTable("Cheers"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ContentBlock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Key", "Locale") + .IsUnique(); + + b.ToTable("ContentBlocks"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.DistributedRateLimitBucket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Count") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PartitionKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PolicyName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WindowEndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WindowStartUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PolicyName", "PartitionKey", "WindowStartUtc") + .IsUnique(); + + b.ToTable("DistributedRateLimitBuckets"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.FriendFeedEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AchievementId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ActorUserId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Value") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId", "AchievementId") + .IsUnique() + .HasFilter("\"AchievementId\" IS NOT NULL"); + + b.HasIndex("ActorUserId", "CreatedAtUtc", "Id") + .IsDescending(false, true, true); + + b.HasIndex("ActorUserId", "Type", "Value") + .IsUnique() + .HasFilter("\"AchievementId\" IS NULL"); + + b.ToTable("FriendFeedEvents"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Friendship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddresseeId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("RespondedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("AddresseeId"); + + b.HasIndex("RequesterId"); + + b.ToTable("Friendships"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Goal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentValue") + .HasColumnType("numeric"); + + b.Property("Deadline") + .HasColumnType("date"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("StreakSyncedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TargetValue") + .HasColumnType("numeric"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("Unit") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "IsDeleted"); + + b.ToTable("Goals"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.GoalProgressLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GoalId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("PreviousValue") + .HasColumnType("numeric"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("GoalId"); + + b.HasIndex("GoalId", "IsDeleted"); + + b.ToTable("GoalProgressLogs"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.GoogleCalendarSyncSuggestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DiscoveredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DismissedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GoogleEventId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ImportedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ImportedHabitId") + .HasColumnType("uuid"); + + b.Property("RawEventJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartDateUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "GoogleEventId") + .IsUnique(); + + b.HasIndex("UserId", "DismissedAtUtc", "ImportedAtUtc"); + + b.ToTable("GoogleCalendarSyncSuggestions"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Habit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChecklistItems") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Days") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DueDate") + .HasColumnType("date"); + + b.Property("DueEndTime") + .HasColumnType("time without time zone"); + + b.Property("DueTime") + .HasColumnType("time without time zone"); + + b.Property("Emoji") + .HasColumnType("text"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("FrequencyQuantity") + .HasColumnType("integer"); + + b.Property("FrequencyUnit") + .HasColumnType("integer"); + + b.Property("GoogleEventId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsBadHabit") + .HasColumnType("boolean"); + + b.Property("IsCompleted") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsFlexible") + .HasColumnType("boolean"); + + b.Property("IsGeneral") + .HasColumnType("boolean"); + + b.Property("OriginalDayOfMonth") + .HasColumnType("integer"); + + b.Property("ParentHabitId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ReminderEnabled") + .HasColumnType("boolean"); + + b.Property("ReminderTimes") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[15]'::jsonb"); + + b.Property("ScheduledReminders") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("SlipAlertEnabled") + .HasColumnType("boolean"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ParentHabitId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "GoogleEventId") + .IsUnique() + .HasFilter("\"GoogleEventId\" IS NOT NULL AND \"IsDeleted\" = FALSE"); + + b.HasIndex("UserId", "IsDeleted"); + + b.ToTable("Habits"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.HabitLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "HabitId", "Date" }, "IX_HabitLogs_HabitId_Date"); + + b.HasIndex(new[] { "HabitId", "Date" }, "IX_HabitLogs_HabitId_Date_Completed") + .IsUnique() + .HasFilter("\"Value\" > 0 AND NOT \"IsDeleted\""); + + b.ToTable("HabitLogs"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsRead") + .HasColumnType("boolean"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Url") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Url") + .HasFilter("\"Url\" IS NOT NULL"); + + b.HasIndex("UserId", "CreatedAtUtc") + .IsDescending(false, true); + + b.HasIndex("UserId", "IsDeleted"); + + b.HasIndex("UserId", "IsRead"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.PendingAgentOperationState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ArgumentsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CapabilityId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfirmationRequirement") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ConfirmationTokenHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ConfirmedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OperationFingerprint") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OperationId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RiskClass") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("StepUpSatisfiedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Surface") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CapabilityId"); + + b.HasIndex("UserId", "OperationFingerprint"); + + b.ToTable("PendingAgentOperations"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.PendingClarification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MissingArgumentKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PartialArgumentsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("QuickActionsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ResolvedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ToolName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("UserId", "CreatedAtUtc"); + + b.ToTable("PendingClarifications"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ProcessedPlayNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MessageId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ProcessedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("MessageId") + .IsUnique(); + + b.ToTable("ProcessedPlayNotifications"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ProcessedStripeEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EventId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ProcessedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("EventId") + .IsUnique(); + + b.ToTable("ProcessedStripeEvents"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.PushSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Auth") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Endpoint") + .IsRequired() + .HasColumnType("text"); + + b.Property("P256dh") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Endpoint") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("PushSubscriptions"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Referral", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferredUserId") + .HasColumnType("uuid"); + + b.Property("ReferrerId") + .HasColumnType("uuid"); + + b.Property("RewardGrantedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("ReferredUserId") + .IsUnique(); + + b.HasIndex("ReferrerId"); + + b.ToTable("Referrals"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Report", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CheerId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Details") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ReportedUserId") + .HasColumnType("uuid"); + + b.Property("ReporterId") + .HasColumnType("uuid"); + + b.Property("ReviewedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("CheerId"); + + b.HasIndex("ReportedUserId"); + + b.HasIndex("ReporterId"); + + b.HasIndex("Status"); + + b.ToTable("Reports"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.SentReminder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("MinutesBefore") + .HasColumnType("integer"); + + b.Property("ReminderTimeUtc") + .HasColumnType("time without time zone"); + + b.Property("SentAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("When") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("HabitId", "Date", "MinutesBefore", "ReminderTimeUtc", "When") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("HabitId", "Date", "MinutesBefore", "ReminderTimeUtc", "When"), false); + + b.ToTable("SentReminders"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.SentSlipAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("SentAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WeekStart") + .HasColumnType("date"); + + b.HasKey("Id"); + + b.HasIndex("HabitId", "WeekStart") + .IsUnique(); + + b.ToTable("SentSlipAlerts"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.SentStreakFreezeAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("FrozenDate") + .HasColumnType("date"); + + b.Property("SentAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "FrozenDate") + .IsUnique(); + + b.ToTable("SentStreakFreezeAlerts"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.StreakFreeze", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UsedOnDate") + .HasColumnType("date"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "UsedOnDate") + .IsUnique(); + + b.ToTable("StreakFreezes"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Color") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "IsDeleted"); + + b.HasIndex("UserId", "Name") + .IsUnique(); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AdRewardBonusMessages") + .HasColumnType("integer"); + + b.Property("AdRewardsClaimedToday") + .HasColumnType("integer"); + + b.Property("AiMemoryEnabled") + .HasColumnType("boolean"); + + b.Property("AiMessagesResetAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AiMessagesUsedThisMonth") + .HasColumnType("integer"); + + b.Property("AiSummaryEnabled") + .HasColumnType("boolean"); + + b.Property("ColorScheme") + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentStreak") + .HasColumnType("integer"); + + b.Property("DeactivatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("GoogleAccessToken") + .HasColumnType("text"); + + b.Property("GoogleCalendarAutoSyncEnabled") + .HasColumnType("boolean"); + + b.Property("GoogleCalendarAutoSyncStatus") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("GoogleCalendarLastSyncError") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("GoogleCalendarLastSyncedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GoogleCalendarSelectedIds") + .HasColumnType("text"); + + b.Property("GoogleCalendarSyncReconciledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GoogleRefreshToken") + .HasColumnType("text"); + + b.Property("Handle") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("HasCompletedOnboarding") + .HasColumnType("boolean"); + + b.Property("HasCompletedOnboardingChecklist") + .HasColumnType("boolean"); + + b.Property("HasCompletedTour") + .HasColumnType("boolean"); + + b.Property("HasCreatedFirstHabit") + .HasColumnType("boolean"); + + b.Property("HasImportedCalendar") + .HasColumnType("boolean"); + + b.Property("HasLoggedFirstHabit") + .HasColumnType("boolean"); + + b.Property("HasTriedAstra") + .HasColumnType("boolean"); + + b.Property("IsDeactivated") + .HasColumnType("boolean"); + + b.Property("IsLifetimePro") + .HasColumnType("boolean"); + + b.Property("Language") + .HasColumnType("text"); + + b.Property("LastActiveDate") + .HasColumnType("date"); + + b.Property("LastAdRewardAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastAdRewardLocalDate") + .HasColumnType("date"); + + b.Property("LastFreezeAwardStreak") + .HasColumnType("integer"); + + b.Property("Level") + .HasColumnType("integer"); + + b.Property("LongestStreak") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Plan") + .HasColumnType("integer"); + + b.Property("PlanExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayPurchaseToken") + .HasColumnType("text"); + + b.Property("ReferralCode") + .HasColumnType("text"); + + b.Property("ReferralCouponId") + .HasColumnType("text"); + + b.Property("ReferredByUserId") + .HasColumnType("uuid"); + + b.Property("ScheduledDeletionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SocialOptIn") + .HasColumnType("boolean"); + + b.Property("StreakFreezesAccumulated") + .HasColumnType("integer"); + + b.Property("StripeCustomerId") + .HasColumnType("text"); + + b.Property("StripeSubscriptionId") + .HasColumnType("text"); + + b.Property("SubscriptionInterval") + .HasColumnType("integer"); + + b.Property("SubscriptionSource") + .HasColumnType("integer"); + + b.Property("ThemePreference") + .HasColumnType("text"); + + b.Property("TimeZone") + .HasColumnType("text"); + + b.Property("TotalXp") + .HasColumnType("integer"); + + b.Property("TrialEndsAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WeekStartDay") + .HasColumnType("integer"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("PlayPurchaseToken") + .IsUnique() + .HasFilter("\"PlayPurchaseToken\" IS NOT NULL"); + + b.HasIndex("ReferralCode") + .IsUnique() + .HasFilter("\"ReferralCode\" IS NOT NULL"); + + b.HasIndex("GoogleCalendarAutoSyncEnabled", "GoogleCalendarLastSyncedAt") + .HasFilter("\"GoogleCalendarAutoSyncEnabled\" = TRUE"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.UserAchievement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AchievementId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EarnedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "AchievementId") + .IsUnique(); + + b.ToTable("UserAchievements"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.UserFact", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Category") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExtractedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FactText") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "IsDeleted"); + + b.ToTable("UserFacts"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUsedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("UserSessions"); + }); + + modelBuilder.Entity("HabitGoals", b => + { + b.HasOne("Orbit.Domain.Entities.Goal", null) + .WithMany() + .HasForeignKey("GoalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany() + .HasForeignKey("HabitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("HabitTags", b => + { + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany() + .HasForeignKey("HabitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AiFactExtractionBatch", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ApiKey", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.BlockedUser", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("BlockedId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("BlockerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ChecklistTemplate", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Cheer", b => + { + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany() + .HasForeignKey("HabitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("RecipientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("SenderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.FriendFeedEvent", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("ActorUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Friendship", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("AddresseeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.GoalProgressLog", b => + { + b.HasOne("Orbit.Domain.Entities.Goal", null) + .WithMany("ProgressLogs") + .HasForeignKey("GoalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.GoogleCalendarSyncSuggestion", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Habit", b => + { + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany("Children") + .HasForeignKey("ParentHabitId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.HabitLog", b => + { + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany("Logs") + .HasForeignKey("HabitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.PendingClarification", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.PushSubscription", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Report", b => + { + b.HasOne("Orbit.Domain.Entities.Cheer", null) + .WithMany() + .HasForeignKey("CheerId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("ReportedUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("ReporterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.SentStreakFreezeAlert", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.StreakFreeze", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.UserSession", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Goal", b => + { + b.Navigation("ProgressLogs"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Habit", b => + { + b.Navigation("Children"); + + b.Navigation("Logs"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Orbit.Infrastructure/Migrations/20260627054025_AddOnboardingChecklistFlags.cs b/src/Orbit.Infrastructure/Migrations/20260627054025_AddOnboardingChecklistFlags.cs new file mode 100644 index 00000000..62832ffa --- /dev/null +++ b/src/Orbit.Infrastructure/Migrations/20260627054025_AddOnboardingChecklistFlags.cs @@ -0,0 +1,65 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Orbit.Infrastructure.Migrations +{ + /// + public partial class AddOnboardingChecklistFlags : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "HasCompletedOnboardingChecklist", + table: "Users", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "HasCreatedFirstHabit", + table: "Users", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "HasLoggedFirstHabit", + table: "Users", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "HasTriedAstra", + table: "Users", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.Sql( + "UPDATE \"Users\" SET \"HasCompletedOnboardingChecklist\" = TRUE;"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "HasCompletedOnboardingChecklist", + table: "Users"); + + migrationBuilder.DropColumn( + name: "HasCreatedFirstHabit", + table: "Users"); + + migrationBuilder.DropColumn( + name: "HasLoggedFirstHabit", + table: "Users"); + + migrationBuilder.DropColumn( + name: "HasTriedAstra", + table: "Users"); + } + } +} diff --git a/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs b/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs index dfb13841..6cb7280a 100644 --- a/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs +++ b/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs @@ -465,6 +465,31 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("Orbit.Domain.Entities.BlockedUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BlockedId") + .HasColumnType("uuid"); + + b.Property("BlockerId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("BlockedId"); + + b.HasIndex("BlockerId", "BlockedId") + .IsUnique(); + + b.ToTable("BlockedUsers"); + }); + modelBuilder.Entity("Orbit.Domain.Entities.ChecklistTemplate", b => { b.Property("Id") @@ -506,6 +531,39 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("ChecklistTemplates"); }); + modelBuilder.Entity("Orbit.Domain.Entities.Cheer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("Note") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RecipientId") + .HasColumnType("uuid"); + + b.Property("SenderId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("HabitId"); + + b.HasIndex("RecipientId"); + + b.HasIndex("SenderId", "CreatedAtUtc"); + + b.ToTable("Cheers"); + }); + modelBuilder.Entity("Orbit.Domain.Entities.ContentBlock", b => { b.Property("Id") @@ -581,6 +639,78 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("DistributedRateLimitBuckets"); }); + modelBuilder.Entity("Orbit.Domain.Entities.FriendFeedEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AchievementId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ActorUserId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Value") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId", "AchievementId") + .IsUnique() + .HasFilter("\"AchievementId\" IS NOT NULL"); + + b.HasIndex("ActorUserId", "CreatedAtUtc", "Id") + .IsDescending(false, true, true); + + b.HasIndex("ActorUserId", "Type", "Value") + .IsUnique() + .HasFilter("\"AchievementId\" IS NULL"); + + b.ToTable("FriendFeedEvents"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Friendship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddresseeId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("RespondedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("AddresseeId"); + + b.HasIndex("RequesterId"); + + b.ToTable("Friendships"); + }); + modelBuilder.Entity("Orbit.Domain.Entities.Goal", b => { b.Property("Id") @@ -1200,6 +1330,54 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Referrals"); }); + modelBuilder.Entity("Orbit.Domain.Entities.Report", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CheerId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Details") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ReportedUserId") + .HasColumnType("uuid"); + + b.Property("ReporterId") + .HasColumnType("uuid"); + + b.Property("ReviewedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("CheerId"); + + b.HasIndex("ReportedUserId"); + + b.HasIndex("ReporterId"); + + b.HasIndex("Status"); + + b.ToTable("Reports"); + }); + modelBuilder.Entity("Orbit.Domain.Entities.SentReminder", b => { b.Property("Id") @@ -1408,15 +1586,31 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("GoogleRefreshToken") .HasColumnType("text"); + b.Property("Handle") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + b.Property("HasCompletedOnboarding") .HasColumnType("boolean"); + b.Property("HasCompletedOnboardingChecklist") + .HasColumnType("boolean"); + b.Property("HasCompletedTour") .HasColumnType("boolean"); + b.Property("HasCreatedFirstHabit") + .HasColumnType("boolean"); + b.Property("HasImportedCalendar") .HasColumnType("boolean"); + b.Property("HasLoggedFirstHabit") + .HasColumnType("boolean"); + + b.Property("HasTriedAstra") + .HasColumnType("boolean"); + b.Property("IsDeactivated") .HasColumnType("boolean"); @@ -1469,6 +1663,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ScheduledDeletionAt") .HasColumnType("timestamp with time zone"); + b.Property("SocialOptIn") + .HasColumnType("boolean"); + b.Property("StreakFreezesAccumulated") .HasColumnType("integer"); @@ -1670,6 +1867,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("Orbit.Domain.Entities.BlockedUser", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("BlockedId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("BlockerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + modelBuilder.Entity("Orbit.Domain.Entities.ChecklistTemplate", b => { b.HasOne("Orbit.Domain.Entities.User", null) @@ -1679,6 +1891,51 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("Orbit.Domain.Entities.Cheer", b => + { + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany() + .HasForeignKey("HabitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("RecipientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("SenderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.FriendFeedEvent", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("ActorUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Friendship", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("AddresseeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + modelBuilder.Entity("Orbit.Domain.Entities.GoalProgressLog", b => { b.HasOne("Orbit.Domain.Entities.Goal", null) @@ -1732,6 +1989,26 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("Orbit.Domain.Entities.Report", b => + { + b.HasOne("Orbit.Domain.Entities.Cheer", null) + .WithMany() + .HasForeignKey("CheerId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("ReportedUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("ReporterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + modelBuilder.Entity("Orbit.Domain.Entities.SentStreakFreezeAlert", b => { b.HasOne("Orbit.Domain.Entities.User", null) diff --git a/src/Orbit.Infrastructure/Persistence/AccountResetRepository.cs b/src/Orbit.Infrastructure/Persistence/AccountResetRepository.cs index 9a2582af..2bbba79a 100644 --- a/src/Orbit.Infrastructure/Persistence/AccountResetRepository.cs +++ b/src/Orbit.Infrastructure/Persistence/AccountResetRepository.cs @@ -107,6 +107,26 @@ await context.Referrals .Where(r => r.ReferrerId == userId || r.ReferredUserId == userId) .ExecuteDeleteAsync(cancellationToken); + await context.FriendFeedEvents + .Where(e => e.ActorUserId == userId) + .ExecuteDeleteAsync(cancellationToken); + + await context.Reports + .Where(r => r.ReporterId == userId || r.ReportedUserId == userId) + .ExecuteDeleteAsync(cancellationToken); + + await context.Cheers + .Where(c => c.SenderId == userId || c.RecipientId == userId) + .ExecuteDeleteAsync(cancellationToken); + + await context.BlockedUsers + .Where(b => b.BlockerId == userId || b.BlockedId == userId) + .ExecuteDeleteAsync(cancellationToken); + + await context.Friendships + .Where(f => f.RequesterId == userId || f.AddresseeId == userId) + .ExecuteDeleteAsync(cancellationToken); + await context.Goals .IgnoreQueryFilters() .Where(g => g.UserId == userId) diff --git a/src/Orbit.Infrastructure/Persistence/FriendFeedReader.cs b/src/Orbit.Infrastructure/Persistence/FriendFeedReader.cs new file mode 100644 index 00000000..d057f6dd --- /dev/null +++ b/src/Orbit.Infrastructure/Persistence/FriendFeedReader.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; + +namespace Orbit.Infrastructure.Persistence; + +public class FriendFeedReader(OrbitDbContext context) : IFriendFeedReader +{ + public async Task> ReadFeedPageAsync( + IReadOnlyCollection actorUserIds, + DateTime? cursorCreatedAtUtc, + Guid? cursorId, + int limit, + CancellationToken cancellationToken = default) + { + if (actorUserIds.Count == 0) + return []; + + var actorIds = actorUserIds as IReadOnlyList ?? actorUserIds.ToList(); + + var query = context.FriendFeedEvents + .AsNoTracking() + .Where(e => actorIds.Contains(e.ActorUserId)); + + if (cursorCreatedAtUtc.HasValue && cursorId.HasValue) + { + var cursorTime = cursorCreatedAtUtc.Value; + var cursorRowId = cursorId.Value; + query = query.Where(e => EF.Functions.LessThan( + ValueTuple.Create(e.CreatedAtUtc, e.Id), + ValueTuple.Create(cursorTime, cursorRowId))); + } + + return await query + .OrderByDescending(e => e.CreatedAtUtc) + .ThenByDescending(e => e.Id) + .Take(limit) + .ToListAsync(cancellationToken); + } +} diff --git a/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs b/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs index fc42241e..0238b2a8 100644 --- a/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs +++ b/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs @@ -56,6 +56,11 @@ public OrbitDbContext(DbContextOptions options, IEncryptionServi public DbSet ProcessedPlayNotifications => Set(); public DbSet ProcessedStripeEvents => Set(); public DbSet AiFactExtractionBatches => Set(); + public DbSet Friendships => Set(); + public DbSet Cheers => Set(); + public DbSet BlockedUsers => Set(); + public DbSet Reports => Set(); + public DbSet FriendFeedEvents => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -102,6 +107,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) ConfigureChecklistTemplateEntity(modelBuilder); ConfigureAppFeatureFlagEntity(modelBuilder); ConfigureContentBlockEntity(modelBuilder); + ConfigureFriendshipEntity(modelBuilder); + ConfigureCheerEntity(modelBuilder); + ConfigureBlockedUserEntity(modelBuilder); + ConfigureReportEntity(modelBuilder); + ConfigureFriendFeedEventEntity(modelBuilder); } /// @@ -437,6 +447,75 @@ private static void ConfigureContentBlockEntity(ModelBuilder modelBuilder) }); } + private static void ConfigureFriendshipEntity(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasIndex(f => f.RequesterId); + entity.HasIndex(f => f.AddresseeId); + entity.Property(f => f.Status).HasConversion().HasMaxLength(32); + entity.HasOne().WithMany().HasForeignKey(f => f.RequesterId).OnDelete(DeleteBehavior.Restrict); + entity.HasOne().WithMany().HasForeignKey(f => f.AddresseeId).OnDelete(DeleteBehavior.Restrict); + }); + } + + private static void ConfigureCheerEntity(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasIndex(c => c.RecipientId); + entity.HasIndex(c => new { c.SenderId, c.CreatedAtUtc }); + entity.HasIndex(c => c.HabitId); + entity.Property(c => c.Note).HasMaxLength(DomainConstants.MaxCheerNoteLength); + entity.HasOne().WithMany().HasForeignKey(c => c.SenderId).OnDelete(DeleteBehavior.Restrict); + entity.HasOne().WithMany().HasForeignKey(c => c.RecipientId).OnDelete(DeleteBehavior.Restrict); + entity.HasOne().WithMany().HasForeignKey(c => c.HabitId).OnDelete(DeleteBehavior.Cascade); + }); + } + + private static void ConfigureBlockedUserEntity(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasIndex(b => new { b.BlockerId, b.BlockedId }).IsUnique(); + entity.HasOne().WithMany().HasForeignKey(b => b.BlockerId).OnDelete(DeleteBehavior.Restrict); + entity.HasOne().WithMany().HasForeignKey(b => b.BlockedId).OnDelete(DeleteBehavior.Restrict); + }); + } + + private static void ConfigureReportEntity(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasIndex(r => r.ReportedUserId); + entity.HasIndex(r => r.Status); + entity.Property(r => r.Reason).HasConversion().HasMaxLength(32); + entity.Property(r => r.Status).HasConversion().HasMaxLength(32); + entity.Property(r => r.Details).HasMaxLength(DomainConstants.MaxReportDetailsLength); + entity.HasOne().WithMany().HasForeignKey(r => r.ReporterId).OnDelete(DeleteBehavior.Restrict); + entity.HasOne().WithMany().HasForeignKey(r => r.ReportedUserId).OnDelete(DeleteBehavior.Restrict); + entity.HasOne().WithMany().HasForeignKey(r => r.CheerId).OnDelete(DeleteBehavior.SetNull); + }); + } + + private static void ConfigureFriendFeedEventEntity(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasIndex(e => new { e.ActorUserId, e.CreatedAtUtc, e.Id }) + .IsDescending(false, true, true); + entity.HasIndex(e => new { e.ActorUserId, e.AchievementId }) + .IsUnique() + .HasFilter("\"AchievementId\" IS NOT NULL"); + entity.HasIndex(e => new { e.ActorUserId, e.Type, e.Value }) + .IsUnique() + .HasFilter("\"AchievementId\" IS NULL"); + entity.Property(e => e.Type).HasConversion().HasMaxLength(32); + entity.Property(e => e.AchievementId).HasMaxLength(50); + entity.HasOne().WithMany().HasForeignKey(e => e.ActorUserId).OnDelete(DeleteBehavior.Restrict); + }); + } + private static void ConfigureUserEntity(ModelBuilder modelBuilder, NullableEncryptionValueConverter? nullableEncConverter) { modelBuilder.Entity(entity => @@ -445,6 +524,8 @@ private static void ConfigureUserEntity(ModelBuilder modelBuilder, NullableEncry entity.HasIndex(u => u.ReferralCode).IsUnique().HasFilter("\"ReferralCode\" IS NOT NULL"); entity.HasIndex(u => u.PlayPurchaseToken).IsUnique().HasFilter("\"PlayPurchaseToken\" IS NOT NULL"); + entity.Property(u => u.Handle).HasMaxLength(DomainConstants.HandleMaxLength); + entity.Property(u => u.GoogleCalendarAutoSyncStatus) .HasConversion() .HasMaxLength(32); diff --git a/src/Orbit.Infrastructure/Services/AgentCatalogService.Capabilities.cs b/src/Orbit.Infrastructure/Services/AgentCatalogService.Capabilities.cs index d0379ecd..3562325a 100644 --- a/src/Orbit.Infrastructure/Services/AgentCatalogService.Capabilities.cs +++ b/src/Orbit.Infrastructure/Services/AgentCatalogService.Capabilities.cs @@ -30,7 +30,8 @@ .. SubscriptionCapabilities(), .. ApiKeyCapabilities(), .. SupportCapabilities(), .. SyncCapabilities(), - .. AccountAndAuthCapabilities() + .. AccountAndAuthCapabilities(), + .. SocialCapabilities() ]; } @@ -634,7 +635,8 @@ private static AgentCapability[] GamificationCapabilities() [ "GamificationController.GetProfile", "GamificationController.GetAchievements", - "GamificationController.GetStreakInfo" + "GamificationController.GetStreakInfo", + "GamificationController.GetRecap" ]) ]; } @@ -884,6 +886,38 @@ private static AgentCapability[] SyncCapabilities() ]; } + private static AgentCapability[] SocialCapabilities() + { + return + [ + CreateCapability( + AgentCapabilityIds.SocialManage, + "Manage Social", + "Manages friendships, cheers, the friend feed, handles, blocking, and reporting. Cataloged but not exposed to the agent in this phase.", + "social", + AgentScopes.ManageSocial, + AgentRiskClass.Low, + isMutation: true, + isPhaseOneReadOnly: false, + AgentConfirmationRequirement.None, + controllerActions: + [ + "FriendsController.GetFriends", + "FriendsController.GetFeed", + "FriendsController.GetCheers", + "FriendsController.SendRequest", + "FriendsController.AcceptRequest", + "FriendsController.RemoveFriend", + "FriendsController.SendCheer", + "FriendsController.Block", + "FriendsController.Unblock", + "FriendsController.Report", + "ProfileController.SetHandle", + "ProfileController.SetSocialOptIn" + ]) + ]; + } + private static AgentCapability[] AccountAndAuthCapabilities() { return diff --git a/src/Orbit.Infrastructure/Services/DistributedRateLimitService.cs b/src/Orbit.Infrastructure/Services/DistributedRateLimitService.cs index 447e2414..832ffeb8 100644 --- a/src/Orbit.Infrastructure/Services/DistributedRateLimitService.cs +++ b/src/Orbit.Infrastructure/Services/DistributedRateLimitService.cs @@ -19,7 +19,13 @@ public class DistributedRateLimitService(OrbitDbContext dbContext, TimeProvider ["habit-suggest"] = new(TimeSpan.FromMinutes(1), PermitLimit: 15, SegmentCount: 4), ["support"] = new(TimeSpan.FromHours(1), PermitLimit: 3, SegmentCount: 1), ["uploads"] = new(TimeSpan.FromMinutes(1), PermitLimit: 30, SegmentCount: 4), - ["tag-suggest"] = new(TimeSpan.FromMinutes(1), PermitLimit: 15, SegmentCount: 4) + ["tag-suggest"] = new(TimeSpan.FromMinutes(1), PermitLimit: 15, SegmentCount: 4), + ["cheers"] = new(TimeSpan.FromHours(24), PermitLimit: 20, SegmentCount: 1), + ["friend-requests"] = new(TimeSpan.FromHours(24), PermitLimit: 30, SegmentCount: 1), + ["reports"] = new(TimeSpan.FromHours(24), PermitLimit: 20, SegmentCount: 1), + ["set-handle"] = new(TimeSpan.FromHours(24), PermitLimit: 5, SegmentCount: 1), + ["block"] = new(TimeSpan.FromHours(24), PermitLimit: 50, SegmentCount: 1), + ["unblock"] = new(TimeSpan.FromHours(24), PermitLimit: 50, SegmentCount: 1) }; public async Task TryAcquireAsync( diff --git a/src/Orbit.Infrastructure/Services/FeatureFlagService.cs b/src/Orbit.Infrastructure/Services/FeatureFlagService.cs index 68bead86..6fef55e3 100644 --- a/src/Orbit.Infrastructure/Services/FeatureFlagService.cs +++ b/src/Orbit.Infrastructure/Services/FeatureFlagService.cs @@ -1,11 +1,16 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; using Orbit.Infrastructure.Persistence; namespace Orbit.Infrastructure.Services; -public class FeatureFlagService(OrbitDbContext dbContext) : IFeatureFlagService +public class FeatureFlagService(OrbitDbContext dbContext, IMemoryCache cache) : IFeatureFlagService { + private const string EnabledFlagsCacheKey = "feature-flags:enabled"; + private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(30); + public async Task> GetEnabledKeysForUserAsync( Guid userId, CancellationToken cancellationToken = default) @@ -17,10 +22,7 @@ public async Task> GetEnabledKeysForUserAsync( if (user is null) return []; - var flags = await dbContext.AppFeatureFlags - .AsNoTracking() - .Where(item => item.Enabled) - .ToListAsync(cancellationToken); + var flags = await GetEnabledFlagsAsync(cancellationToken); return flags .Where(flag => string.IsNullOrWhiteSpace(flag.PlanRequirement) || UserHasFeaturePlan(user, flag.PlanRequirement)) @@ -29,7 +31,24 @@ public async Task> GetEnabledKeysForUserAsync( .ToList(); } - private static bool UserHasFeaturePlan(Orbit.Domain.Entities.User user, string planRequirement) + private async Task> GetEnabledFlagsAsync(CancellationToken cancellationToken) + { + if (cache.TryGetValue(EnabledFlagsCacheKey, out IReadOnlyList? cached) && cached is not null) + return cached; + + var flags = await dbContext.AppFeatureFlags + .AsNoTracking() + .Where(item => item.Enabled) + .Select(item => new EnabledFlag(item.Key, item.PlanRequirement)) + .ToListAsync(cancellationToken); + + cache.Set(EnabledFlagsCacheKey, (IReadOnlyList)flags, CacheTtl); + return flags; + } + + private sealed record EnabledFlag(string Key, string? PlanRequirement); + + private static bool UserHasFeaturePlan(User user, string planRequirement) { return planRequirement.Trim().ToLowerInvariant() switch { diff --git a/src/Orbit.Infrastructure/Services/Prompts/Sections/Static/CoreIdentitySection.cs b/src/Orbit.Infrastructure/Services/Prompts/Sections/Static/CoreIdentitySection.cs index 98eb3ea8..581f6eed 100644 --- a/src/Orbit.Infrastructure/Services/Prompts/Sections/Static/CoreIdentitySection.cs +++ b/src/Orbit.Infrastructure/Services/Prompts/Sections/Static/CoreIdentitySection.cs @@ -19,8 +19,9 @@ public string Build(PromptContext context) ### What You CAN Do: - **Converse** about habits, routines, productivity, wellness, goals, and life organization - - **Act immediately** when the user's intent is clear - create, log, update, complete, abandon, link, or delete habits and goals without asking for unnecessary confirmation - - **Ask questions** only when the request is genuinely ambiguous or missing critical details + - **Act directly** on clear, low-risk requests - create, log, update, complete, abandon, and link habits and goals - by calling the tool right away. Bias toward doing, not asking. + - **Trigger destructive and bulk actions too** - delete, bulk create, bulk delete, and bulk log or skip. These run through a confirmation card that Orbit shows the user automatically, so call the tool as usual, never add an "are you sure?" line, and never stall or refuse. The card is what gates the action. + - **Clarify only genuine ambiguity** - when a request is truly unclear or missing critical details, prefer one short inline question and let the clarification card with its quick-action chips be the safety net. - **Give advice** on habit building, routine design, consistency strategies, goal planning, and progress tracking """); return sb.ToString(); diff --git a/src/Orbit.Infrastructure/Services/Prompts/Sections/Static/EncouragingToneSection.cs b/src/Orbit.Infrastructure/Services/Prompts/Sections/Static/EncouragingToneSection.cs new file mode 100644 index 00000000..5d6ef76e --- /dev/null +++ b/src/Orbit.Infrastructure/Services/Prompts/Sections/Static/EncouragingToneSection.cs @@ -0,0 +1,20 @@ +using System.Text; + +namespace Orbit.Infrastructure.Services.Prompts.Sections.Static; + +public class EncouragingToneSection : IPromptSection +{ + public int Order => 150; + public bool ShouldInclude(PromptContext context) => true; + + public string Build(PromptContext context) + { + var sb = new StringBuilder(); + sb.AppendLine(""" + ## Tone and Encouragement + + Keep your voice warm, supportive, and concise. Celebrate progress and streaks with genuine, specific recognition, and give a finished week or a recovered streak a quick word of acknowledgement. Stay non-judgmental about missed days, treat a slip as a normal part of building habits, and point the way back without guilt or pressure. Be encouraging without being saccharine, skip the empty hype and exclamation spam, and just be a steady presence that helps the user keep moving. + """); + return sb.ToString(); + } +} diff --git a/src/Orbit.Infrastructure/Services/StreakFreezeAutoActivationService.cs b/src/Orbit.Infrastructure/Services/StreakFreezeAutoActivationService.cs index 8b153c6d..933fc534 100644 --- a/src/Orbit.Infrastructure/Services/StreakFreezeAutoActivationService.cs +++ b/src/Orbit.Infrastructure/Services/StreakFreezeAutoActivationService.cs @@ -12,7 +12,7 @@ namespace Orbit.Infrastructure.Services; /// -/// Auto-activates a streak freeze for a Pro user who held an active streak but logged +/// Auto-activates a streak freeze for an eligible user who held an active streak but logged /// nothing on their fully-elapsed local "yesterday". Inserting a /// row for the missed date is sufficient to preserve the streak: the presence-based /// resolver in treats any date carrying a freeze as covered, @@ -66,6 +66,10 @@ internal async Task ActivateMissedDayFreezes(CancellationToken ct) if (candidates.Count == 0) return; + var gamificationFreeTierEnabled = await dbContext.AppFeatureFlags + .AsNoTracking() + .AnyAsync(f => f.Key == FeatureFlagKeys.GamificationFreeTier && f.Enabled, ct); + var candidateIds = candidates.Select(u => u.Id).ToList(); var earliestMissed = utcYesterday.AddDays(-MaxTimeZoneSkewDays); @@ -85,7 +89,7 @@ internal async Task ActivateMissedDayFreezes(CancellationToken ct) var completionsByUser = await LoadRecentCompletionsAsync(dbContext, candidateIds, monthFloor, ct); foreach (var user in candidates) - await ProcessUserAsync(user, freezesByUser, guardedByUser, completionsByUser, pushService, dbContext, ct); + await ProcessUserAsync(user, gamificationFreeTierEnabled, freezesByUser, guardedByUser, completionsByUser, pushService, dbContext, ct); } private static async Task>> LoadRecentCompletionsAsync( @@ -121,6 +125,7 @@ private static async Task>> LoadRecentComplet private async Task ProcessUserAsync( User user, + bool gamificationFreeTierEnabled, Dictionary> freezesByUser, Dictionary> guardedByUser, Dictionary> completionsByUser, @@ -128,7 +133,7 @@ private async Task ProcessUserAsync( OrbitDbContext dbContext, CancellationToken ct) { - if (!user.HasProAccess) return; + if (!user.HasProAccess && !gamificationFreeTierEnabled) return; var tz = TimeZoneHelper.FindTimeZone(user.TimeZone, logger, user.Id); var userToday = DateOnly.FromDateTime(TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, tz)); diff --git a/src/Orbit.Infrastructure/Services/SystemPromptBuilder.cs b/src/Orbit.Infrastructure/Services/SystemPromptBuilder.cs index 9ff4a4dd..15381f7c 100644 --- a/src/Orbit.Infrastructure/Services/SystemPromptBuilder.cs +++ b/src/Orbit.Infrastructure/Services/SystemPromptBuilder.cs @@ -16,6 +16,7 @@ public SystemPromptBuilder() _staticSections = [ new CoreIdentitySection(), + new EncouragingToneSection(), new GlobalRulesSection(), new StructuringStrategySection(), new ClarificationGuidanceSection(), diff --git a/src/Orbit.Infrastructure/Services/UserStreakService.cs b/src/Orbit.Infrastructure/Services/UserStreakService.cs index a23ae066..d585e269 100644 --- a/src/Orbit.Infrastructure/Services/UserStreakService.cs +++ b/src/Orbit.Infrastructure/Services/UserStreakService.cs @@ -1,5 +1,6 @@ using Orbit.Application.Common; using Orbit.Application.Habits.Services; +using Orbit.Application.Social.Services; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; using Orbit.Domain.Models; @@ -11,7 +12,8 @@ public class UserStreakService( IGenericRepository habitRepository, IGenericRepository habitLogRepository, IGenericRepository streakFreezeRepository, - IUserDateService userDateService) : IUserStreakService + IUserDateService userDateService, + IFriendFeedEventEmitter friendFeedEventEmitter) : IUserStreakService { public async Task RecalculateAsync( Guid userId, @@ -24,6 +26,7 @@ public class UserStreakService( if (user is null) return null; + var previousStreak = user.CurrentStreak; var userToday = await userDateService.GetUserTodayAsync(userId, cancellationToken); var lookbackStart = userToday.AddDays(-AppConstants.MaxStreakLookbackDays); @@ -33,7 +36,9 @@ public class UserStreakService( var hasRecurring = contributingHabits.Any(h => h.FrequencyUnit is not null); if (!hasRecurring) { - return CalendarFallback(user, completionDateSet, freezeDateSet, awardFreezeIfEligible); + var fallbackState = CalendarFallback(user, completionDateSet, freezeDateSet, awardFreezeIfEligible); + await friendFeedEventEmitter.EmitStreakMilestonesAsync(user, previousStreak, cancellationToken); + return fallbackState; } var userTimeZone = TimeZoneHelper.FindTimeZone(user.TimeZone, userId: user.Id); @@ -53,6 +58,7 @@ public class UserStreakService( AppConstants.MaxStreakFreezesAccumulated, AppConstants.StreakDaysPerFreeze); } + await friendFeedEventEmitter.EmitStreakMilestonesAsync(user, previousStreak, cancellationToken); return new UserStreakState(currentStreak, longestStreak, lastActiveDate); } diff --git a/tests/Orbit.Application.Tests/Chat/Tools/ChecklistUserFactPlatformToolTests.cs b/tests/Orbit.Application.Tests/Chat/Tools/ChecklistUserFactPlatformToolTests.cs index 2a811987..6e3d7a14 100644 --- a/tests/Orbit.Application.Tests/Chat/Tools/ChecklistUserFactPlatformToolTests.cs +++ b/tests/Orbit.Application.Tests/Chat/Tools/ChecklistUserFactPlatformToolTests.cs @@ -352,7 +352,10 @@ public async Task GetGamificationOverviewTool_ReturnsSuccessForAllSections() [], 7, 10, - new DateOnly(2026, 4, 14)))); + new DateOnly(2026, 4, 14), + true, + false, + new NextRewardCarrot(3, "Climber", 25, null)))); mediator.Send(Arg.Any(), Arg.Any()) .Returns(Result.Success(new AchievementsResponse([]))); mediator.Send(Arg.Any(), Arg.Any()) @@ -369,7 +372,7 @@ public async Task GetGamificationOverviewTool_ReturnsFailureWhenAchievementsFail { var mediator = Substitute.For(); mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success(new GamificationProfileResponse(0, 1, "Starter", 0, 10, 10, 0, 1, [], [], 0, 0, null))); + .Returns(Result.Success(new GamificationProfileResponse(0, 1, "Starter", 0, 10, 10, 0, 1, [], [], 0, 0, null, true, false, new NextRewardCarrot(2, "Explorer", 10, null)))); mediator.Send(Arg.Any(), Arg.Any()) .Returns(Result.Failure("achievements_failed")); var tool = new GetGamificationOverviewTool(mediator); @@ -385,7 +388,7 @@ public async Task GetGamificationOverviewTool_ReturnsFailureWhenStreakFails() { var mediator = Substitute.For(); mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success(new GamificationProfileResponse(0, 1, "Starter", 0, 10, 10, 0, 1, [], [], 0, 0, null))); + .Returns(Result.Success(new GamificationProfileResponse(0, 1, "Starter", 0, 10, 10, 0, 1, [], [], 0, 0, null, true, false, new NextRewardCarrot(2, "Explorer", 10, null)))); mediator.Send(Arg.Any(), Arg.Any()) .Returns(Result.Success(new AchievementsResponse([]))); mediator.Send(Arg.Any(), Arg.Any()) diff --git a/tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.cs index 939c1e8d..69bd998d 100644 --- a/tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.cs @@ -37,6 +37,7 @@ public class ProcessUserChatCommandHandlerTests private readonly IAgentCatalogService _catalogService = Substitute.For(); private readonly IAgentOperationExecutor _operationExecutor = Substitute.For(); private readonly IPendingClarificationStore _pendingClarificationStore = Substitute.For(); + private readonly IGamificationService _gamificationService = Substitute.For(); private readonly ILogger _logger = Substitute.For>(); private static readonly Guid UserId = Guid.NewGuid(); @@ -64,7 +65,7 @@ private ProcessUserChatCommandHandler CreateHandler(params IAiTool[] tools) var aiDeps = new ChatAiDependencies(_aiIntentService, toolRegistry, _promptBuilder, _catalogService); var dataDeps = new ChatDataDependencies(_habitRepo, _goalRepo, _userRepo, _userFactRepo, _tagRepo, _checklistTemplateRepo, _featureFlagService); var executionDeps = new ChatExecutionDependencies( - _userDateService, _userStreakService, _payGate, _unitOfWork, _scopeFactory, _operationExecutor, _pendingClarificationStore, _streakGoalReadSyncer); + _userDateService, _userStreakService, _payGate, _unitOfWork, _scopeFactory, _operationExecutor, _pendingClarificationStore, _streakGoalReadSyncer, _gamificationService); return new ProcessUserChatCommandHandler( dataDeps, aiDeps, executionDeps, _logger); @@ -287,6 +288,20 @@ public async Task Handle_SuccessfulResponse_ReturnsChatResponse() result.Value.Actions.Should().BeEmpty(); } + [Fact] + public async Task Handle_SuccessfulTurn_FiresOnboardingAstraUsedSignal() + { + SetupUserAndPayGate(); + SetupAiResponse(new AiResponse { TextMessage = "Hello! How can I help?", ToolCalls = null }); + var handler = CreateHandler(); + + var command = new ProcessUserChatCommand(UserId, "Hello AI"); + await handler.Handle(command, CancellationToken.None); + + await _gamificationService.Received(1).ProcessOnboardingChecklistAsync( + UserId, OnboardingChecklistSignal.AstraUsed, Arg.Any()); + } + [Fact] public async Task Handle_CapableClientWithDirective_PopulatesHabitListAndStripsToken() { diff --git a/tests/Orbit.Application.Tests/Commands/Habits/BulkCreateHabitsCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Habits/BulkCreateHabitsCommandHandlerTests.cs index 2e80a870..1d412626 100644 --- a/tests/Orbit.Application.Tests/Commands/Habits/BulkCreateHabitsCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Habits/BulkCreateHabitsCommandHandlerTests.cs @@ -17,6 +17,7 @@ public class BulkCreateHabitsCommandHandlerTests { private readonly IGenericRepository _habitRepo = Substitute.For>(); private readonly IGenericRepository _suggestionRepo = Substitute.For>(); + private readonly IGenericRepository _tagRepo = Substitute.For>(); private readonly IPayGateService _payGate = Substitute.For(); private readonly IUserDateService _userDateService = Substitute.For(); private readonly IUnitOfWork _unitOfWork = Substitute.For(); @@ -29,9 +30,12 @@ public class BulkCreateHabitsCommandHandlerTests public BulkCreateHabitsCommandHandlerTests() { _handler = new BulkCreateHabitsCommandHandler( - _habitRepo, _suggestionRepo, _payGate, _userDateService, _unitOfWork, _cache, + _habitRepo, _suggestionRepo, _tagRepo, _payGate, _userDateService, _unitOfWork, _cache, Substitute.For>()); + _tagRepo.FindTrackedAsync(Arg.Any>>(), Arg.Any()) + .Returns(new List().AsReadOnly()); + _payGate.CanCreateHabits(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Result.Success()); _payGate.CanCreateSubHabits(Arg.Any(), Arg.Any()) @@ -241,4 +245,90 @@ public async Task Handle_NoSubHabits_DoesNotCheckSubHabitGate() await _payGate.DidNotReceive().CanCreateSubHabits(Arg.Any(), Arg.Any()); } + + private (List AddedTags, List AddedHabits) CaptureTagAndHabitAdds() + { + var addedTags = new List(); + _tagRepo.AddAsync(Arg.Any(), Arg.Any()) + .Returns(call => + { + addedTags.Add(call.Arg()); + return Task.CompletedTask; + }); + + var addedHabits = new List(); + _habitRepo.AddAsync(Arg.Any(), Arg.Any()) + .Returns(call => + { + addedHabits.Add(call.Arg()); + return Task.CompletedTask; + }); + + return (addedTags, addedHabits); + } + + [Fact] + public async Task Handle_WithTags_CreatesAndAttachesNewTagsWithDefaultColor() + { + var (addedTags, addedHabits) = CaptureTagAndHabitAdds(); + + var items = new List + { + new("Run", null, FrequencyUnit.Day, 1, Tags: new List { "Fitness", "Health" }) + }; + var command = new BulkCreateHabitsCommand(UserId, items); + + var result = await _handler.Handle(command, CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + addedTags.Should().HaveCount(2); + addedTags.Select(t => t.Name).Should().BeEquivalentTo(new[] { "Fitness", "Health" }); + addedTags.Should().AllSatisfy(t => t.Color.Should().Be("#7c3aed")); + addedHabits.Should().ContainSingle(); + addedHabits[0].Tags.Should().HaveCount(2); + } + + [Fact] + public async Task Handle_SharedTagNameAcrossItems_DedupesToSingleTagSharedByBothHabits() + { + var (addedTags, addedHabits) = CaptureTagAndHabitAdds(); + + var items = new List + { + new("Run", null, FrequencyUnit.Day, 1, Tags: new List { "Health" }), + new("Sleep", null, FrequencyUnit.Day, 1, Tags: new List { "health" }) + }; + var command = new BulkCreateHabitsCommand(UserId, items); + + var result = await _handler.Handle(command, CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + addedTags.Should().ContainSingle(); + addedTags[0].Name.Should().Be("Health"); + addedHabits.Should().HaveCount(2); + addedHabits[0].Tags.Single().Should().BeSameAs(addedHabits[1].Tags.Single()); + } + + [Fact] + public async Task Handle_ExistingTagName_ReusesExistingTagWithoutCreating() + { + var existingTag = Tag.Create(UserId, "Health", "#123456").Value; + _tagRepo.FindTrackedAsync(Arg.Any>>(), Arg.Any()) + .Returns(new List { existingTag }.AsReadOnly()); + + var (addedTags, addedHabits) = CaptureTagAndHabitAdds(); + + var items = new List + { + new("Run", null, FrequencyUnit.Day, 1, Tags: new List { "health" }) + }; + var command = new BulkCreateHabitsCommand(UserId, items); + + var result = await _handler.Handle(command, CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + addedTags.Should().BeEmpty(); + addedHabits.Should().ContainSingle(); + addedHabits[0].Tags.Single().Should().BeSameAs(existingTag); + } } diff --git a/tests/Orbit.Application.Tests/Commands/Habits/CreateHabitCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Habits/CreateHabitCommandHandlerTests.cs index 0aa37372..36d909ea 100644 --- a/tests/Orbit.Application.Tests/Commands/Habits/CreateHabitCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Habits/CreateHabitCommandHandlerTests.cs @@ -62,6 +62,17 @@ await _habitRepo.Received(1).AddAsync( await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any()); } + [Fact] + public async Task Handle_ValidCommand_FiresOnboardingHabitCreatedSignal() + { + var command = new CreateHabitCommand(UserId, "Read", null, FrequencyUnit.Day, 1); + + await _handler.Handle(command, CancellationToken.None); + + await _gamificationService.Received(1).ProcessOnboardingChecklistAsync( + UserId, OnboardingChecklistSignal.HabitCreated, Arg.Any()); + } + [Fact] public async Task Handle_FirstRootHabit_AssignsPositionZero() { diff --git a/tests/Orbit.Application.Tests/Commands/Habits/LogHabitCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Habits/LogHabitCommandHandlerTests.cs index 2f67db63..05fd2425 100644 --- a/tests/Orbit.Application.Tests/Commands/Habits/LogHabitCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Habits/LogHabitCommandHandlerTests.cs @@ -81,6 +81,22 @@ await _habitLogRepo.Received(1).AddAsync( await _unitOfWork.Received(2).SaveChangesAsync(Arg.Any()); } + [Fact] + public async Task Handle_SuccessfulLog_FiresOnboardingHabitLoggedSignal() + { + var habit = CreateTestHabit(); + _habitRepo.FindOneTrackedAsync( + Arg.Any>>(), + Arg.Any, IQueryable>?>(), + Arg.Any()) + .Returns(habit); + + await _handler.Handle(new LogHabitCommand(UserId, habit.Id), CancellationToken.None); + + await _gamificationService.Received(1).ProcessOnboardingChecklistAsync( + UserId, OnboardingChecklistSignal.HabitLogged, Arg.Any()); + } + [Fact] public async Task Handle_ConcurrencyConflictOnLogCommit_ReloadsHabitAndRetries() { diff --git a/tests/Orbit.Application.Tests/Gamification/AchievementDefinitionsTests.cs b/tests/Orbit.Application.Tests/Gamification/AchievementDefinitionsTests.cs index 239ceca3..40b8f10b 100644 --- a/tests/Orbit.Application.Tests/Gamification/AchievementDefinitionsTests.cs +++ b/tests/Orbit.Application.Tests/Gamification/AchievementDefinitionsTests.cs @@ -6,9 +6,9 @@ namespace Orbit.Application.Tests.Gamification; public class AchievementDefinitionsTests { [Fact] - public void All_Has25Achievements() + public void All_Has29Achievements() { - AchievementDefinitions.All.Should().HaveCount(25); + AchievementDefinitions.All.Should().HaveCount(29); } [Fact] @@ -87,23 +87,45 @@ public void Rarities_MultipleRepresented() } [Fact] - public void GettingStartedCategory_Has3Achievements() + public void GettingStartedCategory_Has4Achievements() { var gettingStarted = AchievementDefinitions.All .Where(a => a.Category == Domain.Enums.AchievementCategory.GettingStarted) .ToList(); - gettingStarted.Should().HaveCount(3); + gettingStarted.Should().HaveCount(4); } [Fact] - public void ConsistencyCategory_Has6Achievements() + public void ConsistencyCategory_Has8Achievements() { var consistency = AchievementDefinitions.All .Where(a => a.Category == Domain.Enums.AchievementCategory.Consistency) .ToList(); - consistency.Should().HaveCount(6); + consistency.Should().HaveCount(8); + } + + [Theory] + [InlineData(AchievementDefinitions.HalfYearHero, "Half-Year Hero", Domain.Enums.AchievementCategory.Consistency, Domain.Enums.AchievementRarity.Epic, 350)] + [InlineData(AchievementDefinitions.StreakTitan, "Streak Titan", Domain.Enums.AchievementCategory.Consistency, Domain.Enums.AchievementRarity.Legendary, 750)] + [InlineData(AchievementDefinitions.FirstCheer, "Good Vibes", Domain.Enums.AchievementCategory.Special, Domain.Enums.AchievementRarity.Common, 50)] + [InlineData(AchievementDefinitions.OnboardingComplete, "All Systems Go", Domain.Enums.AchievementCategory.GettingStarted, Domain.Enums.AchievementRarity.Common, 50)] + public void NewAchievements_HaveExpectedMetadata( + string id, + string expectedName, + Domain.Enums.AchievementCategory expectedCategory, + Domain.Enums.AchievementRarity expectedRarity, + int expectedXp) + { + var definition = AchievementDefinitions.GetById(id); + + definition.Should().NotBeNull(); + definition!.Name.Should().Be(expectedName); + definition.Category.Should().Be(expectedCategory); + definition.Rarity.Should().Be(expectedRarity); + definition.XpReward.Should().Be(expectedXp); + definition.IconKey.Should().Be(id); } [Fact] diff --git a/tests/Orbit.Application.Tests/Gamification/LevelDefinitionsTests.cs b/tests/Orbit.Application.Tests/Gamification/LevelDefinitionsTests.cs index facd8f3b..7c042c00 100644 --- a/tests/Orbit.Application.Tests/Gamification/LevelDefinitionsTests.cs +++ b/tests/Orbit.Application.Tests/Gamification/LevelDefinitionsTests.cs @@ -36,7 +36,10 @@ public void All_LevelsAreOrdered() [InlineData(4000, 8, "Admiral")] [InlineData(6000, 9, "Elite")] [InlineData(10_000, 10, "Legend")] - [InlineData(50_000, 10, "Legend")] + [InlineData(12_099, 10, "Legend")] + [InlineData(12_100, 11, "Legend")] + [InlineData(40_000, 20, "Legend")] + [InlineData(50_000, 22, "Legend")] public void GetLevelForXp_ReturnsCorrectLevel(int xp, int expectedLevel, string expectedTitle) { var level = LevelDefinitions.GetLevelForXp(xp); @@ -46,21 +49,38 @@ public void GetLevelForXp_ReturnsCorrectLevel(int xp, int expectedLevel, string } [Theory] - [InlineData(0, 100)] [InlineData(50, 50)] [InlineData(100, 200)] [InlineData(250, 50)] [InlineData(9999, 1)] public void GetXpToNextLevel_ReturnsCorrectXpNeeded(int xp, int expectedXpToNext) + [InlineData(0, 100)] + [InlineData(50, 50)] + [InlineData(100, 200)] + [InlineData(250, 50)] + [InlineData(9999, 1)] + [InlineData(10_000, 2_100)] + [InlineData(12_100, 2_300)] + [InlineData(50_000, 2_900)] + public void GetXpToNextLevel_ReturnsCorrectXpNeeded(int xp, int expectedXpToNext) { var xpToNext = LevelDefinitions.GetXpToNextLevel(xp); xpToNext.Should().Be(expectedXpToNext); } - [Theory] - [InlineData(10_000)] - [InlineData(50_000)] - public void GetXpToNextLevel_AtMaxLevel_ReturnsNull(int xp) + [Fact] + public void XpCurve_IsContinuousAtLevel10() { - var xpToNext = LevelDefinitions.GetXpToNextLevel(xp); + LevelDefinitions.XpRequiredForLevel(10).Should().Be(10_000); + LevelDefinitions.All[9].XpRequired.Should().Be(10_000); + } - xpToNext.Should().BeNull(); + [Fact] + public void XpCurve_SecondDifferenceIsConstant200_PastLevel10() + { + for (var level = 10; level <= 19; level++) + { + var increment = LevelDefinitions.XpRequiredForLevel(level + 1) - LevelDefinitions.XpRequiredForLevel(level); + var nextIncrement = LevelDefinitions.XpRequiredForLevel(level + 2) - LevelDefinitions.XpRequiredForLevel(level + 1); + + (nextIncrement - increment).Should().Be(200); + } } [Fact] diff --git a/tests/Orbit.Application.Tests/Queries/Gamification/GetGamificationProfileQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Gamification/GetGamificationProfileQueryHandlerTests.cs index 8bbed1ed..cfc36fc5 100644 --- a/tests/Orbit.Application.Tests/Queries/Gamification/GetGamificationProfileQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/Gamification/GetGamificationProfileQueryHandlerTests.cs @@ -1,5 +1,6 @@ using FluentAssertions; using NSubstitute; +using Orbit.Application.Common; using Orbit.Application.Gamification; using Orbit.Application.Gamification.Queries; using Orbit.Domain.Entities; @@ -12,13 +13,22 @@ public class GetGamificationProfileQueryHandlerTests { private readonly IGenericRepository _userRepo = Substitute.For>(); private readonly IGenericRepository _achievementRepo = Substitute.For>(); + private readonly IFeatureFlagService _featureFlagService = Substitute.For(); private readonly GetGamificationProfileQueryHandler _handler; private static readonly Guid UserId = Guid.NewGuid(); public GetGamificationProfileQueryHandlerTests() { - _handler = new GetGamificationProfileQueryHandler(_userRepo, _achievementRepo); + _featureFlagService.GetEnabledKeysForUserAsync(Arg.Any(), Arg.Any()) + .Returns(Array.Empty()); + _handler = new GetGamificationProfileQueryHandler(_userRepo, _achievementRepo, _featureFlagService); + } + + private void EnableFreeTierFlag() + { + _featureFlagService.GetEnabledKeysForUserAsync(Arg.Any(), Arg.Any()) + .Returns(new[] { FeatureFlagKeys.GamificationFreeTier }); } private static User CreateProUser() @@ -84,10 +94,33 @@ public async Task Handle_ProUser_CalculatesXpToNextLevel() result.Value.XpToNextLevel.Should().Be(100); } [Fact] - public async Task Handle_MaxLevel_XpToNextLevelIsNull() + public async Task Handle_AtLevel10_ReturnsInfiniteNextLevel() + { + var user = CreateProUser(); + user.AddXp(10_000); + _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); + + _achievementRepo.FindAsync( + Arg.Any>>(), + Arg.Any()) + .Returns(new List()); + + var query = new GetGamificationProfileQuery(UserId); + + var result = await _handler.Handle(query, CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Level.Should().Be(10); + result.Value.XpToNextLevel.Should().Be(2_100); + result.Value.XpForNextLevel.Should().Be(12_100); + } + + [Fact] + public async Task Handle_ProUserPast10_ComputesInfiniteNextLevel() { var user = CreateProUser(); - user.AddXp(10_000); _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); + user.AddXp(15_000); + _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); _achievementRepo.FindAsync( Arg.Any>>(), @@ -99,11 +132,14 @@ public async Task Handle_MaxLevel_XpToNextLevelIsNull() var result = await _handler.Handle(query, CancellationToken.None); result.IsSuccess.Should().BeTrue(); - result.Value.XpToNextLevel.Should().BeNull(); + result.Value.Level.Should().Be(12); + result.Value.LevelTitle.Should().Be("Legend"); + result.Value.XpToNextLevel.Should().Be(1_900); + result.Value.IsPro.Should().BeTrue(); } [Fact] - public async Task Handle_FreeUser_ReturnsPayGateFailure() + public async Task Handle_FreeUser_FlagOff_ReturnsPayGateFailure() { var user = CreateFreeUser(); _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); @@ -116,6 +152,64 @@ public async Task Handle_FreeUser_ReturnsPayGateFailure() result.ErrorCode.Should().Be("PAY_GATE"); } + [Fact] + public async Task Handle_FreeUser_FlagOn_ExposesXpLevelStreak_HidesAchievements() + { + var user = CreateFreeUser(); + user.AddXp(150); + user.SetStreakState(5, 12, new DateOnly(2026, 6, 20)); + EnableFreeTierFlag(); + _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); + + _achievementRepo.FindAsync( + Arg.Any>>(), + Arg.Any()) + .Returns(new List { UserAchievement.Create(UserId, AchievementDefinitions.FirstOrbit) }); + + var query = new GetGamificationProfileQuery(UserId); + + var result = await _handler.Handle(query, CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.TotalXp.Should().Be(150); + result.Value.Level.Should().Be(2); + result.Value.CurrentStreak.Should().Be(5); + result.Value.LongestStreak.Should().Be(12); + result.Value.IsPro.Should().BeFalse(); + result.Value.AchievementsLocked.Should().BeTrue(); + result.Value.Achievements.Should().BeEmpty(); + result.Value.AchievementsEarned.Should().Be(0); + result.Value.AchievementsTotal.Should().Be(AchievementDefinitions.All.Count); + result.Value.NextReward.ProTeaser.Should().NotBeNull(); + result.Value.NextReward.ProTeaser!.Kind.Should().Be("achievements"); + result.Value.NextReward.ProTeaser.Locked.Should().BeTrue(); + result.Value.NextReward.NextLevel.Should().Be(3); + } + + [Fact] + public async Task Handle_ProUser_FlagOff_ReturnsFullProfile_WithAchievements_NoTeaser() + { + var user = CreateProUser(); + user.AddXp(150); + _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); + + _achievementRepo.FindAsync( + Arg.Any>>(), + Arg.Any()) + .Returns(new List { UserAchievement.Create(UserId, AchievementDefinitions.FirstOrbit) }); + + var query = new GetGamificationProfileQuery(UserId); + + var result = await _handler.Handle(query, CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.IsPro.Should().BeTrue(); + result.Value.AchievementsLocked.Should().BeFalse(); + result.Value.Achievements.Should().NotBeEmpty(); + result.Value.AchievementsEarned.Should().Be(1); + result.Value.NextReward.ProTeaser.Should().BeNull(); + } + [Fact] public async Task Handle_UserNotFound_ReturnsFailure() { diff --git a/tests/Orbit.Application.Tests/Queries/Gamification/GetRecapQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Gamification/GetRecapQueryHandlerTests.cs new file mode 100644 index 00000000..751a2379 --- /dev/null +++ b/tests/Orbit.Application.Tests/Queries/Gamification/GetRecapQueryHandlerTests.cs @@ -0,0 +1,123 @@ +using FluentAssertions; +using MediatR; +using Microsoft.Extensions.Options; +using NSubstitute; +using Orbit.Application.Common; +using Orbit.Application.Gamification.Queries; +using Orbit.Application.Habits.Services; +using Orbit.Application.Referrals.Commands; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Enums; +using Orbit.Domain.Interfaces; +using Orbit.Domain.Models; +using System.Linq.Expressions; + +namespace Orbit.Application.Tests.Queries.Gamification; + +public class GetRecapQueryHandlerTests +{ + private readonly IGenericRepository _habitRepo = Substitute.For>(); + private readonly IUserStreakService _userStreakService = Substitute.For(); + private readonly IMediator _mediator = Substitute.For(); + private readonly GetRecapQueryHandler _handler; + + private static readonly Guid UserId = Guid.NewGuid(); + private static readonly DateOnly DateTo = new(2026, 6, 20); + private static readonly DateOnly DateFrom = DateTo.AddDays(-6); + private const string ReferralCode = "ABCD2345"; + + public GetRecapQueryHandlerTests() + { + var frontendSettings = Options.Create(new FrontendSettings { BaseUrl = "https://app.useorbit.org" }); + _handler = new GetRecapQueryHandler(_habitRepo, _userStreakService, frontendSettings, _mediator); + + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Result.Success(ReferralCode)); + } + + private void StubHabits(params Habit[] habits) + { + _habitRepo.FindAsync( + Arg.Any>>(), + Arg.Any, IQueryable>?>(), + Arg.Any()) + .Returns(habits.ToList().AsReadOnly()); + } + + private static Habit CreateLoggedDailyHabit() + { + var habit = Habit.Create(new HabitCreateParams( + UserId, "Read", FrequencyUnit.Day, 1, DueDate: DateFrom)).Value; + habit.Log(DateFrom); + habit.Log(DateFrom.AddDays(1)); + habit.Log(DateFrom.AddDays(2)); + return habit; + } + + [Fact] + public async Task Handle_ComputesMetrics_MatchingCalculator() + { + var habit = CreateLoggedDailyHabit(); + StubHabits(habit); + _userStreakService.RecalculateAsync(UserId, Arg.Any(), awardFreezeIfEligible: false) + .Returns(new UserStreakState(7, 20, DateTo)); + + var query = new GetRecapQuery(UserId, DateFrom, DateTo, "week"); + + var result = await _handler.Handle(query, CancellationToken.None); + + var expected = RetrospectiveMetricsCalculator.Compute( + new List { habit }, DateFrom, DateTo, 7, 20); + + result.IsSuccess.Should().BeTrue(); + result.Value.Period.Should().Be("week"); + result.Value.Metrics.Should().BeEquivalentTo(expected); + } + + [Fact] + public async Task Handle_ShareDeepLink_ContainsReferralCodeAndPeriod() + { + StubHabits(CreateLoggedDailyHabit()); + _userStreakService.RecalculateAsync(UserId, Arg.Any(), awardFreezeIfEligible: false) + .Returns(new UserStreakState(1, 1, DateTo)); + + var query = new GetRecapQuery(UserId, DateFrom, DateTo, "month"); + + var result = await _handler.Handle(query, CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.ShareDeepLink.Should().Be("https://app.useorbit.org/r/ABCD2345?recap=month"); + } + + [Fact] + public async Task Handle_EmptyPeriod_ReturnsZeroedMetrics_NotFailure() + { + StubHabits(); + _userStreakService.RecalculateAsync(UserId, Arg.Any(), awardFreezeIfEligible: false) + .Returns((UserStreakState?)null); + + var query = new GetRecapQuery(UserId, DateFrom, DateTo, "week"); + + var result = await _handler.Handle(query, CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Metrics.TotalCompletions.Should().Be(0); + result.Value.Metrics.CompletionRate.Should().Be(0); + result.Value.Metrics.CurrentStreak.Should().Be(0); + result.Value.Metrics.TopHabits.Should().BeEmpty(); + } + + [Fact] + public async Task Handle_ReferralCommandFails_PropagatesFailure() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Result.Failure(ErrorMessages.UserNotFound)); + + var query = new GetRecapQuery(UserId, DateFrom, DateTo, "week"); + + var result = await _handler.Handle(query, CancellationToken.None); + + result.IsFailure.Should().BeTrue(); + } +} diff --git a/tests/Orbit.Application.Tests/Queries/Gamification/GetRecapQueryValidatorTests.cs b/tests/Orbit.Application.Tests/Queries/Gamification/GetRecapQueryValidatorTests.cs new file mode 100644 index 00000000..7f1fe807 --- /dev/null +++ b/tests/Orbit.Application.Tests/Queries/Gamification/GetRecapQueryValidatorTests.cs @@ -0,0 +1,44 @@ +using FluentAssertions; +using Orbit.Application.Gamification.Queries; + +namespace Orbit.Application.Tests.Queries.Gamification; + +public class GetRecapQueryValidatorTests +{ + private readonly GetRecapQueryValidator _validator = new(); + + private static readonly DateOnly DateTo = new(2026, 6, 20); + private static readonly DateOnly DateFrom = DateTo.AddDays(-6); + + [Theory] + [InlineData("week")] + [InlineData("month")] + [InlineData("quarter")] + [InlineData("semester")] + [InlineData("year")] + public void Validate_AllowedPeriod_Passes(string period) + { + var result = _validator.Validate(new GetRecapQuery(Guid.NewGuid(), DateFrom, DateTo, period)); + + result.IsValid.Should().BeTrue(); + } + + [Theory] + [InlineData("day")] + [InlineData("decade")] + [InlineData("")] + public void Validate_DisallowedPeriod_Fails(string period) + { + var result = _validator.Validate(new GetRecapQuery(Guid.NewGuid(), DateFrom, DateTo, period)); + + result.IsValid.Should().BeFalse(); + } + + [Fact] + public void Validate_DateFromAfterDateTo_Fails() + { + var result = _validator.Validate(new GetRecapQuery(Guid.NewGuid(), DateTo.AddDays(1), DateTo, "week")); + + result.IsValid.Should().BeFalse(); + } +} diff --git a/tests/Orbit.Application.Tests/Queries/Gamification/GetStreakInfoQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Gamification/GetStreakInfoQueryHandlerTests.cs index 01513352..8b223319 100644 --- a/tests/Orbit.Application.Tests/Queries/Gamification/GetStreakInfoQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/Gamification/GetStreakInfoQueryHandlerTests.cs @@ -1,5 +1,6 @@ using FluentAssertions; using NSubstitute; +using Orbit.Application.Common; using Orbit.Application.Gamification.Queries; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; @@ -14,6 +15,7 @@ public class GetStreakInfoQueryHandlerTests private readonly IGenericRepository _streakFreezeRepo = Substitute.For>(); private readonly IUserDateService _userDateService = Substitute.For(); private readonly IUserStreakService _userStreakService = Substitute.For(); + private readonly IFeatureFlagService _featureFlagService = Substitute.For(); private readonly IUnitOfWork _unitOfWork = Substitute.For(); private readonly GetStreakInfoQueryHandler _handler; @@ -22,8 +24,10 @@ public class GetStreakInfoQueryHandlerTests public GetStreakInfoQueryHandlerTests() { + _featureFlagService.GetEnabledKeysForUserAsync(Arg.Any(), Arg.Any()) + .Returns(Array.Empty()); _handler = new GetStreakInfoQueryHandler( - _userRepo, _streakFreezeRepo, _userDateService, _userStreakService, _unitOfWork); + _userRepo, _streakFreezeRepo, _userDateService, _userStreakService, _featureFlagService, _unitOfWork); _userDateService.GetUserTodayAsync(UserId, Arg.Any()).Returns(Today); } @@ -32,6 +36,19 @@ private static User CreateTestUser() return User.Create("Test User", "test@example.com").Value; } + private static User CreateFreeUser() + { + var user = User.Create("Test User", "test@example.com").Value; + user.StartTrial(DateTime.UtcNow.AddDays(-1)); + return user; + } + + private void EnableFreeTierFlag() + { + _featureFlagService.GetEnabledKeysForUserAsync(Arg.Any(), Arg.Any()) + .Returns(new[] { FeatureFlagKeys.GamificationFreeTier }); + } + [Fact] public async Task Handle_UserFound_ReturnsStreakInfo() { @@ -175,4 +192,32 @@ public async Task Handle_AllFreezesUsed_ReturnsZeroAvailable() result.Value.FreezesAvailable.Should().Be(0); result.Value.FreezesUsedThisMonth.Should().Be(3); } + + [Fact] + public async Task Handle_FreeUser_FlagOff_ReturnsPayGate() + { + var user = CreateFreeUser(); + _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); + + var result = await _handler.Handle(new GetStreakInfoQuery(UserId), CancellationToken.None); + + result.IsFailure.Should().BeTrue(); + result.ErrorCode.Should().Be("PAY_GATE"); + } + + [Fact] + public async Task Handle_FreeUser_FlagOn_ReturnsStreakInfo() + { + var user = CreateFreeUser(); + EnableFreeTierFlag(); + _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); + _streakFreezeRepo.FindAsync( + Arg.Any>>(), + Arg.Any()) + .Returns(new List().AsReadOnly()); + + var result = await _handler.Handle(new GetStreakInfoQuery(UserId), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + } } diff --git a/tests/Orbit.Application.Tests/Queries/Habits/RetrospectivePeriodRangeTests.cs b/tests/Orbit.Application.Tests/Queries/Habits/RetrospectivePeriodRangeTests.cs new file mode 100644 index 00000000..cde5c76b --- /dev/null +++ b/tests/Orbit.Application.Tests/Queries/Habits/RetrospectivePeriodRangeTests.cs @@ -0,0 +1,50 @@ +using FluentAssertions; +using Orbit.Application.Habits.Queries; + +namespace Orbit.Application.Tests.Queries.Habits; + +public class RetrospectivePeriodRangeTests +{ + private static readonly DateOnly Today = new(2026, 6, 20); + + [Theory] + [InlineData("week")] + [InlineData("month")] + [InlineData("quarter")] + [InlineData("semester")] + [InlineData("year")] + public void Resolve_KnownPeriod_WindowEndsToday(string period) + { + var (dateFrom, dateTo) = RetrospectivePeriodRange.Resolve(period, Today, weekStartDay: 1); + + dateTo.Should().Be(Today); + dateFrom.Should().BeOnOrBefore(Today); + } + + [Fact] + public void Resolve_UnknownPeriod_Throws() + { + var act = () => RetrospectivePeriodRange.Resolve("decade", Today, weekStartDay: 1); + + act.Should().Throw(); + } + + [Theory] + [InlineData("week")] + [InlineData("month")] + [InlineData("quarter")] + [InlineData("semester")] + [InlineData("year")] + [InlineData("WEEK")] + [InlineData("Year")] + public void IsKnownPeriod_KnownPeriod_ReturnsTrue(string period) => + RetrospectivePeriodRange.IsKnownPeriod(period).Should().BeTrue(); + + [Theory] + [InlineData("day")] + [InlineData("decade")] + [InlineData("")] + [InlineData(null)] + public void IsKnownPeriod_UnknownPeriod_ReturnsFalse(string? period) => + RetrospectivePeriodRange.IsKnownPeriod(period).Should().BeFalse(); +} diff --git a/tests/Orbit.Application.Tests/Queries/Profile/ExportUserDataQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Profile/ExportUserDataQueryHandlerTests.cs index 7c4db077..b8ff00db 100644 --- a/tests/Orbit.Application.Tests/Queries/Profile/ExportUserDataQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/Profile/ExportUserDataQueryHandlerTests.cs @@ -24,6 +24,11 @@ public class ExportUserDataQueryHandlerTests private readonly IGenericRepository _streakFreezeRepo = Substitute.For>(); private readonly IGenericRepository _referralRepo = Substitute.For>(); private readonly IGenericRepository _apiKeyRepo = Substitute.For>(); + private readonly IGenericRepository _friendshipRepo = Substitute.For>(); + private readonly IGenericRepository _cheerRepo = Substitute.For>(); + private readonly IGenericRepository _blockedUserRepo = Substitute.For>(); + private readonly IGenericRepository _reportRepo = Substitute.For>(); + private readonly IGenericRepository _friendFeedEventRepo = Substitute.For>(); private readonly IUserDateService _userDateService = Substitute.For(); private readonly IStreakGoalReadSyncer _streakGoalReadSyncer = Substitute.For(); private readonly ExportUserDataQueryHandler _handler; @@ -36,6 +41,7 @@ public ExportUserDataQueryHandlerTests() _handler = new ExportUserDataQueryHandler( _userRepo, _habitRepo, _habitLogRepo, _goalRepo, _goalProgressLogRepo, _tagRepo, _userFactRepo, _notificationRepo, _checklistTemplateRepo, _userAchievementRepo, _streakFreezeRepo, _referralRepo, _apiKeyRepo, + _friendshipRepo, _cheerRepo, _blockedUserRepo, _reportRepo, _friendFeedEventRepo, _userDateService, _streakGoalReadSyncer); _userDateService.GetUserTodayAsync(UserId, Arg.Any()).Returns(Today); @@ -54,6 +60,11 @@ public ExportUserDataQueryHandlerTests() ReturnsEmpty(_streakFreezeRepo); ReturnsEmpty(_referralRepo); ReturnsEmpty(_apiKeyRepo); + ReturnsEmpty(_friendshipRepo); + ReturnsEmpty(_cheerRepo); + ReturnsEmpty(_blockedUserRepo); + ReturnsEmpty(_reportRepo); + ReturnsEmpty(_friendFeedEventRepo); } private static void ReturnsEmpty(IGenericRepository repository) where T : Orbit.Domain.Common.Entity @@ -108,6 +119,34 @@ public async Task Handle_EmptyAccount_ReturnsAccountSettingsAndEmptyCollections( result.Value.Goals.Should().BeEmpty(); result.Value.Tags.Should().BeEmpty(); result.Value.Facts.Should().BeEmpty(); + result.Value.Friendships.Should().BeEmpty(); + result.Value.Cheers.Should().BeEmpty(); + result.Value.BlockedUsers.Should().BeEmpty(); + result.Value.Reports.Should().BeEmpty(); + result.Value.FriendFeedEvents.Should().BeEmpty(); + } + + [Fact] + public async Task Handle_IncludesSocialData() + { + var user = CreateTestUser(); + ArrangeUser(user); + var otherId = Guid.NewGuid(); + + Returns(_friendshipRepo, Friendship.Create(UserId, otherId).Value); + Returns(_cheerRepo, Cheer.Create(UserId, otherId, Guid.NewGuid(), "great work").Value); + Returns(_blockedUserRepo, BlockedUser.Create(UserId, otherId).Value); + Returns(_reportRepo, Report.Create(UserId, otherId, ReportReason.Spam, "spammy", null).Value); + Returns(_friendFeedEventRepo, FriendFeedEvent.StreakMilestone(UserId, 7)); + + var result = await _handler.Handle(new ExportUserDataQuery(UserId), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Friendships.Should().ContainSingle(f => f.AddresseeId == otherId); + result.Value.Cheers.Should().ContainSingle(c => c.Note == "great work"); + result.Value.BlockedUsers.Should().ContainSingle(b => b.BlockedId == otherId); + result.Value.Reports.Should().ContainSingle(r => r.Reason == "Spam"); + result.Value.FriendFeedEvents.Should().ContainSingle(e => e.Type == "StreakMilestone" && e.Value == 7); } [Fact] diff --git a/tests/Orbit.Application.Tests/Queries/Profile/GetProfileQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Profile/GetProfileQueryHandlerTests.cs index 4f33ca42..1b110be6 100644 --- a/tests/Orbit.Application.Tests/Queries/Profile/GetProfileQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/Profile/GetProfileQueryHandlerTests.cs @@ -1,5 +1,6 @@ using FluentAssertions; using NSubstitute; +using Orbit.Application.Common; using Orbit.Application.Profile.Queries; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; @@ -12,6 +13,7 @@ public class GetProfileQueryHandlerTests private readonly IGenericRepository _userRepo = Substitute.For>(); private readonly IGenericRepository _streakFreezeRepo = Substitute.For>(); private readonly IUserDateService _userDateService = Substitute.For(); + private readonly IFeatureFlagService _featureFlagService = Substitute.For(); private readonly IPayGateService _payGate = Substitute.For(); private readonly GetProfileQueryHandler _handler; @@ -20,7 +22,9 @@ public class GetProfileQueryHandlerTests public GetProfileQueryHandlerTests() { - _handler = new GetProfileQueryHandler(_userRepo, _streakFreezeRepo, _userDateService, _payGate); + _featureFlagService.GetEnabledKeysForUserAsync(Arg.Any(), Arg.Any()) + .Returns(Array.Empty()); + _handler = new GetProfileQueryHandler(_userRepo, _streakFreezeRepo, _userDateService, _featureFlagService, _payGate); _userDateService.GetUserTodayAsync(UserId, Arg.Any()).Returns(Today); } @@ -29,6 +33,27 @@ private static User CreateTestUser(string name = "Test User") return User.Create(name, "test@example.com").Value; } + private static User CreateFreeUser() + { + var user = User.Create("Test User", "test@example.com").Value; + user.StartTrial(DateTime.UtcNow.AddDays(-1)); + return user; + } + + private void EnableFreeTierFlag() + { + _featureFlagService.GetEnabledKeysForUserAsync(Arg.Any(), Arg.Any()) + .Returns(new[] { FeatureFlagKeys.GamificationFreeTier }); + } + + private void StubFreezeRepoEmpty() + { + _streakFreezeRepo.FindAsync( + Arg.Any>>(), + Arg.Any()) + .Returns(new List().AsReadOnly()); + } + [Fact] public async Task Handle_UserFound_ReturnsProfile() { @@ -50,6 +75,25 @@ public async Task Handle_UserFound_ReturnsProfile() result.Value.AiMessagesLimit.Should().Be(20); } + [Fact] + public async Task Handle_ReturnsOnboardingChecklistFlags() + { + var user = CreateTestUser(); + user.MarkFirstHabitCreated(); + user.MarkAstraUsed(); + _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); + _payGate.GetAiMessageLimit(UserId, Arg.Any()).Returns(20); + StubFreezeRepoEmpty(); + + var result = await _handler.Handle(new GetProfileQuery(UserId), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.HasCreatedFirstHabit.Should().BeTrue(); + result.Value.HasLoggedFirstHabit.Should().BeFalse(); + result.Value.HasTriedAstra.Should().BeTrue(); + result.Value.HasCompletedOnboardingChecklist.Should().BeFalse(); + } + [Fact] public async Task Handle_UserWithStreak_ReturnsCurrentAndLongest() { @@ -207,4 +251,60 @@ public async Task Handle_Returns12HourClock_ForUnitedStatesTimeZone() result.Value.Uses24HourClock.Should().BeFalse(); } + + [Fact] + public async Task Handle_ProUser_CanViewGamificationTrue() + { + var user = CreateTestUser(); + user.SetStripeSubscription("sub_123", DateTime.UtcNow.AddYears(1)); + _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); + _payGate.GetAiMessageLimit(UserId, Arg.Any()).Returns(500); + StubFreezeRepoEmpty(); + + var result = await _handler.Handle(new GetProfileQuery(UserId), CancellationToken.None); + + result.Value.CanViewGamification.Should().BeTrue(); + } + + [Fact] + public async Task Handle_FreeUser_FlagOn_CanViewGamificationTrue() + { + var user = CreateFreeUser(); + EnableFreeTierFlag(); + _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); + _payGate.GetAiMessageLimit(UserId, Arg.Any()).Returns(20); + StubFreezeRepoEmpty(); + + var result = await _handler.Handle(new GetProfileQuery(UserId), CancellationToken.None); + + result.Value.CanViewGamification.Should().BeTrue(); + } + + [Fact] + public async Task Handle_FreeUser_FlagOff_CanViewGamificationFalse() + { + var user = CreateFreeUser(); + _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); + _payGate.GetAiMessageLimit(UserId, Arg.Any()).Returns(20); + StubFreezeRepoEmpty(); + + var result = await _handler.Handle(new GetProfileQuery(UserId), CancellationToken.None); + + result.Value.CanViewGamification.Should().BeFalse(); + } + + [Fact] + public async Task Handle_Past10Xp_ComputesLevelFromXp() + { + var user = CreateTestUser(); + user.AddXp(15_000); + _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); + _payGate.GetAiMessageLimit(UserId, Arg.Any()).Returns(20); + StubFreezeRepoEmpty(); + + var result = await _handler.Handle(new GetProfileQuery(UserId), CancellationToken.None); + + result.Value.Level.Should().Be(12); + result.Value.LevelTitle.Should().Be("Legend"); + } } diff --git a/tests/Orbit.Application.Tests/Services/GamificationServiceTests.cs b/tests/Orbit.Application.Tests/Services/GamificationServiceTests.cs index 9322c2c2..f5654374 100644 --- a/tests/Orbit.Application.Tests/Services/GamificationServiceTests.cs +++ b/tests/Orbit.Application.Tests/Services/GamificationServiceTests.cs @@ -5,6 +5,7 @@ using NSubstitute.ExceptionExtensions; using Orbit.Application.Gamification; using Orbit.Application.Gamification.Services; +using Orbit.Application.Social.Services; using Orbit.Domain.Entities; using Orbit.Domain.Enums; using Orbit.Domain.Interfaces; @@ -22,6 +23,7 @@ public class GamificationServiceTests private readonly IGenericRepository _notificationRepo = Substitute.For>(); private readonly IPushNotificationService _pushService = Substitute.For(); private readonly IUserDateService _userDateService = Substitute.For(); + private readonly IFriendFeedEventEmitter _feedEmitter = Substitute.For(); private readonly IUnitOfWork _unitOfWork = Substitute.For(); private readonly GamificationService _sut; @@ -33,7 +35,7 @@ public GamificationServiceTests() var repos = new GamificationRepositories( _userRepo, _habitRepo, _habitLogRepo, _goalRepo, _achievementRepo, _notificationRepo); _sut = new GamificationService( - repos, _pushService, _userDateService, _unitOfWork, + repos, _pushService, _userDateService, _feedEmitter, _unitOfWork, Substitute.For>()); _userDateService.GetUserTodayAsync(Arg.Any(), Arg.Any()) @@ -872,4 +874,110 @@ await _pushService.DidNotReceive().SendToUserAsync( Arg.Any(), Arg.Any()); } + + [Fact] + public async Task ProcessOnboardingChecklist_ProUserAllThreeSignals_GrantsOnboardingCompleteOnceAndCompletes() + { + var user = CreateProUser(); + user.MarkFirstHabitCreated(); + user.MarkFirstHabitLogged(); + SetupUserLookup(user); + SetupNoEarnedAchievements(); + + await _sut.ProcessOnboardingChecklistAsync(UserId, OnboardingChecklistSignal.AstraUsed); + + user.HasTriedAstra.Should().BeTrue(); + user.HasCompletedOnboardingChecklist.Should().BeTrue(); + await _achievementRepo.Received(1).AddAsync( + Arg.Is(a => a.AchievementId == AchievementDefinitions.OnboardingComplete), + Arg.Any()); + await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task ProcessOnboardingChecklist_SignalMissing_DoesNotCompleteOrGrant() + { + var user = CreateProUser(); + user.MarkFirstHabitCreated(); + SetupUserLookup(user); + SetupNoEarnedAchievements(); + + await _sut.ProcessOnboardingChecklistAsync(UserId, OnboardingChecklistSignal.HabitLogged); + + user.HasLoggedFirstHabit.Should().BeTrue(); + user.HasTriedAstra.Should().BeFalse(); + user.HasCompletedOnboardingChecklist.Should().BeFalse(); + await _achievementRepo.DidNotReceive().AddAsync( + Arg.Any(), + Arg.Any()); + await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task ProcessOnboardingChecklist_FreeUserAllThreeSignals_CompletesButNoAchievement() + { + var user = CreateFreeUser(); + user.MarkFirstHabitCreated(); + user.MarkFirstHabitLogged(); + SetupUserLookup(user); + SetupNoEarnedAchievements(); + + await _sut.ProcessOnboardingChecklistAsync(UserId, OnboardingChecklistSignal.AstraUsed); + + user.HasCompletedOnboardingChecklist.Should().BeTrue(); + await _achievementRepo.DidNotReceive().AddAsync( + Arg.Any(), + Arg.Any()); + await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task ProcessOnboardingChecklist_AlreadyComplete_EarlyOutWithoutSaving() + { + var user = CreateProUser(); + user.MarkFirstHabitCreated(); + user.MarkFirstHabitLogged(); + user.MarkAstraUsed(); + user.CompleteOnboardingChecklist(); + SetupUserLookup(user); + + await _sut.ProcessOnboardingChecklistAsync(UserId, OnboardingChecklistSignal.AstraUsed); + + await _achievementRepo.DidNotReceive().AddAsync( + Arg.Any(), + Arg.Any()); + await _unitOfWork.DidNotReceive().SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task ProcessOnboardingChecklist_AlreadyEarnedAchievement_CompletesWithoutDoubleGrant() + { + var user = CreateProUser(); + user.MarkFirstHabitCreated(); + user.MarkFirstHabitLogged(); + SetupUserLookup(user); + SetupEarnedAchievements(AchievementDefinitions.OnboardingComplete); + + await _sut.ProcessOnboardingChecklistAsync(UserId, OnboardingChecklistSignal.AstraUsed); + + user.HasCompletedOnboardingChecklist.Should().BeTrue(); + await _achievementRepo.DidNotReceive().AddAsync( + Arg.Is(a => a.AchievementId == AchievementDefinitions.OnboardingComplete), + Arg.Any()); + await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task ProcessOnboardingChecklist_UserNotFound_DoesNothing() + { + _userRepo.FindOneTrackedAsync( + Arg.Any>>(), + Arg.Any, IQueryable>?>(), + Arg.Any()) + .Returns((User?)null); + + await _sut.ProcessOnboardingChecklistAsync(UserId, OnboardingChecklistSignal.AstraUsed); + + await _unitOfWork.DidNotReceive().SaveChangesAsync(Arg.Any()); + } } diff --git a/tests/Orbit.Application.Tests/Social/BlockReportCommandTests.cs b/tests/Orbit.Application.Tests/Social/BlockReportCommandTests.cs new file mode 100644 index 00000000..046f8092 --- /dev/null +++ b/tests/Orbit.Application.Tests/Social/BlockReportCommandTests.cs @@ -0,0 +1,185 @@ +using FluentAssertions; +using NSubstitute; +using Orbit.Application.Common; +using Orbit.Application.Social.Commands; +using Orbit.Application.Social.Services; +using Orbit.Domain.Entities; +using Orbit.Domain.Enums; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Tests.Social; + +public class BlockReportCommandTests +{ + private readonly IGenericRepository _userRepository = Substitute.For>(); + private readonly IGenericRepository _friendshipRepository = Substitute.For>(); + private readonly IGenericRepository _blockedUserRepository = Substitute.For>(); + private readonly IGenericRepository _cheerRepository = Substitute.For>(); + private readonly IGenericRepository _reportRepository = Substitute.For>(); + private readonly IUnitOfWork _unitOfWork = Substitute.For(); + + private readonly SocialAccessGuard _guard; + private readonly FriendGraphService _friendGraph; + private readonly User _caller = SocialTestHelpers.OptedInUser("Caller"); + private readonly User _target = SocialTestHelpers.OptedInUser("Target"); + + public BlockReportCommandTests() + { + _guard = new SocialAccessGuard(_userRepository); + _friendGraph = new FriendGraphService(_userRepository, _friendshipRepository, _blockedUserRepository); + SocialTestHelpers.StubUsers(_userRepository, _caller, _target); + SocialTestHelpers.StubFind(_blockedUserRepository); + SocialTestHelpers.StubFind(_friendshipRepository); + SocialTestHelpers.StubFind(_reportRepository); + SocialTestHelpers.StubFind(_cheerRepository); + } + + private BlockUserCommandHandler BlockHandler() => + new(_guard, _friendGraph, _userRepository, _blockedUserRepository, _friendshipRepository, _unitOfWork); + + private ReportUserCommandHandler ReportHandler() => + new(_guard, _userRepository, _cheerRepository, _reportRepository, _unitOfWork); + + [Fact] + public async Task Block_CreatesRowAndRemovesFriendship() + { + var friendship = Friendship.Create(_caller.Id, _target.Id).Value; + friendship.Accept(); + SocialTestHelpers.StubFind(_friendshipRepository, friendship); + + var result = await BlockHandler().Handle(new BlockUserCommand(_caller.Id, _target.Id), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + await _blockedUserRepository.Received(1).AddAsync( + Arg.Is(b => b.BlockerId == _caller.Id && b.BlockedId == _target.Id), + Arg.Any()); + _friendshipRepository.Received(1).Remove(friendship); + } + + [Fact] + public async Task Block_AlreadyBlocked_IsNoOpSuccess() + { + SocialTestHelpers.StubFind(_blockedUserRepository, BlockedUser.Create(_caller.Id, _target.Id).Value); + + var result = await BlockHandler().Handle(new BlockUserCommand(_caller.Id, _target.Id), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + await _blockedUserRepository.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Block_UnknownTarget_ReturnsUserNotFound_WithoutPersisting() + { + SocialTestHelpers.StubUsers(_userRepository, _caller); + + var result = await BlockHandler().Handle(new BlockUserCommand(_caller.Id, Guid.NewGuid()), CancellationToken.None); + + result.ErrorCode.Should().Be(ErrorCodes.UserNotFound); + await _blockedUserRepository.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + await _unitOfWork.DidNotReceive().SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task Unblock_RemovesExistingBlock() + { + var block = BlockedUser.Create(_caller.Id, _target.Id).Value; + SocialTestHelpers.StubFind(_blockedUserRepository, block); + + var handler = new UnblockUserCommandHandler(_guard, _blockedUserRepository, _unitOfWork); + var result = await handler.Handle(new UnblockUserCommand(_caller.Id, _target.Id), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + _blockedUserRepository.Received(1).Remove(block); + } + + [Fact] + public async Task Unblock_NoBlock_IsNoOpSuccess() + { + var handler = new UnblockUserCommandHandler(_guard, _blockedUserRepository, _unitOfWork); + var result = await handler.Handle(new UnblockUserCommand(_caller.Id, _target.Id), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + _blockedUserRepository.DidNotReceive().Remove(Arg.Any()); + } + + [Fact] + public async Task Report_CreatesPendingReportWithReasonAndOptionalCheer() + { + var cheer = Cheer.Create(_caller.Id, _target.Id, Guid.NewGuid(), "nice").Value; + SocialTestHelpers.StubFind(_cheerRepository, cheer); + + var result = await ReportHandler().Handle( + new ReportUserCommand(_caller.Id, _target.Id, ReportReason.Harassment, "abusive", cheer.Id), + CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + await _reportRepository.Received(1).AddAsync( + Arg.Is(r => r.ReporterId == _caller.Id + && r.ReportedUserId == _target.Id + && r.Reason == ReportReason.Harassment + && r.Details == "abusive" + && r.CheerId == cheer.Id + && r.Status == ReportStatus.Pending), + Arg.Any()); + } + + [Fact] + public async Task Report_WithoutCheer_Succeeds() + { + var result = await ReportHandler().Handle( + new ReportUserCommand(_caller.Id, _target.Id, ReportReason.Spam, null, null), + CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + await _reportRepository.Received(1).AddAsync( + Arg.Is(r => r.CheerId == null), Arg.Any()); + } + + [Fact] + public async Task Report_CheerNotInvolvingReportedUser_ReturnsCheerNotFound() + { + var unrelatedCheer = Cheer.Create(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), null).Value; + SocialTestHelpers.StubFind(_cheerRepository, unrelatedCheer); + + var result = await ReportHandler().Handle( + new ReportUserCommand(_caller.Id, _target.Id, ReportReason.Harassment, null, unrelatedCheer.Id), + CancellationToken.None); + + result.ErrorCode.Should().Be(ErrorCodes.CheerNotFound); + await _reportRepository.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Report_CheerDoesNotExist_ReturnsCheerNotFound() + { + var result = await ReportHandler().Handle( + new ReportUserCommand(_caller.Id, _target.Id, ReportReason.Harassment, null, Guid.NewGuid()), + CancellationToken.None); + + result.ErrorCode.Should().Be(ErrorCodes.CheerNotFound); + await _reportRepository.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Report_UnknownTarget_ReturnsUserNotFound() + { + SocialTestHelpers.StubUsers(_userRepository, _caller); + + var result = await ReportHandler().Handle( + new ReportUserCommand(_caller.Id, Guid.NewGuid(), ReportReason.Spam, null, null), CancellationToken.None); + + result.ErrorCode.Should().Be(ErrorCodes.UserNotFound); + } + + [Fact] + public async Task Report_CallerOptedOut_ReturnsSocialDisabled() + { + var optedOut = SocialTestHelpers.OptedOutUser(); + SocialTestHelpers.StubUsers(_userRepository, optedOut, _target); + + var result = await ReportHandler().Handle( + new ReportUserCommand(optedOut.Id, _target.Id, ReportReason.Spam, null, null), CancellationToken.None); + + result.ErrorCode.Should().Be(ErrorCodes.SocialDisabled); + } +} diff --git a/tests/Orbit.Application.Tests/Social/FriendFeedEmitterTests.cs b/tests/Orbit.Application.Tests/Social/FriendFeedEmitterTests.cs new file mode 100644 index 00000000..de98d369 --- /dev/null +++ b/tests/Orbit.Application.Tests/Social/FriendFeedEmitterTests.cs @@ -0,0 +1,138 @@ +using FluentAssertions; +using NSubstitute; +using Orbit.Application.Gamification; +using Orbit.Application.Social.Services; +using Orbit.Domain.Entities; +using Orbit.Domain.Enums; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Tests.Social; + +public class FriendFeedEmitterTests +{ + private readonly IGenericRepository _feedRepository = Substitute.For>(); + private readonly FriendFeedEmitter _emitter; + + private static readonly DateOnly Today = DateOnly.FromDateTime(DateTime.UtcNow); + + public FriendFeedEmitterTests() + { + _emitter = new FriendFeedEmitter(_feedRepository); + SocialTestHelpers.StubFind(_feedRepository); + } + + private static User ActorWithStreak(int currentStreak, bool optedIn = true) + { + var user = optedIn ? SocialTestHelpers.OptedInUser("Actor") : SocialTestHelpers.OptedOutUser("Actor"); + user.SetStreakState(currentStreak, currentStreak, Today); + return user; + } + + [Fact] + public async Task StreakMilestone_CrossingTier_EmitsOneEvent() + { + var actor = ActorWithStreak(7); + + await _emitter.EmitStreakMilestonesAsync(actor, previousStreak: 6, CancellationToken.None); + + await _feedRepository.Received(1).AddAsync( + Arg.Is(e => e.Type == FriendFeedEventType.StreakMilestone && e.Value == 7 && e.ActorUserId == actor.Id), + Arg.Any()); + } + + [Fact] + public async Task StreakMilestone_CrossingMultipleTiersAtOnce_EmitsEach() + { + var actor = ActorWithStreak(30); + + await _emitter.EmitStreakMilestonesAsync(actor, previousStreak: 6, CancellationToken.None); + + await _feedRepository.Received(1).AddAsync(Arg.Is(e => e.Value == 7), Arg.Any()); + await _feedRepository.Received(1).AddAsync(Arg.Is(e => e.Value == 14), Arg.Any()); + await _feedRepository.Received(1).AddAsync(Arg.Is(e => e.Value == 30), Arg.Any()); + } + + [Fact] + public async Task StreakMilestone_OrdinaryDailyLog_CrossesNoTier_EmitsNothing() + { + var actor = ActorWithStreak(10); + + await _emitter.EmitStreakMilestonesAsync(actor, previousStreak: 9, CancellationToken.None); + + await _feedRepository.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + await _feedRepository.DidNotReceive().FindAsync( + Arg.Any>>(), Arg.Any()); + } + + [Fact] + public async Task StreakMilestone_AlreadyEmittedTier_IsNotDuplicated() + { + var actor = ActorWithStreak(7); + SocialTestHelpers.StubFind(_feedRepository, FriendFeedEvent.StreakMilestone(actor.Id, 7)); + + await _emitter.EmitStreakMilestonesAsync(actor, previousStreak: 6, CancellationToken.None); + + await _feedRepository.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task StreakMilestone_OptedOutActor_EmitsNothing() + { + var actor = ActorWithStreak(7, optedIn: false); + + await _emitter.EmitStreakMilestonesAsync(actor, previousStreak: 6, CancellationToken.None); + + await _feedRepository.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Achievement_VolumeCategory_EmitsHabitCompletedMilestoneWithCount() + { + var actor = SocialTestHelpers.OptedInUser("Actor"); + + await _emitter.EmitAchievementEventAsync(actor, AchievementDefinitions.Dedicated, AchievementCategory.Volume, CancellationToken.None); + + await _feedRepository.Received(1).AddAsync( + Arg.Is(e => e.Type == FriendFeedEventType.HabitCompletedMilestone + && e.AchievementId == AchievementDefinitions.Dedicated + && e.Value == 100), + Arg.Any()); + } + + [Fact] + public async Task Achievement_NonVolumeCategory_EmitsAchievementUnlocked() + { + var actor = SocialTestHelpers.OptedInUser("Actor"); + + await _emitter.EmitAchievementEventAsync(actor, AchievementDefinitions.PerfectDay, AchievementCategory.Perfection, CancellationToken.None); + + await _feedRepository.Received(1).AddAsync( + Arg.Is(e => e.Type == FriendFeedEventType.AchievementUnlocked + && e.AchievementId == AchievementDefinitions.PerfectDay + && e.Value == null), + Arg.Any()); + } + + [Fact] + public async Task Achievement_AlreadyEmitted_IsNotDuplicated() + { + var actor = SocialTestHelpers.OptedInUser("Actor"); + _feedRepository.AnyAsync( + Arg.Any>>(), Arg.Any()) + .Returns(true); + + await _emitter.EmitAchievementEventAsync(actor, AchievementDefinitions.PerfectDay, AchievementCategory.Perfection, CancellationToken.None); + + await _feedRepository.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Achievement_OptedOutActor_EmitsNothing() + { + var actor = SocialTestHelpers.OptedOutUser("Actor"); + + await _emitter.EmitAchievementEventAsync(actor, AchievementDefinitions.PerfectDay, AchievementCategory.Perfection, CancellationToken.None); + + await _feedRepository.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + } +} diff --git a/tests/Orbit.Application.Tests/Social/FriendshipCommandsTests.cs b/tests/Orbit.Application.Tests/Social/FriendshipCommandsTests.cs new file mode 100644 index 00000000..9379d331 --- /dev/null +++ b/tests/Orbit.Application.Tests/Social/FriendshipCommandsTests.cs @@ -0,0 +1,292 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Orbit.Application.Common; +using Orbit.Application.Social.Commands; +using Orbit.Application.Social.Queries; +using Orbit.Application.Social.Services; +using Orbit.Domain.Entities; +using Orbit.Domain.Enums; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Tests.Social; + +public class FriendshipCommandsTests +{ + private readonly IGenericRepository _userRepository = Substitute.For>(); + private readonly IGenericRepository _friendshipRepository = Substitute.For>(); + private readonly IGenericRepository _blockedUserRepository = Substitute.For>(); + private readonly IUnitOfWork _unitOfWork = Substitute.For(); + private readonly IPushNotificationService _pushNotificationService = Substitute.For(); + + private readonly SocialAccessGuard _guard; + private readonly FriendGraphService _friendGraph; + + public FriendshipCommandsTests() + { + _guard = new SocialAccessGuard(_userRepository); + _friendGraph = new FriendGraphService(_userRepository, _friendshipRepository, _blockedUserRepository); + } + + private SendFriendRequestCommandHandler SendHandler() => + new(_guard, _friendGraph, _friendshipRepository, _unitOfWork); + + private AcceptFriendRequestCommandHandler AcceptHandler() => + new(_guard, _friendshipRepository, _userRepository, _unitOfWork, _pushNotificationService, + Substitute.For>()); + + [Fact] + public async Task SendRequest_ValidHandle_CreatesPendingFriendship() + { + var caller = SocialTestHelpers.OptedInUser(); + var target = SocialTestHelpers.OptedInUser(); + target.SetHandle("friendhandle"); + SocialTestHelpers.StubUsers(_userRepository, caller, target); + SocialTestHelpers.StubFind(_friendshipRepository); + SocialTestHelpers.StubFind(_blockedUserRepository); + + var result = await SendHandler().Handle( + new SendFriendRequestCommand(caller.Id, "friendhandle", null), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + await _friendshipRepository.Received(1).AddAsync( + Arg.Is(f => f.RequesterId == caller.Id && f.AddresseeId == target.Id && f.Status == FriendshipStatus.Pending), + Arg.Any()); + } + + [Fact] + public async Task SendRequest_CallerOptedOut_ReturnsSocialDisabled() + { + var caller = SocialTestHelpers.OptedOutUser(); + SocialTestHelpers.StubUsers(_userRepository, caller); + + var result = await SendHandler().Handle( + new SendFriendRequestCommand(caller.Id, "anyhandle", null), CancellationToken.None); + + result.ErrorCode.Should().Be(ErrorCodes.SocialDisabled); + } + + [Fact] + public async Task SendRequest_TargetNotFound_ReturnsUniformUserNotFound() + { + var caller = SocialTestHelpers.OptedInUser(); + SocialTestHelpers.StubUsers(_userRepository, caller); + SocialTestHelpers.StubFind(_friendshipRepository); + SocialTestHelpers.StubFind(_blockedUserRepository); + + var result = await SendHandler().Handle( + new SendFriendRequestCommand(caller.Id, "ghost", null), CancellationToken.None); + + result.ErrorCode.Should().Be(ErrorCodes.UserNotFound); + } + + [Fact] + public async Task SendRequest_TargetOptedOut_ReturnsUniformUserNotFound() + { + var caller = SocialTestHelpers.OptedInUser(); + var target = SocialTestHelpers.OptedOutUser(); + target.SetHandle("privatehandle"); + SocialTestHelpers.StubUsers(_userRepository, caller, target); + SocialTestHelpers.StubFind(_friendshipRepository); + SocialTestHelpers.StubFind(_blockedUserRepository); + + var result = await SendHandler().Handle( + new SendFriendRequestCommand(caller.Id, "privatehandle", null), CancellationToken.None); + + result.ErrorCode.Should().Be(ErrorCodes.UserNotFound); + } + + [Fact] + public async Task SendRequest_ToSelf_ReturnsUniformUserNotFound() + { + var caller = SocialTestHelpers.OptedInUser(); + caller.SetHandle("myself"); + SocialTestHelpers.StubUsers(_userRepository, caller); + SocialTestHelpers.StubFind(_friendshipRepository); + SocialTestHelpers.StubFind(_blockedUserRepository); + + var result = await SendHandler().Handle( + new SendFriendRequestCommand(caller.Id, "myself", null), CancellationToken.None); + + result.ErrorCode.Should().Be(ErrorCodes.UserNotFound); + } + + [Fact] + public async Task SendRequest_Blocked_ReturnsUniformUserNotFound() + { + var caller = SocialTestHelpers.OptedInUser(); + var target = SocialTestHelpers.OptedInUser(); + target.SetHandle("blockedhandle"); + SocialTestHelpers.StubUsers(_userRepository, caller, target); + SocialTestHelpers.StubFind(_friendshipRepository); + SocialTestHelpers.StubFind(_blockedUserRepository, BlockedUser.Create(target.Id, caller.Id).Value); + + var result = await SendHandler().Handle( + new SendFriendRequestCommand(caller.Id, "blockedhandle", null), CancellationToken.None); + + result.ErrorCode.Should().Be(ErrorCodes.UserNotFound); + } + + [Fact] + public async Task SendRequest_AlreadyConnected_ReturnsAlreadyFriends() + { + var caller = SocialTestHelpers.OptedInUser(); + var target = SocialTestHelpers.OptedInUser(); + target.SetHandle("existinghandle"); + SocialTestHelpers.StubUsers(_userRepository, caller, target); + SocialTestHelpers.StubFind(_friendshipRepository, Friendship.Create(caller.Id, target.Id).Value); + SocialTestHelpers.StubFind(_blockedUserRepository); + + var result = await SendHandler().Handle( + new SendFriendRequestCommand(caller.Id, "existinghandle", null), CancellationToken.None); + + result.ErrorCode.Should().Be(ErrorCodes.AlreadyFriends); + } + + [Fact] + public async Task SendRequest_AtFriendCap_ReturnsFriendLimitReached() + { + var caller = SocialTestHelpers.OptedInUser(); + var target = SocialTestHelpers.OptedInUser(); + target.SetHandle("caphandle"); + SocialTestHelpers.StubUsers(_userRepository, caller, target); + SocialTestHelpers.StubFind(_friendshipRepository); + SocialTestHelpers.StubFind(_blockedUserRepository); + _friendshipRepository.CountAsync( + Arg.Any>>(), + Arg.Any()) + .Returns(AppConstants.MaxFriends); + + var result = await SendHandler().Handle( + new SendFriendRequestCommand(caller.Id, "caphandle", null), CancellationToken.None); + + result.ErrorCode.Should().Be(ErrorCodes.FriendLimitReached); + } + + [Fact] + public async Task Accept_ByAddressee_SetsAcceptedAndPushesRequester() + { + var caller = SocialTestHelpers.OptedInUser(); + var requester = SocialTestHelpers.OptedInUser(); + var friendship = Friendship.Create(requester.Id, caller.Id).Value; + SocialTestHelpers.StubUsers(_userRepository, caller, requester); + SocialTestHelpers.StubFind(_friendshipRepository, friendship); + + var result = await AcceptHandler().Handle( + new AcceptFriendRequestCommand(caller.Id, friendship.Id), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + friendship.Status.Should().Be(FriendshipStatus.Accepted); + friendship.RespondedAtUtc.Should().NotBeNull(); + await _pushNotificationService.Received(1).SendToUserAsync( + requester.Id, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Accept_UnknownRequest_ReturnsFriendRequestNotFound() + { + var caller = SocialTestHelpers.OptedInUser(); + SocialTestHelpers.StubUsers(_userRepository, caller); + SocialTestHelpers.StubFind(_friendshipRepository); + + var result = await AcceptHandler().Handle( + new AcceptFriendRequestCommand(caller.Id, Guid.NewGuid()), CancellationToken.None); + + result.ErrorCode.Should().Be(ErrorCodes.FriendRequestNotFound); + } + + [Fact] + public async Task Accept_ByNonAddressee_ReturnsFriendRequestNotFound() + { + var caller = SocialTestHelpers.OptedInUser(); + var requester = SocialTestHelpers.OptedInUser(); + var someoneElse = SocialTestHelpers.OptedInUser(); + var friendship = Friendship.Create(requester.Id, someoneElse.Id).Value; + SocialTestHelpers.StubUsers(_userRepository, caller, requester, someoneElse); + SocialTestHelpers.StubFind(_friendshipRepository, friendship); + + var result = await AcceptHandler().Handle( + new AcceptFriendRequestCommand(caller.Id, friendship.Id), CancellationToken.None); + + result.ErrorCode.Should().Be(ErrorCodes.FriendRequestNotFound); + } + + [Fact] + public async Task Accept_AlreadyAccepted_ReturnsFriendshipNotPending() + { + var caller = SocialTestHelpers.OptedInUser(); + var requester = SocialTestHelpers.OptedInUser(); + var friendship = Friendship.Create(requester.Id, caller.Id).Value; + friendship.Accept(); + SocialTestHelpers.StubUsers(_userRepository, caller, requester); + SocialTestHelpers.StubFind(_friendshipRepository, friendship); + + var result = await AcceptHandler().Handle( + new AcceptFriendRequestCommand(caller.Id, friendship.Id), CancellationToken.None); + + result.IsFailure.Should().BeTrue(); + result.ErrorCode.Should().Be("FRIENDSHIP_NOT_PENDING"); + } + + [Fact] + public async Task Remove_ExistingFriendship_DeletesRow() + { + var caller = SocialTestHelpers.OptedInUser(); + var friend = SocialTestHelpers.OptedInUser(); + var friendship = Friendship.Create(caller.Id, friend.Id).Value; + friendship.Accept(); + SocialTestHelpers.StubUsers(_userRepository, caller, friend); + SocialTestHelpers.StubFind(_friendshipRepository, friendship); + + var handler = new RemoveFriendCommandHandler(_guard, _friendGraph, _friendshipRepository, _unitOfWork); + var result = await handler.Handle(new RemoveFriendCommand(caller.Id, friend.Id), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + _friendshipRepository.Received(1).Remove(friendship); + await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task Remove_NoFriendship_IsNoOpSuccess() + { + var caller = SocialTestHelpers.OptedInUser(); + SocialTestHelpers.StubUsers(_userRepository, caller); + SocialTestHelpers.StubFind(_friendshipRepository); + + var handler = new RemoveFriendCommandHandler(_guard, _friendGraph, _friendshipRepository, _unitOfWork); + var result = await handler.Handle(new RemoveFriendCommand(caller.Id, Guid.NewGuid()), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + _friendshipRepository.DidNotReceive().Remove(Arg.Any()); + } + + [Fact] + public async Task GetFriends_PartitionsAcceptedIncomingOutgoing_AndExcludesBlocked() + { + var caller = SocialTestHelpers.OptedInUser(); + var friend = SocialTestHelpers.OptedInUser("Friend"); + var incomingRequester = SocialTestHelpers.OptedInUser("Incoming"); + var outgoingAddressee = SocialTestHelpers.OptedInUser("Outgoing"); + var blockedFriend = SocialTestHelpers.OptedInUser("Blocked"); + + var accepted = Friendship.Create(caller.Id, friend.Id).Value; + accepted.Accept(); + var incoming = Friendship.Create(incomingRequester.Id, caller.Id).Value; + var outgoing = Friendship.Create(caller.Id, outgoingAddressee.Id).Value; + var blockedAccepted = Friendship.Create(caller.Id, blockedFriend.Id).Value; + blockedAccepted.Accept(); + + SocialTestHelpers.StubUsers(_userRepository, caller, friend, incomingRequester, outgoingAddressee, blockedFriend); + SocialTestHelpers.StubFind(_friendshipRepository, accepted, incoming, outgoing, blockedAccepted); + SocialTestHelpers.StubFind(_blockedUserRepository, BlockedUser.Create(caller.Id, blockedFriend.Id).Value); + + var handler = new GetFriendsQueryHandler(_guard, _friendshipRepository, _blockedUserRepository, _userRepository); + var result = await handler.Handle(new GetFriendsQuery(caller.Id), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Friends.Should().ContainSingle(f => f.UserId == friend.Id); + result.Value.Friends.Should().NotContain(f => f.UserId == blockedFriend.Id); + result.Value.IncomingRequests.Should().ContainSingle(r => r.UserId == incomingRequester.Id); + result.Value.OutgoingRequests.Should().ContainSingle(r => r.UserId == outgoingAddressee.Id); + } +} diff --git a/tests/Orbit.Application.Tests/Social/GetCheersQueryTests.cs b/tests/Orbit.Application.Tests/Social/GetCheersQueryTests.cs new file mode 100644 index 00000000..52dc040c --- /dev/null +++ b/tests/Orbit.Application.Tests/Social/GetCheersQueryTests.cs @@ -0,0 +1,86 @@ +using FluentAssertions; +using NSubstitute; +using Orbit.Application.Social.Queries; +using Orbit.Application.Social.Services; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Tests.Social; + +public class GetCheersQueryTests +{ + private readonly IGenericRepository _userRepository = Substitute.For>(); + private readonly IGenericRepository _cheerRepository = Substitute.For>(); + private readonly IGenericRepository _blockedUserRepository = Substitute.For>(); + private readonly GetCheersQueryHandler _handler; + + private readonly User _caller = SocialTestHelpers.OptedInUser("Caller"); + private readonly User _friend = SocialTestHelpers.OptedInUser("Friend"); + + public GetCheersQueryTests() + { + var guard = new SocialAccessGuard(_userRepository); + _handler = new GetCheersQueryHandler(guard, _cheerRepository, _blockedUserRepository, _userRepository); + SocialTestHelpers.StubUsers(_userRepository, _caller, _friend); + SocialTestHelpers.StubFind(_blockedUserRepository); + } + + [Fact] + public async Task Received_ReturnsCheersWithSenderDisplayFields() + { + var received = Cheer.Create(_friend.Id, _caller.Id, Guid.NewGuid(), "proud of you").Value; + var sent = Cheer.Create(_caller.Id, _friend.Id, Guid.NewGuid(), "go go").Value; + SocialTestHelpers.StubFind(_cheerRepository, received, sent); + + var result = await _handler.Handle(new GetCheersQuery(_caller.Id, "received"), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Items.Should().ContainSingle(); + var item = result.Value.Items[0]; + item.SenderId.Should().Be(_friend.Id); + item.SenderDisplayName.Should().Be("Friend"); + item.SenderHandle.Should().Be(_friend.Handle); + item.Note.Should().Be("proud of you"); + } + + [Fact] + public async Task Sent_ReturnsOnlyCheersTheCallerSent() + { + var received = Cheer.Create(_friend.Id, _caller.Id, Guid.NewGuid(), "a").Value; + var sent = Cheer.Create(_caller.Id, _friend.Id, Guid.NewGuid(), "b").Value; + SocialTestHelpers.StubFind(_cheerRepository, received, sent); + + var result = await _handler.Handle(new GetCheersQuery(_caller.Id, "sent"), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Items.Should().ContainSingle(c => c.Id == sent.Id); + } + + [Fact] + public async Task BlockedUser_CheersExcludedFromBothDirections() + { + var received = Cheer.Create(_friend.Id, _caller.Id, Guid.NewGuid(), "before block").Value; + var sent = Cheer.Create(_caller.Id, _friend.Id, Guid.NewGuid(), "before block").Value; + SocialTestHelpers.StubFind(_cheerRepository, received, sent); + SocialTestHelpers.StubFind(_blockedUserRepository, BlockedUser.Create(_caller.Id, _friend.Id).Value); + + var receivedResult = await _handler.Handle(new GetCheersQuery(_caller.Id, "received"), CancellationToken.None); + var sentResult = await _handler.Handle(new GetCheersQuery(_caller.Id, "sent"), CancellationToken.None); + + receivedResult.IsSuccess.Should().BeTrue(); + receivedResult.Value.Items.Should().BeEmpty(); + sentResult.IsSuccess.Should().BeTrue(); + sentResult.Value.Items.Should().BeEmpty(); + } + + [Fact] + public async Task CallerOptedOut_ReturnsSocialDisabled() + { + var optedOut = SocialTestHelpers.OptedOutUser(); + SocialTestHelpers.StubUsers(_userRepository, optedOut); + + var result = await _handler.Handle(new GetCheersQuery(optedOut.Id, "received"), CancellationToken.None); + + result.IsFailure.Should().BeTrue(); + } +} diff --git a/tests/Orbit.Application.Tests/Social/GetFriendFeedQueryTests.cs b/tests/Orbit.Application.Tests/Social/GetFriendFeedQueryTests.cs new file mode 100644 index 00000000..56a96746 --- /dev/null +++ b/tests/Orbit.Application.Tests/Social/GetFriendFeedQueryTests.cs @@ -0,0 +1,139 @@ +using FluentAssertions; +using NSubstitute; +using Orbit.Application.Social.Queries; +using Orbit.Application.Social.Services; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Tests.Social; + +public class GetFriendFeedQueryTests +{ + private readonly IGenericRepository _userRepository = Substitute.For>(); + private readonly IGenericRepository _friendshipRepository = Substitute.For>(); + private readonly IGenericRepository _blockedUserRepository = Substitute.For>(); + private readonly IFriendFeedReader _feedReader = Substitute.For(); + private readonly GetFriendFeedQueryHandler _handler; + + private readonly User _caller = SocialTestHelpers.OptedInUser("Caller"); + + public GetFriendFeedQueryTests() + { + var guard = new SocialAccessGuard(_userRepository); + var friendGraph = new FriendGraphService(_userRepository, _friendshipRepository, _blockedUserRepository); + _handler = new GetFriendFeedQueryHandler(guard, friendGraph, _blockedUserRepository, _userRepository, _feedReader); + } + + private static Friendship Accepted(Guid a, Guid b) + { + var friendship = Friendship.Create(a, b).Value; + friendship.Accept(); + return friendship; + } + + private void StubReader(params FriendFeedEvent[] events) => + _feedReader.ReadFeedPageAsync( + Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((IReadOnlyList)events.ToList()); + + [Fact] + public async Task Feed_IncludesOnlyAcceptedNonBlockedOptedInFriends() + { + var goodFriend = SocialTestHelpers.OptedInUser("Good"); + var optedOutFriend = SocialTestHelpers.OptedOutUser("Quiet"); + var blockedFriend = SocialTestHelpers.OptedInUser("Blocked"); + + SocialTestHelpers.StubUsers(_userRepository, _caller, goodFriend, optedOutFriend, blockedFriend); + SocialTestHelpers.StubFind(_friendshipRepository, + Accepted(_caller.Id, goodFriend.Id), + Accepted(_caller.Id, optedOutFriend.Id), + Accepted(_caller.Id, blockedFriend.Id)); + SocialTestHelpers.StubFind(_blockedUserRepository, BlockedUser.Create(_caller.Id, blockedFriend.Id).Value); + StubReader(FriendFeedEvent.StreakMilestone(goodFriend.Id, 7)); + + var result = await _handler.Handle(new GetFriendFeedQuery(_caller.Id, null, null), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + await _feedReader.Received(1).ReadFeedPageAsync( + Arg.Is>(ids => ids.Count == 1 && ids.Contains(goodFriend.Id)), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + result.Value.Items.Should().ContainSingle(i => i.ActorUserId == goodFriend.Id && i.ActorDisplayName == "Good"); + } + + [Fact] + public async Task Feed_NoFriends_ReturnsEmptyPageWithoutQueryingReader() + { + SocialTestHelpers.StubUsers(_userRepository, _caller); + SocialTestHelpers.StubFind(_friendshipRepository); + SocialTestHelpers.StubFind(_blockedUserRepository); + + var result = await _handler.Handle(new GetFriendFeedQuery(_caller.Id, null, null), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Items.Should().BeEmpty(); + result.Value.NextCursor.Should().BeNull(); + await _feedReader.DidNotReceive().ReadFeedPageAsync( + Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Feed_FullPage_EmitsNextCursorThatPagesStably() + { + var friend = SocialTestHelpers.OptedInUser("Friend"); + SocialTestHelpers.StubUsers(_userRepository, _caller, friend); + SocialTestHelpers.StubFind(_friendshipRepository, Accepted(_caller.Id, friend.Id)); + SocialTestHelpers.StubFind(_blockedUserRepository); + + var first = FriendFeedEvent.StreakMilestone(friend.Id, 30); + var second = FriendFeedEvent.StreakMilestone(friend.Id, 14); + var third = FriendFeedEvent.StreakMilestone(friend.Id, 7); + StubReader(first, second, third); + + var page1 = await _handler.Handle(new GetFriendFeedQuery(_caller.Id, null, 2), CancellationToken.None); + + page1.Value.Items.Should().HaveCount(2); + page1.Value.NextCursor.Should().NotBeNull(); + + _feedReader.ClearReceivedCalls(); + StubReader(); + + await _handler.Handle(new GetFriendFeedQuery(_caller.Id, page1.Value.NextCursor, 2), CancellationToken.None); + + await _feedReader.Received(1).ReadFeedPageAsync( + Arg.Any>(), second.CreatedAtUtc, second.Id, 3, Arg.Any()); + } + + [Fact] + public async Task Feed_DropsEventsWhoseActorIsNotVisible() + { + var friend = SocialTestHelpers.OptedInUser("Friend"); + SocialTestHelpers.StubUsers(_userRepository, _caller, friend); + SocialTestHelpers.StubFind(_friendshipRepository, Accepted(_caller.Id, friend.Id)); + SocialTestHelpers.StubFind(_blockedUserRepository); + + var ghost = FriendFeedEvent.StreakMilestone(Guid.NewGuid(), 30); + var visible = FriendFeedEvent.StreakMilestone(friend.Id, 7); + StubReader(ghost, visible); + + var result = await _handler.Handle(new GetFriendFeedQuery(_caller.Id, null, null), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Items.Should().ContainSingle(i => i.ActorUserId == friend.Id); + result.Value.Items.Should().NotContain(i => i.ActorUserId == ghost.ActorUserId); + } + + [Fact] + public async Task Feed_LastPage_HasNullNextCursor() + { + var friend = SocialTestHelpers.OptedInUser("Friend"); + SocialTestHelpers.StubUsers(_userRepository, _caller, friend); + SocialTestHelpers.StubFind(_friendshipRepository, Accepted(_caller.Id, friend.Id)); + SocialTestHelpers.StubFind(_blockedUserRepository); + StubReader(FriendFeedEvent.StreakMilestone(friend.Id, 7)); + + var result = await _handler.Handle(new GetFriendFeedQuery(_caller.Id, null, 2), CancellationToken.None); + + result.Value.Items.Should().HaveCount(1); + result.Value.NextCursor.Should().BeNull(); + } +} diff --git a/tests/Orbit.Application.Tests/Social/SendCheerCommandTests.cs b/tests/Orbit.Application.Tests/Social/SendCheerCommandTests.cs new file mode 100644 index 00000000..c7c09527 --- /dev/null +++ b/tests/Orbit.Application.Tests/Social/SendCheerCommandTests.cs @@ -0,0 +1,178 @@ +using System.Linq.Expressions; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Orbit.Application.Common; +using Orbit.Application.Gamification; +using Orbit.Application.Social.Commands; +using Orbit.Application.Social.Services; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Tests.Social; + +public class SendCheerCommandTests +{ + private readonly IGenericRepository _userRepository = Substitute.For>(); + private readonly IGenericRepository _friendshipRepository = Substitute.For>(); + private readonly IGenericRepository _blockedUserRepository = Substitute.For>(); + private readonly IGenericRepository _habitRepository = Substitute.For>(); + private readonly IGenericRepository _cheerRepository = Substitute.For>(); + private readonly IGenericRepository _achievementRepository = Substitute.For>(); + private readonly IContentModerationService _moderation = Substitute.For(); + private readonly IPushNotificationService _push = Substitute.For(); + private readonly IUnitOfWork _unitOfWork = Substitute.For(); + + private readonly SendCheerCommandHandler _handler; + + private readonly User _sender = SocialTestHelpers.OptedInUser("Sender"); + private readonly User _recipient = SocialTestHelpers.OptedInUser("Recipient"); + private readonly Guid _habitId = Guid.NewGuid(); + + public SendCheerCommandTests() + { + var guard = new SocialAccessGuard(_userRepository); + var friendGraph = new FriendGraphService(_userRepository, _friendshipRepository, _blockedUserRepository); + var repos = new SendCheerRepositories(_userRepository, _habitRepository, _cheerRepository, _achievementRepository); + _handler = new SendCheerCommandHandler( + guard, friendGraph, repos, _moderation, _push, _unitOfWork, + Substitute.For>()); + + SocialTestHelpers.StubUsers(_userRepository, _sender, _recipient); + SocialTestHelpers.StubFind(_friendshipRepository, AcceptedFriendship()); + SocialTestHelpers.StubFind(_blockedUserRepository); + SocialTestHelpers.StubFind(_achievementRepository); + _habitRepository.AnyAsync(Arg.Any>>(), Arg.Any()).Returns(true); + _moderation.CheckTextAsync(Arg.Any(), Arg.Any()) + .Returns(new ModerationResult(false, false, [])); + } + + private Friendship AcceptedFriendship() + { + var friendship = Friendship.Create(_sender.Id, _recipient.Id).Value; + friendship.Accept(); + return friendship; + } + + private SendCheerCommand Command(string? note = "Keep it up!") => + new(_sender.Id, _recipient.Id, _habitId, note); + + [Fact] + public async Task CleanNote_PersistsCheerAndPushesRecipient() + { + var result = await _handler.Handle(Command(), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + await _cheerRepository.Received(1).AddAsync( + Arg.Is(c => c.SenderId == _sender.Id && c.RecipientId == _recipient.Id && c.HabitId == _habitId), + Arg.Any()); + await _push.Received(1).SendToUserAsync( + _recipient.Id, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task FlaggedNote_RejectsAndPersistsNothingAndDoesNotPush() + { + _moderation.CheckTextAsync(Arg.Any(), Arg.Any()) + .Returns(new ModerationResult(Flagged: true, Unavailable: false, ["harassment"])); + + var result = await _handler.Handle(Command("nasty text"), CancellationToken.None); + + result.ErrorCode.Should().Be(ErrorCodes.ContentRejected); + await _cheerRepository.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + await _push.DidNotReceive().SendToUserAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + await _unitOfWork.DidNotReceive().SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task ModerationUnavailable_FailsOpenAndPersists() + { + _moderation.CheckTextAsync(Arg.Any(), Arg.Any()) + .Returns(new ModerationResult(Flagged: false, Unavailable: true, [])); + + var result = await _handler.Handle(Command("maybe risky"), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + await _cheerRepository.Received(1).AddAsync(Arg.Any(), Arg.Any()); + await _push.Received(1).SendToUserAsync( + _recipient.Id, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task EmptyNote_SkipsModeration() + { + var result = await _handler.Handle(Command(null), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + await _moderation.DidNotReceive().CheckTextAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task NonFriendRecipient_ReturnsNotFriends() + { + SocialTestHelpers.StubFind(_friendshipRepository); + + var result = await _handler.Handle(Command(), CancellationToken.None); + + result.ErrorCode.Should().Be(ErrorCodes.NotFriends); + await _cheerRepository.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task BlockedRecipient_ReturnsBlocked() + { + SocialTestHelpers.StubFind(_blockedUserRepository, BlockedUser.Create(_recipient.Id, _sender.Id).Value); + + var result = await _handler.Handle(Command(), CancellationToken.None); + + result.ErrorCode.Should().Be(ErrorCodes.Blocked); + } + + [Fact] + public async Task OptedOutRecipient_ReturnsNotFriends() + { + var privateRecipient = SocialTestHelpers.OptedOutUser("Private"); + SocialTestHelpers.StubUsers(_userRepository, _sender, privateRecipient); + + var result = await _handler.Handle( + new SendCheerCommand(_sender.Id, privateRecipient.Id, _habitId, "hi"), CancellationToken.None); + + result.ErrorCode.Should().Be(ErrorCodes.NotFriends); + } + + [Fact] + public async Task HabitNotOwnedByRecipient_ReturnsHabitNotFound() + { + _habitRepository.AnyAsync(Arg.Any>>(), Arg.Any()).Returns(false); + + var result = await _handler.Handle(Command(), CancellationToken.None); + + result.ErrorCode.Should().Be(ErrorCodes.HabitNotFound); + } + + [Fact] + public async Task FirstCheer_AwardsAchievementAndXp() + { + var xpBefore = _sender.TotalXp; + + var result = await _handler.Handle(Command(), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + await _achievementRepository.Received(1).AddAsync( + Arg.Is(a => a.UserId == _sender.Id && a.AchievementId == AchievementDefinitions.FirstCheer), + Arg.Any()); + _sender.TotalXp.Should().BeGreaterThan(xpBefore); + } + + [Fact] + public async Task FirstCheer_NotReAwardedWhenAlreadyEarned() + { + SocialTestHelpers.StubFind(_achievementRepository, UserAchievement.Create(_sender.Id, AchievementDefinitions.FirstCheer)); + + var result = await _handler.Handle(Command(), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + await _achievementRepository.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + } +} diff --git a/tests/Orbit.Application.Tests/Social/SetHandleCommandTests.cs b/tests/Orbit.Application.Tests/Social/SetHandleCommandTests.cs new file mode 100644 index 00000000..0e03513e --- /dev/null +++ b/tests/Orbit.Application.Tests/Social/SetHandleCommandTests.cs @@ -0,0 +1,64 @@ +using System.Linq.Expressions; +using FluentAssertions; +using NSubstitute; +using Orbit.Application.Common; +using Orbit.Application.Profile.Commands; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Tests.Social; + +public class SetHandleCommandTests +{ + private readonly IGenericRepository _userRepository = Substitute.For>(); + private readonly IUnitOfWork _unitOfWork = Substitute.For(); + private readonly SetHandleCommandHandler _handler; + + public SetHandleCommandTests() + { + _handler = new SetHandleCommandHandler(_userRepository, _unitOfWork); + } + + [Fact] + public async Task Handle_AvailableHandle_SetsHandleAndSaves() + { + var user = SocialTestHelpers.OptedInUser(); + SocialTestHelpers.StubUsers(_userRepository, user); + _userRepository.AnyAsync(Arg.Any>>(), Arg.Any()).Returns(false); + + var result = await _handler.Handle(new SetHandleCommand(user.Id, "cosmo_42"), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + user.Handle.Should().Be("cosmo_42"); + await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task Handle_CaseInsensitiveCollision_ReturnsHandleTaken() + { + var user = SocialTestHelpers.OptedInUser(); + _userRepository.FindOneTrackedAsync( + Arg.Any>>(), + Arg.Any, IQueryable>?>(), + Arg.Any()) + .Returns(user); + _userRepository.AnyAsync(Arg.Any>>(), Arg.Any()).Returns(true); + + var result = await _handler.Handle(new SetHandleCommand(user.Id, "Taken"), CancellationToken.None); + + result.IsFailure.Should().BeTrue(); + result.ErrorCode.Should().Be(ErrorCodes.HandleTaken); + await _unitOfWork.DidNotReceive().SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task Handle_UnknownUser_ReturnsUserNotFound() + { + SocialTestHelpers.StubUsers(_userRepository); + + var result = await _handler.Handle(new SetHandleCommand(Guid.NewGuid(), "valid_handle"), CancellationToken.None); + + result.IsFailure.Should().BeTrue(); + result.ErrorCode.Should().Be(ErrorCodes.UserNotFound); + } +} diff --git a/tests/Orbit.Application.Tests/Social/SocialAccessGuardTests.cs b/tests/Orbit.Application.Tests/Social/SocialAccessGuardTests.cs new file mode 100644 index 00000000..988fedb7 --- /dev/null +++ b/tests/Orbit.Application.Tests/Social/SocialAccessGuardTests.cs @@ -0,0 +1,54 @@ +using FluentAssertions; +using NSubstitute; +using Orbit.Application.Common; +using Orbit.Application.Social.Services; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Tests.Social; + +public class SocialAccessGuardTests +{ + private readonly IGenericRepository _userRepository = Substitute.For>(); + private readonly SocialAccessGuard _guard; + + public SocialAccessGuardTests() + { + _guard = new SocialAccessGuard(_userRepository); + } + + [Fact] + public async Task EnsureEnabled_OptedIn_ReturnsTrackedUser() + { + var user = SocialTestHelpers.OptedInUser(); + SocialTestHelpers.StubUsers(_userRepository, user); + + var result = await _guard.EnsureEnabledAsync(user.Id, CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeSameAs(user); + } + + [Fact] + public async Task EnsureEnabled_OptedOut_ReturnsSocialDisabled() + { + var user = SocialTestHelpers.OptedOutUser(); + SocialTestHelpers.StubUsers(_userRepository, user); + + var result = await _guard.EnsureEnabledAsync(user.Id, CancellationToken.None); + + result.IsFailure.Should().BeTrue(); + result.ErrorCode.Should().Be(ErrorCodes.SocialDisabled); + } + + [Fact] + public async Task EnsureEnabled_UnknownUser_ReturnsUserNotFound() + { + SocialTestHelpers.StubUsers(_userRepository); + + var result = await _guard.EnsureEnabledAsync(Guid.NewGuid(), CancellationToken.None); + + result.IsFailure.Should().BeTrue(); + result.ErrorCode.Should().Be(ErrorCodes.UserNotFound); + } +} diff --git a/tests/Orbit.Application.Tests/Social/SocialTestHelpers.cs b/tests/Orbit.Application.Tests/Social/SocialTestHelpers.cs new file mode 100644 index 00000000..d99791ca --- /dev/null +++ b/tests/Orbit.Application.Tests/Social/SocialTestHelpers.cs @@ -0,0 +1,59 @@ +using System.Linq.Expressions; +using NSubstitute; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Tests.Social; + +/// +/// Shared arrangement helpers for the social handler tests. The access guard and friend-graph +/// services are concrete (non-virtual), so the tests construct them for real over substituted +/// repositories whose lookups resolve against an in-memory set by compiling the LINQ predicate. +/// +internal static class SocialTestHelpers +{ + public static User OptedInUser(string name = "Test User") + { + var user = User.Create(name, $"{Guid.NewGuid():N}@example.com").Value; + user.SeedDefaultHandle(); + user.SetSocialOptIn(true); + return user; + } + + public static User OptedOutUser(string name = "Private User") + { + var user = User.Create(name, $"{Guid.NewGuid():N}@example.com").Value; + user.SeedDefaultHandle(); + return user; + } + + public static void StubUsers(IGenericRepository repository, params User[] users) => + StubFind(repository, users); + + public static void StubFind(IGenericRepository repository, params T[] items) where T : Entity + { + repository.FindOneTrackedAsync( + Arg.Any>>(), + Arg.Any, IQueryable>?>(), + Arg.Any()) + .Returns(call => items.FirstOrDefault(call.Arg>>().Compile())); + + repository.FindAsync( + Arg.Any>>(), + Arg.Any()) + .Returns(call => (IReadOnlyList)items + .Where(call.Arg>>().Compile()) + .ToList()); + + repository.AnyAsync( + Arg.Any>>(), + Arg.Any()) + .Returns(call => items.Any(call.Arg>>().Compile())); + + repository.CountAsync( + Arg.Any>>(), + Arg.Any()) + .Returns(call => items.Count(call.Arg>>().Compile())); + } +} diff --git a/tests/Orbit.Application.Tests/Social/SocialValidatorsTests.cs b/tests/Orbit.Application.Tests/Social/SocialValidatorsTests.cs new file mode 100644 index 00000000..ae59cba1 --- /dev/null +++ b/tests/Orbit.Application.Tests/Social/SocialValidatorsTests.cs @@ -0,0 +1,131 @@ +using System.Text; +using FluentAssertions; +using Orbit.Application.Profile.Commands; +using Orbit.Application.Profile.Validators; +using Orbit.Application.Social.Commands; +using Orbit.Application.Social.Queries; +using Orbit.Application.Social.Validators; +using Orbit.Domain.Enums; + +namespace Orbit.Application.Tests.Social; + +public class SocialValidatorsTests +{ + private static readonly Guid UserId = Guid.NewGuid(); + + [Theory] + [InlineData("handle", null, true)] + [InlineData(null, "REF123", true)] + [InlineData(null, null, false)] + [InlineData("handle", "REF123", false)] + public void SendFriendRequest_RequiresExactlyOneIdentifier(string? handle, string? referralCode, bool expectedValid) + { + var validator = new SendFriendRequestCommandValidator(); + var result = validator.Validate(new SendFriendRequestCommand(UserId, handle, referralCode)); + result.IsValid.Should().Be(expectedValid); + } + + [Fact] + public void SendCheer_RejectsNoteOver200Chars() + { + var validator = new SendCheerCommandValidator(); + var result = validator.Validate(new SendCheerCommand(UserId, Guid.NewGuid(), Guid.NewGuid(), new string('x', 201))); + result.IsValid.Should().BeFalse(); + } + + [Fact] + public void SendCheer_RejectsCheeringSelf() + { + var validator = new SendCheerCommandValidator(); + var result = validator.Validate(new SendCheerCommand(UserId, UserId, Guid.NewGuid(), "hi")); + result.IsValid.Should().BeFalse(); + } + + [Fact] + public void SendCheer_AcceptsValidCommand() + { + var validator = new SendCheerCommandValidator(); + var result = validator.Validate(new SendCheerCommand(UserId, Guid.NewGuid(), Guid.NewGuid(), "nice work")); + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void Report_RejectsUnknownReason() + { + var validator = new ReportUserCommandValidator(); + var result = validator.Validate(new ReportUserCommand(UserId, Guid.NewGuid(), (ReportReason)999, null, null)); + result.IsValid.Should().BeFalse(); + } + + [Fact] + public void Report_RejectsDetailsOver500Chars() + { + var validator = new ReportUserCommandValidator(); + var result = validator.Validate(new ReportUserCommand(UserId, Guid.NewGuid(), ReportReason.Spam, new string('x', 501), null)); + result.IsValid.Should().BeFalse(); + } + + [Theory] + [InlineData("received", true)] + [InlineData("sent", true)] + [InlineData("everything", false)] + public void GetCheers_ValidatesDirection(string direction, bool expectedValid) + { + var validator = new GetCheersQueryValidator(); + var result = validator.Validate(new GetCheersQuery(UserId, direction)); + result.IsValid.Should().Be(expectedValid); + } + + [Theory] + [InlineData(0, false)] + [InlineData(51, false)] + [InlineData(30, true)] + [InlineData(null, true)] + public void GetFriendFeed_ValidatesPageSize(int? pageSize, bool expectedValid) + { + var validator = new GetFriendFeedQueryValidator(); + var result = validator.Validate(new GetFriendFeedQuery(UserId, null, pageSize)); + result.IsValid.Should().Be(expectedValid); + } + + [Fact] + public void GetFriendFeed_RejectsMalformedCursor() + { + var validator = new GetFriendFeedQueryValidator(); + var result = validator.Validate(new GetFriendFeedQuery(UserId, "!!!not-a-cursor!!!", null)); + result.IsValid.Should().BeFalse(); + } + + [Fact] + public void GetFriendFeed_AcceptsWellFormedCursor() + { + var raw = $"{DateTime.UtcNow.Ticks}:{Guid.NewGuid():N}"; + var cursor = Convert.ToBase64String(Encoding.UTF8.GetBytes(raw)).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + var validator = new GetFriendFeedQueryValidator(); + var result = validator.Validate(new GetFriendFeedQuery(UserId, cursor, null)); + result.IsValid.Should().BeTrue(); + } + + [Theory] + [InlineData("ab", false)] + [InlineData("has space", false)] + [InlineData("bad-dash", false)] + [InlineData("good_handle", true)] + public void SetHandle_ValidatesFormat(string handle, bool expectedValid) + { + var validator = new SetHandleCommandValidator(); + var result = validator.Validate(new SetHandleCommand(UserId, handle)); + result.IsValid.Should().Be(expectedValid); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void GetFriends_RequiresUserId(bool hasUserId) + { + var validator = new GetFriendsQueryValidator(); + var result = validator.Validate(new GetFriendsQuery(hasUserId ? UserId : Guid.Empty)); + result.IsValid.Should().Be(hasUserId); + } +} diff --git a/tests/Orbit.Application.Tests/Validators/SendFriendRequestCommandValidatorTests.cs b/tests/Orbit.Application.Tests/Validators/SendFriendRequestCommandValidatorTests.cs new file mode 100644 index 00000000..1c51515a --- /dev/null +++ b/tests/Orbit.Application.Tests/Validators/SendFriendRequestCommandValidatorTests.cs @@ -0,0 +1,79 @@ +using FluentAssertions; +using FluentValidation.TestHelper; +using Orbit.Application.Common; +using Orbit.Application.Social.Commands; +using Orbit.Application.Social.Validators; + +namespace Orbit.Application.Tests.Validators; + +public class SendFriendRequestCommandValidatorTests +{ + private readonly SendFriendRequestCommandValidator _validator = new(); + + [Fact] + public void Validate_HandleOnly_NoErrors() + { + var result = _validator.TestValidate(new SendFriendRequestCommand(Guid.NewGuid(), "alice", null)); + result.ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void Validate_ReferralCodeOnly_NoErrors() + { + var result = _validator.TestValidate(new SendFriendRequestCommand(Guid.NewGuid(), null, "ABC123")); + result.ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void Validate_EmptyUserId_HasError() + { + var result = _validator.TestValidate(new SendFriendRequestCommand(Guid.Empty, "alice", null)); + result.ShouldHaveValidationErrorFor(x => x.UserId); + } + + [Fact] + public void Validate_BothIdentifiers_HasError() + { + var result = _validator.TestValidate(new SendFriendRequestCommand(Guid.NewGuid(), "alice", "ABC123")); + result.IsValid.Should().BeFalse(); + } + + [Fact] + public void Validate_NeitherIdentifier_HasError() + { + var result = _validator.TestValidate(new SendFriendRequestCommand(Guid.NewGuid(), null, null)); + result.IsValid.Should().BeFalse(); + } + + [Fact] + public void Validate_HandleOverMaxLength_HasError() + { + var handle = new string('a', AppConstants.HandleMaxLength + 1); + var result = _validator.TestValidate(new SendFriendRequestCommand(Guid.NewGuid(), handle, null)); + result.ShouldHaveValidationErrorFor(x => x.Handle); + } + + [Fact] + public void Validate_HandleAtMaxLength_NoHandleError() + { + var handle = new string('a', AppConstants.HandleMaxLength); + var result = _validator.TestValidate(new SendFriendRequestCommand(Guid.NewGuid(), handle, null)); + result.ShouldNotHaveValidationErrorFor(x => x.Handle); + } + + [Fact] + public void Validate_ReferralCodeOver64Chars_HasError() + { + var referralCode = new string('A', 65); + var result = _validator.TestValidate(new SendFriendRequestCommand(Guid.NewGuid(), null, referralCode)); + result.ShouldHaveValidationErrorFor(x => x.ReferralCode); + } + + [Fact] + public void Validate_ReferralCodeExactly64Chars_NoReferralCodeError() + { + var referralCode = new string('A', 64); + var result = _validator.TestValidate(new SendFriendRequestCommand(Guid.NewGuid(), null, referralCode)); + result.ShouldNotHaveValidationErrorFor(x => x.ReferralCode); + } +} diff --git a/tests/Orbit.Application.Tests/Validators/UnblockUserCommandValidatorTests.cs b/tests/Orbit.Application.Tests/Validators/UnblockUserCommandValidatorTests.cs new file mode 100644 index 00000000..261b0781 --- /dev/null +++ b/tests/Orbit.Application.Tests/Validators/UnblockUserCommandValidatorTests.cs @@ -0,0 +1,43 @@ +using FluentValidation.TestHelper; +using Orbit.Application.Social.Commands; +using Orbit.Application.Social.Validators; + +namespace Orbit.Application.Tests.Validators; + +public class UnblockUserCommandValidatorTests +{ + private readonly UnblockUserCommandValidator _validator = new(); + + private static UnblockUserCommand ValidCommand() => new( + UserId: Guid.NewGuid(), + BlockedUserId: Guid.NewGuid()); + + [Fact] + public void Validate_ValidCommand_NoErrors() + { + var result = _validator.TestValidate(ValidCommand()); + result.ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void Validate_EmptyUserId_HasError() + { + var result = _validator.TestValidate(ValidCommand() with { UserId = Guid.Empty }); + result.ShouldHaveValidationErrorFor(x => x.UserId); + } + + [Fact] + public void Validate_EmptyBlockedUserId_HasError() + { + var result = _validator.TestValidate(ValidCommand() with { BlockedUserId = Guid.Empty }); + result.ShouldHaveValidationErrorFor(x => x.BlockedUserId); + } + + [Fact] + public void Validate_SelfUnblock_HasError() + { + var id = Guid.NewGuid(); + var result = _validator.TestValidate(new UnblockUserCommand(id, id)); + result.ShouldHaveValidationErrorFor(x => x.BlockedUserId); + } +} diff --git a/tests/Orbit.Domain.Tests/Entities/SocialEntitiesTests.cs b/tests/Orbit.Domain.Tests/Entities/SocialEntitiesTests.cs new file mode 100644 index 00000000..a993cb81 --- /dev/null +++ b/tests/Orbit.Domain.Tests/Entities/SocialEntitiesTests.cs @@ -0,0 +1,305 @@ +using FluentAssertions; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Enums; + +namespace Orbit.Domain.Tests.Entities; + +public class SocialEntitiesTests +{ + [Fact] + public void Friendship_Create_DifferentUsers_ReturnsPendingRequest() + { + var requesterId = Guid.NewGuid(); + var addresseeId = Guid.NewGuid(); + + var result = Friendship.Create(requesterId, addresseeId); + + result.IsSuccess.Should().BeTrue(); + result.Value.RequesterId.Should().Be(requesterId); + result.Value.AddresseeId.Should().Be(addresseeId); + result.Value.Status.Should().Be(FriendshipStatus.Pending); + result.Value.RespondedAtUtc.Should().BeNull(); + result.Value.CreatedAtUtc.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)); + } + + [Fact] + public void Friendship_Create_SameUser_ReturnsCannotFriendSelf() + { + var userId = Guid.NewGuid(); + + var result = Friendship.Create(userId, userId); + + result.IsFailure.Should().BeTrue(); + result.ErrorCode.Should().Be(DomainErrors.CannotFriendSelf.Code); + } + + [Fact] + public void Friendship_Accept_PendingRequest_TransitionsToAccepted() + { + var friendship = Friendship.Create(Guid.NewGuid(), Guid.NewGuid()).Value; + + var result = friendship.Accept(); + + result.IsSuccess.Should().BeTrue(); + friendship.Status.Should().Be(FriendshipStatus.Accepted); + friendship.RespondedAtUtc.Should().NotBeNull(); + friendship.RespondedAtUtc!.Value.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)); + } + + [Fact] + public void Friendship_Accept_AlreadyAccepted_ReturnsFriendshipNotPending() + { + var friendship = Friendship.Create(Guid.NewGuid(), Guid.NewGuid()).Value; + friendship.Accept(); + + var result = friendship.Accept(); + + result.IsFailure.Should().BeTrue(); + result.ErrorCode.Should().Be(DomainErrors.FriendshipNotPending.Code); + friendship.Status.Should().Be(FriendshipStatus.Accepted); + } + + [Fact] + public void Cheer_Create_ValidInput_ReturnsSuccess() + { + var senderId = Guid.NewGuid(); + var recipientId = Guid.NewGuid(); + var habitId = Guid.NewGuid(); + + var result = Cheer.Create(senderId, recipientId, habitId, "Great job"); + + result.IsSuccess.Should().BeTrue(); + result.Value.SenderId.Should().Be(senderId); + result.Value.RecipientId.Should().Be(recipientId); + result.Value.HabitId.Should().Be(habitId); + result.Value.Note.Should().Be("Great job"); + result.Value.CreatedAtUtc.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)); + } + + [Fact] + public void Cheer_Create_SenderEqualsRecipient_ReturnsCannotCheerSelf() + { + var userId = Guid.NewGuid(); + + var result = Cheer.Create(userId, userId, Guid.NewGuid(), null); + + result.IsFailure.Should().BeTrue(); + result.ErrorCode.Should().Be(DomainErrors.CannotCheerSelf.Code); + } + + [Fact] + public void Cheer_Create_NoteExceedsMaxLength_ReturnsCheerNoteTooLong() + { + var note = new string('a', DomainConstants.MaxCheerNoteLength + 1); + + var result = Cheer.Create(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), note); + + result.IsFailure.Should().BeTrue(); + result.ErrorCode.Should().Be(DomainErrors.CheerNoteTooLong.Code); + } + + [Fact] + public void Cheer_Create_NoteAtMaxLength_ReturnsSuccess() + { + var note = new string('a', DomainConstants.MaxCheerNoteLength); + + var result = Cheer.Create(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), note); + + result.IsSuccess.Should().BeTrue(); + result.Value.Note.Should().Be(note); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Cheer_Create_BlankNote_StoresNull(string? note) + { + var result = Cheer.Create(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), note); + + result.IsSuccess.Should().BeTrue(); + result.Value.Note.Should().BeNull(); + } + + [Fact] + public void Cheer_Create_NoteWithSurroundingWhitespace_IsTrimmed() + { + var result = Cheer.Create(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), " Keep going "); + + result.IsSuccess.Should().BeTrue(); + result.Value.Note.Should().Be("Keep going"); + } + + [Fact] + public void BlockedUser_Create_DifferentUsers_ReturnsSuccess() + { + var blockerId = Guid.NewGuid(); + var blockedId = Guid.NewGuid(); + + var result = BlockedUser.Create(blockerId, blockedId); + + result.IsSuccess.Should().BeTrue(); + result.Value.BlockerId.Should().Be(blockerId); + result.Value.BlockedId.Should().Be(blockedId); + result.Value.CreatedAtUtc.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)); + } + + [Fact] + public void BlockedUser_Create_SameUser_ReturnsCannotBlockSelf() + { + var userId = Guid.NewGuid(); + + var result = BlockedUser.Create(userId, userId); + + result.IsFailure.Should().BeTrue(); + result.ErrorCode.Should().Be(DomainErrors.CannotBlockSelf.Code); + } + + [Fact] + public void Report_Create_ValidInput_ReturnsPending() + { + var reporterId = Guid.NewGuid(); + var reportedUserId = Guid.NewGuid(); + + var result = Report.Create(reporterId, reportedUserId, ReportReason.Spam, "Sending spam links", null); + + result.IsSuccess.Should().BeTrue(); + result.Value.ReporterId.Should().Be(reporterId); + result.Value.ReportedUserId.Should().Be(reportedUserId); + result.Value.Reason.Should().Be(ReportReason.Spam); + result.Value.Details.Should().Be("Sending spam links"); + result.Value.Status.Should().Be(ReportStatus.Pending); + result.Value.CreatedAtUtc.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)); + } + + [Theory] + [InlineData(ReportReason.Spam)] + [InlineData(ReportReason.Harassment)] + [InlineData(ReportReason.InappropriateContent)] + [InlineData(ReportReason.Impersonation)] + [InlineData(ReportReason.Other)] + public void Report_Create_StoresReason(ReportReason reason) + { + var result = Report.Create(Guid.NewGuid(), Guid.NewGuid(), reason, null, null); + + result.IsSuccess.Should().BeTrue(); + result.Value.Reason.Should().Be(reason); + } + + [Fact] + public void Report_Create_SameUser_ReturnsCannotReportSelf() + { + var userId = Guid.NewGuid(); + + var result = Report.Create(userId, userId, ReportReason.Spam, null, null); + + result.IsFailure.Should().BeTrue(); + result.ErrorCode.Should().Be(DomainErrors.CannotReportSelf.Code); + } + + [Fact] + public void Report_Create_DetailsExceedMaxLength_ReturnsReportDetailsTooLong() + { + var details = new string('a', DomainConstants.MaxReportDetailsLength + 1); + + var result = Report.Create(Guid.NewGuid(), Guid.NewGuid(), ReportReason.Other, details, null); + + result.IsFailure.Should().BeTrue(); + result.ErrorCode.Should().Be(DomainErrors.ReportDetailsTooLong.Code); + } + + [Fact] + public void Report_Create_DetailsAtMaxLength_ReturnsSuccess() + { + var details = new string('a', DomainConstants.MaxReportDetailsLength); + + var result = Report.Create(Guid.NewGuid(), Guid.NewGuid(), ReportReason.Other, details, null); + + result.IsSuccess.Should().BeTrue(); + result.Value.Details.Should().Be(details); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Report_Create_BlankDetails_StoresNull(string? details) + { + var result = Report.Create(Guid.NewGuid(), Guid.NewGuid(), ReportReason.Spam, details, null); + + result.IsSuccess.Should().BeTrue(); + result.Value.Details.Should().BeNull(); + } + + [Fact] + public void Report_Create_DetailsWithSurroundingWhitespace_IsTrimmed() + { + var result = Report.Create(Guid.NewGuid(), Guid.NewGuid(), ReportReason.Spam, " abusive ", null); + + result.IsSuccess.Should().BeTrue(); + result.Value.Details.Should().Be("abusive"); + } + + [Fact] + public void Report_Create_NullCheerId_StoresNull() + { + var result = Report.Create(Guid.NewGuid(), Guid.NewGuid(), ReportReason.Spam, null, null); + + result.IsSuccess.Should().BeTrue(); + result.Value.CheerId.Should().BeNull(); + } + + [Fact] + public void Report_Create_NonNullCheerId_StoresValue() + { + var cheerId = Guid.NewGuid(); + + var result = Report.Create(Guid.NewGuid(), Guid.NewGuid(), ReportReason.InappropriateContent, null, cheerId); + + result.IsSuccess.Should().BeTrue(); + result.Value.CheerId.Should().Be(cheerId); + } + + [Fact] + public void FriendFeedEvent_StreakMilestone_SetsTypeAndValue() + { + var actorUserId = Guid.NewGuid(); + + var feedEvent = FriendFeedEvent.StreakMilestone(actorUserId, 30); + + feedEvent.ActorUserId.Should().Be(actorUserId); + feedEvent.Type.Should().Be(FriendFeedEventType.StreakMilestone); + feedEvent.Value.Should().Be(30); + feedEvent.AchievementId.Should().BeNull(); + feedEvent.CreatedAtUtc.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)); + } + + [Fact] + public void FriendFeedEvent_AchievementUnlocked_SetsTypeAndAchievementId() + { + var actorUserId = Guid.NewGuid(); + + var feedEvent = FriendFeedEvent.AchievementUnlocked(actorUserId, "first_habit"); + + feedEvent.ActorUserId.Should().Be(actorUserId); + feedEvent.Type.Should().Be(FriendFeedEventType.AchievementUnlocked); + feedEvent.AchievementId.Should().Be("first_habit"); + feedEvent.Value.Should().BeNull(); + feedEvent.CreatedAtUtc.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)); + } + + [Fact] + public void FriendFeedEvent_HabitCompletedMilestone_SetsAllFields() + { + var actorUserId = Guid.NewGuid(); + + var feedEvent = FriendFeedEvent.HabitCompletedMilestone(actorUserId, "century_club", 100); + + feedEvent.ActorUserId.Should().Be(actorUserId); + feedEvent.Type.Should().Be(FriendFeedEventType.HabitCompletedMilestone); + feedEvent.Value.Should().Be(100); + feedEvent.AchievementId.Should().Be("century_club"); + feedEvent.CreatedAtUtc.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)); + } +} diff --git a/tests/Orbit.Domain.Tests/Entities/UserHandleTests.cs b/tests/Orbit.Domain.Tests/Entities/UserHandleTests.cs new file mode 100644 index 00000000..eec195ef --- /dev/null +++ b/tests/Orbit.Domain.Tests/Entities/UserHandleTests.cs @@ -0,0 +1,150 @@ +using FluentAssertions; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; + +namespace Orbit.Domain.Tests.Entities; + +public class UserHandleTests +{ + private static User CreateUser() => User.Create("Thomas", "thomas@example.com").Value; + + [Theory] + [InlineData("abc")] + [InlineData("user_123")] + [InlineData("ABC")] + [InlineData("a_b")] + public void SetHandle_ValidHandle_SetsHandle(string handle) + { + var user = CreateUser(); + + var result = user.SetHandle(handle); + + result.IsSuccess.Should().BeTrue(); + user.Handle.Should().Be(handle); + } + + [Fact] + public void SetHandle_MaxLengthHandle_SetsHandle() + { + var user = CreateUser(); + var handle = new string('a', DomainConstants.HandleMaxLength); + + var result = user.SetHandle(handle); + + result.IsSuccess.Should().BeTrue(); + user.Handle.Should().Be(handle); + } + + [Theory] + [InlineData("ab")] + [InlineData("a b")] + [InlineData("a-b")] + [InlineData("a.b")] + [InlineData("a@b")] + [InlineData("café1")] + [InlineData("")] + [InlineData(" ")] + public void SetHandle_InvalidHandle_ReturnsFailureAndLeavesHandleUnset(string handle) + { + var user = CreateUser(); + + var result = user.SetHandle(handle); + + result.IsFailure.Should().BeTrue(); + result.ErrorCode.Should().Be(DomainErrors.InvalidHandle.Code); + user.Handle.Should().BeNull(); + } + + [Fact] + public void SetHandle_TooLongHandle_ReturnsFailure() + { + var user = CreateUser(); + var handle = new string('a', DomainConstants.HandleMaxLength + 1); + + var result = user.SetHandle(handle); + + result.IsFailure.Should().BeTrue(); + result.ErrorCode.Should().Be(DomainErrors.InvalidHandle.Code); + user.Handle.Should().BeNull(); + } + + [Fact] + public void SetHandle_InvalidAfterValid_LeavesExistingHandleUnchanged() + { + var user = CreateUser(); + user.SetHandle("validhandle"); + + var result = user.SetHandle("not valid"); + + result.IsFailure.Should().BeTrue(); + user.Handle.Should().Be("validhandle"); + } + + [Fact] + public void SeedDefaultHandle_ProducesPrefixedSeventeenCharHandle() + { + var user = CreateUser(); + + user.SeedDefaultHandle(); + + user.Handle.Should().NotBeNull(); + user.Handle!.Should().HaveLength(17); + user.Handle.Should().StartWith("user_"); + } + + [Fact] + public void SeedDefaultHandle_MatchesDeterministicFormula() + { + var user = CreateUser(); + + user.SeedDefaultHandle(); + + var expected = "user_" + user.Id.ToString("N")[..12]; + user.Handle.Should().Be(expected); + } + + [Fact] + public void SeedDefaultHandle_IsDeterministic() + { + var user = CreateUser(); + + user.SeedDefaultHandle(); + var first = user.Handle; + user.SeedDefaultHandle(); + var second = user.Handle; + + second.Should().Be(first); + } + + [Fact] + public void SeedDefaultHandle_ResultIsAValidSettableHandle() + { + var user = CreateUser(); + user.SeedDefaultHandle(); + var seeded = user.Handle!; + + var result = CreateUser().SetHandle(seeded); + + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public void SetSocialOptIn_DefaultsToFalse() + { + var user = CreateUser(); + + user.SocialOptIn.Should().BeFalse(); + } + + [Fact] + public void SetSocialOptIn_TogglesValue() + { + var user = CreateUser(); + + user.SetSocialOptIn(true); + user.SocialOptIn.Should().BeTrue(); + + user.SetSocialOptIn(false); + user.SocialOptIn.Should().BeFalse(); + } +} diff --git a/tests/Orbit.Domain.Tests/Entities/UserTests.cs b/tests/Orbit.Domain.Tests/Entities/UserTests.cs index cf2ae3d5..cef623af 100644 --- a/tests/Orbit.Domain.Tests/Entities/UserTests.cs +++ b/tests/Orbit.Domain.Tests/Entities/UserTests.cs @@ -635,13 +635,13 @@ public void SetLevel_BelowMinimum_NoChange() } [Fact] - public void SetLevel_AboveMaximum_NoChange() + public void SetLevel_PastTableMax_UpdatesLevel() { var user = CreateValidUser(); user.SetLevel(11); - user.Level.Should().Be(1); + user.Level.Should().Be(11); } [Fact] @@ -842,4 +842,65 @@ public void AwardStreakFreezeIfEligible_AlreadyAwardedMilestone_DoesNotDoubleAwa awardedAgain.Should().BeFalse(); user.StreakFreezesAccumulated.Should().Be(1); } + + [Fact] + public void Create_OnboardingChecklistFlags_DefaultFalse() + { + var user = CreateValidUser(); + + user.HasCreatedFirstHabit.Should().BeFalse(); + user.HasLoggedFirstHabit.Should().BeFalse(); + user.HasTriedAstra.Should().BeFalse(); + user.HasCompletedOnboardingChecklist.Should().BeFalse(); + } + + [Fact] + public void MarkFirstHabitCreated_SetsFlagAndIsIdempotent() + { + var user = CreateValidUser(); + + user.MarkFirstHabitCreated(); + user.MarkFirstHabitCreated(); + + user.HasCreatedFirstHabit.Should().BeTrue(); + user.HasLoggedFirstHabit.Should().BeFalse(); + user.HasTriedAstra.Should().BeFalse(); + } + + [Fact] + public void MarkFirstHabitLogged_SetsFlagAndIsIdempotent() + { + var user = CreateValidUser(); + + user.MarkFirstHabitLogged(); + user.MarkFirstHabitLogged(); + + user.HasLoggedFirstHabit.Should().BeTrue(); + user.HasCreatedFirstHabit.Should().BeFalse(); + user.HasTriedAstra.Should().BeFalse(); + } + + [Fact] + public void MarkAstraUsed_SetsFlagAndIsIdempotent() + { + var user = CreateValidUser(); + + user.MarkAstraUsed(); + user.MarkAstraUsed(); + + user.HasTriedAstra.Should().BeTrue(); + user.HasCreatedFirstHabit.Should().BeFalse(); + user.HasLoggedFirstHabit.Should().BeFalse(); + } + + [Fact] + public void CompleteOnboardingChecklist_SetsFlagAndIsIdempotent() + { + var user = CreateValidUser(); + + user.CompleteOnboardingChecklist(); + user.CompleteOnboardingChecklist(); + + user.HasCompletedOnboardingChecklist.Should().BeTrue(); + } } diff --git a/tests/Orbit.Infrastructure.Tests/AI/ContentModerationServiceTests.cs b/tests/Orbit.Infrastructure.Tests/AI/ContentModerationServiceTests.cs new file mode 100644 index 00000000..8b762933 --- /dev/null +++ b/tests/Orbit.Infrastructure.Tests/AI/ContentModerationServiceTests.cs @@ -0,0 +1,122 @@ +using System.Net; +using System.Text; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Orbit.Infrastructure.AI; +using Orbit.Infrastructure.Configuration; + +namespace Orbit.Infrastructure.Tests.AI; + +public class ContentModerationServiceTests +{ + private readonly StubHttpMessageHandler _handler = new(); + private readonly ContentModerationService _sut; + + public ContentModerationServiceTests() + { + var httpClient = new HttpClient(_handler) + { + BaseAddress = new Uri("https://api.openai.com/v1") + }; + var aiSettings = Options.Create(new AiSettings + { + ApiKey = "test-key", + BaseUrl = "https://api.openai.com/v1" + }); + _sut = new ContentModerationService(httpClient, aiSettings, NullLogger.Instance); + } + + [Fact] + public async Task CheckTextAsync_FlaggedResponse_ReturnsOnlyTrueCategories() + { + _handler.Response = JsonResponse(HttpStatusCode.OK, """ + {"results":[{"flagged":true,"categories":{"harassment":true,"sexual":false}}]} + """); + + var result = await _sut.CheckTextAsync("borderline text"); + + result.Flagged.Should().BeTrue(); + result.Unavailable.Should().BeFalse(); + result.Categories.Should().Contain("harassment"); + result.Categories.Should().NotContain("sexual"); + } + + [Fact] + public async Task CheckTextAsync_NotFlaggedResponse_ReturnsCleanAvailableResult() + { + _handler.Response = JsonResponse(HttpStatusCode.OK, """ + {"results":[{"flagged":false,"categories":{"harassment":false,"violence":false}}]} + """); + + var result = await _sut.CheckTextAsync("a kind note"); + + result.Flagged.Should().BeFalse(); + result.Unavailable.Should().BeFalse(); + result.Categories.Should().BeEmpty(); + } + + [Fact] + public async Task CheckTextAsync_NonSuccessStatus_ReturnsUnavailableWithoutThrowing() + { + _handler.Response = new HttpResponseMessage(HttpStatusCode.InternalServerError); + + var result = await _sut.CheckTextAsync("any text"); + + result.Unavailable.Should().BeTrue(); + result.Flagged.Should().BeFalse(); + } + + [Fact] + public async Task CheckTextAsync_HttpRequestException_ReturnsUnavailableWithoutThrowing() + { + _handler.ExceptionToThrow = new HttpRequestException("connection refused"); + + var result = await _sut.CheckTextAsync("any text"); + + result.Unavailable.Should().BeTrue(); + result.Flagged.Should().BeFalse(); + } + + [Fact] + public async Task CheckTextAsync_TimeoutWhileCallerTokenLive_ReturnsUnavailableWithoutThrowing() + { + _handler.ExceptionToThrow = new TaskCanceledException("the request timed out"); + + var result = await _sut.CheckTextAsync("any text", CancellationToken.None); + + result.Unavailable.Should().BeTrue(); + result.Flagged.Should().BeFalse(); + } + + [Fact] + public async Task CheckTextAsync_CallerCancelsToken_RethrowsOperationCanceled() + { + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + var act = async () => await _sut.CheckTextAsync("any text", cancellation.Token); + + await act.Should().ThrowAsync(); + } + + private static HttpResponseMessage JsonResponse(HttpStatusCode statusCode, string body) => + new(statusCode) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; + + private sealed class StubHttpMessageHandler : HttpMessageHandler + { + public HttpResponseMessage? Response { get; set; } + public Exception? ExceptionToThrow { get; set; } + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (ExceptionToThrow is not null) + throw ExceptionToThrow; + + return Task.FromResult(Response ?? new HttpResponseMessage(HttpStatusCode.OK)); + } + } +} diff --git a/tests/Orbit.Infrastructure.Tests/Controllers/AiControllerTests.cs b/tests/Orbit.Infrastructure.Tests/Controllers/AiControllerTests.cs index 8fc171a0..e5279dc2 100644 --- a/tests/Orbit.Infrastructure.Tests/Controllers/AiControllerTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Controllers/AiControllerTests.cs @@ -493,6 +493,42 @@ public async Task ResolveClarification_SuccessfulPath_DispatchesAndReturnsOk() ok.Value.Should().Be(executorResponse); } + [Fact] + public async Task ResolveClarification_SuccessfulPath_DispatchesMergedArgsThroughExecutorWithoutBilling() + { + StubValidatorOutcome(isValid: true); + StubPendingClarification( + toolName: "create_habit", + partialArgs: "{\"title\":\"Morning habit\"}", + allowedValues: ["{\"frequency_unit\":\"Day\",\"frequency_quantity\":1}"]); + _pendingClarificationStore + .MarkResolvedAsync(Arg.Any(), UserId, Arg.Any()) + .Returns(true); + _operationExecutor.ExecuteAsync(Arg.Any(), Arg.Any()) + .Returns(new AgentExecuteOperationResponse(new AgentOperationResult( + OperationId: "create_habit", + SourceName: "create_habit", + RiskClass: AgentRiskClass.Low, + ConfirmationRequirement: AgentConfirmationRequirement.None, + Status: AgentOperationStatus.Succeeded, + TargetName: "Morning habit"))); + + await _controller.ResolveClarification( + Guid.NewGuid(), + new ResolveClarificationRequest("{\"frequency_unit\":\"Day\",\"frequency_quantity\":1}"), + CancellationToken.None); + + await _operationExecutor.Received(1).ExecuteAsync( + Arg.Is(request => + request.UserId == UserId && + request.OperationId == "create_habit" && + request.Surface == AgentExecutionSurface.Chat && + request.ConfirmationToken == null && + request.Arguments.GetProperty("title").GetString() == "Morning habit" && + request.Arguments.GetProperty("frequency_unit").GetString() == "Day"), + Arg.Any()); + } + private void StubValidatorOutcome(bool isValid, string? propertyName = null, string? message = null) { var validationResult = isValid diff --git a/tests/Orbit.Infrastructure.Tests/Controllers/FriendsControllerRateLimitTests.cs b/tests/Orbit.Infrastructure.Tests/Controllers/FriendsControllerRateLimitTests.cs new file mode 100644 index 00000000..446afef8 --- /dev/null +++ b/tests/Orbit.Infrastructure.Tests/Controllers/FriendsControllerRateLimitTests.cs @@ -0,0 +1,44 @@ +using System.Reflection; +using FluentAssertions; +using Orbit.Api.Controllers; +using Orbit.Api.RateLimiting; + +namespace Orbit.Infrastructure.Tests.Controllers; + +public class FriendsControllerRateLimitTests +{ + [Theory] + [InlineData(nameof(FriendsController.SendCheer))] + [InlineData(nameof(FriendsController.SendRequest))] + [InlineData(nameof(FriendsController.Report))] + [InlineData(nameof(FriendsController.Block))] + [InlineData(nameof(FriendsController.Unblock))] + public void AbuseProneActions_AreRateLimited(string actionName) + { + var rateLimitAttributes = GetAction(actionName) + .GetCustomAttributes(inherit: false) + .ToList(); + + rateLimitAttributes.Should().HaveCount(1); + } + + [Theory] + [InlineData(nameof(FriendsController.AcceptRequest))] + [InlineData(nameof(FriendsController.RemoveFriend))] + [InlineData(nameof(FriendsController.GetFeed))] + public void NonMutatingOrLowRiskActions_AreNotRateLimited(string actionName) + { + var rateLimitAttributes = GetAction(actionName) + .GetCustomAttributes(inherit: false) + .ToList(); + + rateLimitAttributes.Should().BeEmpty(); + } + + private static MethodInfo GetAction(string actionName) + { + var method = typeof(FriendsController).GetMethod(actionName, BindingFlags.Public | BindingFlags.Instance); + method.Should().NotBeNull($"FriendsController should expose a public action named '{actionName}'"); + return method!; + } +} diff --git a/tests/Orbit.Infrastructure.Tests/Controllers/GamificationControllerTests.cs b/tests/Orbit.Infrastructure.Tests/Controllers/GamificationControllerTests.cs index d4ef5dc6..131dac4a 100644 --- a/tests/Orbit.Infrastructure.Tests/Controllers/GamificationControllerTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Controllers/GamificationControllerTests.cs @@ -7,18 +7,20 @@ using Orbit.Api.Controllers; using Orbit.Application.Gamification.Queries; using Orbit.Domain.Common; +using Orbit.Domain.Interfaces; namespace Orbit.Infrastructure.Tests.Controllers; public class GamificationControllerTests { private readonly IMediator _mediator = Substitute.For(); + private readonly IUserDateService _userDateService = Substitute.For(); private readonly GamificationController _controller; private static readonly Guid UserId = Guid.NewGuid(); public GamificationControllerTests() { - _controller = new GamificationController(_mediator); + _controller = new GamificationController(_mediator, _userDateService); var claims = new[] { new Claim(ClaimTypes.NameIdentifier, UserId.ToString()) }; var identity = new ClaimsIdentity(claims, "Test"); var principal = new ClaimsPrincipal(identity); @@ -107,4 +109,19 @@ public async Task GetStreakInfo_PayGateFailure_Returns403() var objectResult = result.Should().BeOfType().Subject; objectResult.StatusCode.Should().Be(403); } + + [Fact] + public async Task GetRecap_Success_ReturnsOk() + { + _userDateService.GetUserTodayAsync(UserId, Arg.Any()) + .Returns(new DateOnly(2026, 6, 20)); + _userDateService.GetUserWeekStartDayAsync(UserId, Arg.Any()) + .Returns(1); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Result.Success(default(RecapResponse)!)); + + var result = await _controller.GetRecap("week", CancellationToken.None); + + result.Should().BeOfType(); + } } diff --git a/tests/Orbit.Infrastructure.Tests/Mcp/GamificationToolsTests.cs b/tests/Orbit.Infrastructure.Tests/Mcp/GamificationToolsTests.cs index 0ddc59bb..0b35927a 100644 --- a/tests/Orbit.Infrastructure.Tests/Mcp/GamificationToolsTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Mcp/GamificationToolsTests.cs @@ -27,7 +27,8 @@ public async Task GetGamificationProfile_Success_ReturnsFormattedProfile() var profile = new GamificationProfileResponse( 1500, 5, "Achiever", 1000, 2000, 500, 3, 10, [], [], - 15, 20, new DateOnly(2026, 4, 2)); + 15, 20, new DateOnly(2026, 4, 2), + true, false, new NextRewardCarrot(6, "Achiever", 500, null)); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Result.Success(profile)); diff --git a/tests/Orbit.Infrastructure.Tests/Mcp/ProfileToolsTests.cs b/tests/Orbit.Infrastructure.Tests/Mcp/ProfileToolsTests.cs index 3d429512..d829a25f 100644 --- a/tests/Orbit.Infrastructure.Tests/Mcp/ProfileToolsTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Mcp/ProfileToolsTests.cs @@ -51,10 +51,10 @@ public async Task GetProfile_Success_ReturnsFormattedProfile() { var profile = new ProfileResponse( "Thomas", "thomas@example.com", "America/Sao_Paulo", - true, true, true, true, "pt-BR", "Pro", true, false, null, null, + true, true, true, true, true, true, true, true, "pt-BR", "Pro", true, false, null, null, 5, 100, false, false, null, null, false, 1, 500, 5, "Achiever", 0, 10, 12, 2, null, null, - false, GoogleCalendarAutoSyncStatus.Idle, null); + false, GoogleCalendarAutoSyncStatus.Idle, null, true); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Result.Success(profile)); diff --git a/tests/Orbit.Infrastructure.Tests/Persistence/AccountResetRepositoryTests.cs b/tests/Orbit.Infrastructure.Tests/Persistence/AccountResetRepositoryTests.cs index c6cd2594..7477c3ed 100644 --- a/tests/Orbit.Infrastructure.Tests/Persistence/AccountResetRepositoryTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Persistence/AccountResetRepositoryTests.cs @@ -203,6 +203,53 @@ public async Task DeleteAllUserDataAsync_EmptiesEveryOrphanProneTable_ForUserOnl (await _dbContext.Notifications.IgnoreQueryFilters().CountAsync(n => n.UserId == _otherUserId)).Should().Be(2); } + private void SeedSocialDataForUser(Guid userId, Guid counterpartId, Guid habitId) + { + _dbContext.Friendships.Add(Friendship.Create(userId, counterpartId).Value); + _dbContext.Cheers.Add(Cheer.Create(userId, counterpartId, habitId, "nice").Value); + _dbContext.Cheers.Add(Cheer.Create(counterpartId, userId, habitId, null).Value); + _dbContext.BlockedUsers.Add(BlockedUser.Create(userId, counterpartId).Value); + _dbContext.Reports.Add(Report.Create(userId, counterpartId, ReportReason.Spam, "x", null).Value); + _dbContext.FriendFeedEvents.Add(FriendFeedEvent.StreakMilestone(userId, 7)); + } + + [Fact] + public async Task DeleteAllUserDataAsync_RemovesSocialGraphForUser_LeavesUnrelatedUsersData() + { + var unrelatedUserId = Guid.NewGuid(); + var unrelatedCounterpartId = Guid.NewGuid(); + + SeedUser(_userId, "target@example.com"); + SeedUser(_otherUserId, "other@example.com"); + SeedUser(unrelatedUserId, "unrelated@example.com"); + SeedUser(unrelatedCounterpartId, "unrelated-counterpart@example.com"); + + var cheeredHabit = Habit.Create(new HabitCreateParams( + _otherUserId, "Habit", FrequencyUnit.Day, 1)).Value; + _dbContext.Habits.Add(cheeredHabit); + + SeedSocialDataForUser(_userId, _otherUserId, cheeredHabit.Id); + _dbContext.Friendships.Add(Friendship.Create(unrelatedUserId, unrelatedCounterpartId).Value); + _dbContext.FriendFeedEvents.Add(FriendFeedEvent.StreakMilestone(unrelatedUserId, 7)); + await _dbContext.SaveChangesAsync(); + + await _repository.DeleteAllUserDataAsync(_userId); + + (await _dbContext.Friendships.CountAsync(f => f.RequesterId == _userId || f.AddresseeId == _userId)) + .Should().Be(0); + (await _dbContext.Cheers.CountAsync(c => c.SenderId == _userId || c.RecipientId == _userId)) + .Should().Be(0); + (await _dbContext.BlockedUsers.CountAsync(b => b.BlockerId == _userId || b.BlockedId == _userId)) + .Should().Be(0); + (await _dbContext.Reports.CountAsync(r => r.ReporterId == _userId || r.ReportedUserId == _userId)) + .Should().Be(0); + (await _dbContext.FriendFeedEvents.CountAsync(e => e.ActorUserId == _userId)) + .Should().Be(0); + + (await _dbContext.Friendships.CountAsync(f => f.RequesterId == unrelatedUserId)).Should().Be(1); + (await _dbContext.FriendFeedEvents.CountAsync(e => e.ActorUserId == unrelatedUserId)).Should().Be(1); + } + private sealed class SqliteCompatOrbitDbContext(DbContextOptions options) : OrbitDbContext(options) { diff --git a/tests/Orbit.Infrastructure.Tests/Services/AgentPolicyEvaluatorTests.cs b/tests/Orbit.Infrastructure.Tests/Services/AgentPolicyEvaluatorTests.cs index 249e74f8..3466cea7 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/AgentPolicyEvaluatorTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/AgentPolicyEvaluatorTests.cs @@ -184,4 +184,72 @@ public void Evaluate_InShadowMode_ReturnsAllowedWithShadowDecision() decision.ShadowReason.Should().Be("step_up_required"); decision.PendingOperation.Should().BeNull(); } + + [Theory] + [InlineData(AgentCapabilityIds.HabitsDelete, "delete_habit")] + [InlineData(AgentCapabilityIds.HabitsBulkWrite, "bulk_create_habits")] + [InlineData(AgentCapabilityIds.HabitsBulkWrite, "bulk_log_habits")] + [InlineData(AgentCapabilityIds.HabitsBulkWrite, "bulk_skip_habits")] + [InlineData(AgentCapabilityIds.HabitsBulkDelete, "bulk_delete_habits")] + [InlineData(AgentCapabilityIds.TagsDelete, "delete_tag")] + public void Evaluate_DestructiveChatCapability_OnChatSurface_RequiresConfirmation( + string capabilityId, + string sourceName) + { + var decision = _policyEvaluator.Evaluate(new AgentPolicyEvaluationContext( + capabilityId, + _userId, + AgentExecutionSurface.Chat, + AgentAuthMethod.Jwt, + [], + sourceName, + $"{sourceName} via chat", + OperationFingerprint: $"{sourceName}:{{\"id\":\"123\"}}")); + + decision.Status.Should().Be(AgentPolicyDecisionStatus.ConfirmationRequired); + decision.Reason.Should().Be("confirmation_required"); + decision.PendingOperation.Should().NotBeNull(); + decision.PendingOperation!.CapabilityId.Should().Be(capabilityId); + } + + [Fact] + public void Evaluate_GoalsDelete_OnChatSurface_RequiresConfirmation() + { + var proUser = _dbContext.Users.First(item => item.Id == _userId); + proUser.StartTrial(DateTime.UtcNow.AddDays(7)); + _dbContext.AppFeatureFlags.Add(AppFeatureFlag.Create("goal_tracking", true, "Pro", "Goal tracking")); + _dbContext.SaveChanges(); + + var decision = _policyEvaluator.Evaluate(new AgentPolicyEvaluationContext( + AgentCapabilityIds.GoalsDelete, + _userId, + AgentExecutionSurface.Chat, + AgentAuthMethod.Jwt, + [], + "delete_goal", + "delete_goal via chat", + OperationFingerprint: "delete_goal:{\"goalId\":\"123\"}")); + + decision.Status.Should().Be(AgentPolicyDecisionStatus.ConfirmationRequired); + decision.PendingOperation.Should().NotBeNull(); + } + + [Theory] + [InlineData("create_habit")] + [InlineData("log_habit")] + public void Evaluate_LowRiskHabitWrite_OnChatSurface_IsAllowed(string sourceName) + { + var decision = _policyEvaluator.Evaluate(new AgentPolicyEvaluationContext( + AgentCapabilityIds.HabitsWrite, + _userId, + AgentExecutionSurface.Chat, + AgentAuthMethod.Jwt, + [], + sourceName, + $"{sourceName} via chat", + OperationFingerprint: $"{sourceName}:{{\"title\":\"Meditate\"}}")); + + decision.Status.Should().Be(AgentPolicyDecisionStatus.Allowed); + decision.PendingOperation.Should().BeNull(); + } } diff --git a/tests/Orbit.Infrastructure.Tests/Services/DistributedRateLimitServiceTests.cs b/tests/Orbit.Infrastructure.Tests/Services/DistributedRateLimitServiceTests.cs index 116a360e..3f356526 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/DistributedRateLimitServiceTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/DistributedRateLimitServiceTests.cs @@ -84,6 +84,21 @@ public async Task TryAcquireAsync_RelationalProvider_UsesExecutionStrategyTransa decision.CurrentCount.Should().Be(10); } + [Theory] + [InlineData("block")] + [InlineData("unblock")] + public async Task TryAcquireAsync_ModerationPolicy_BlocksAfterPermitLimit(string policyName) + { + DistributedRateLimitDecision finalDecision = new(true, 0, 0, DateTime.UtcNow); + + for (var attempt = 0; attempt < 51; attempt++) + finalDecision = await _service.TryAcquireAsync(policyName, "user:blocker"); + + finalDecision.Allowed.Should().BeFalse(); + finalDecision.PermitLimit.Should().Be(50); + finalDecision.CurrentCount.Should().Be(50); + } + private sealed class FixedTimeProvider(DateTimeOffset instant) : TimeProvider { public override DateTimeOffset GetUtcNow() => instant; diff --git a/tests/Orbit.Infrastructure.Tests/Services/FeatureFlagAndAgentSupportTests.cs b/tests/Orbit.Infrastructure.Tests/Services/FeatureFlagAndAgentSupportTests.cs index fccb4bd6..bb0c8ec8 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/FeatureFlagAndAgentSupportTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/FeatureFlagAndAgentSupportTests.cs @@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Routing; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using NSubstitute; using Orbit.Api.Extensions; @@ -51,7 +52,7 @@ public async Task FeatureFlagService_ReturnsEnabledFlagsForMatchingPlan() _dbContext.AppFeatureFlags.Add(AppFeatureFlag.Create("disabled", false, null, "Disabled")); await _dbContext.SaveChangesAsync(); - var service = new FeatureFlagService(_dbContext); + var service = new FeatureFlagService(_dbContext, new MemoryCache(new MemoryCacheOptions())); var result = await service.GetEnabledKeysForUserAsync(user.Id); @@ -61,7 +62,7 @@ public async Task FeatureFlagService_ReturnsEnabledFlagsForMatchingPlan() [Fact] public async Task FeatureFlagService_ReturnsEmptyWhenUserIsMissing() { - var service = new FeatureFlagService(_dbContext); + var service = new FeatureFlagService(_dbContext, new MemoryCache(new MemoryCacheOptions())); var result = await service.GetEnabledKeysForUserAsync(Guid.NewGuid()); diff --git a/tests/Orbit.Infrastructure.Tests/Services/PromptSectionTests.cs b/tests/Orbit.Infrastructure.Tests/Services/PromptSectionTests.cs index 2f7c468a..874aef83 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/PromptSectionTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/PromptSectionTests.cs @@ -31,6 +31,65 @@ public void Build_ContainsOrbitIdentity() result.Should().Contain("Orbit AI"); result.Should().Contain("Habit and Goal Tracking Assistant"); } + + [Fact] + public void Build_SoftenedGuidance_RoutesDestructiveAndAmbiguousThroughCards() + { + var ctx = new PromptContext(new List(), new List(), false, null, null, null, null); + var result = new CoreIdentitySection().Build(ctx); + + result.Should().Contain("Act directly"); + result.Should().Contain("confirmation card"); + result.Should().Contain("clarification card"); + result.Should().Contain("quick-action chips"); + } + + [Fact] + public void Build_NoLongerActsWithoutConfirmationOnDestructiveTools() + { + var ctx = new PromptContext(new List(), new List(), false, null, null, null, null); + var result = new CoreIdentitySection().Build(ctx); + + result.Should().NotContain("without asking for unnecessary confirmation"); + } +} + +public class EncouragingToneSectionTests +{ + [Fact] + public void Order_Is150() + { + new EncouragingToneSection().Order.Should().Be(150); + } + + [Fact] + public void ShouldInclude_AlwaysTrue() + { + var ctx = new PromptContext(new List(), new List(), false, null, null, null, null); + new EncouragingToneSection().ShouldInclude(ctx).Should().BeTrue(); + } + + [Fact] + public void Build_ContainsWarmEncouragingTone() + { + var ctx = new PromptContext(new List(), new List(), false, null, null, null, null); + var result = new EncouragingToneSection().Build(ctx); + + result.Should().Contain("warm"); + result.Should().Contain("streaks"); + result.Should().Contain("non-judgmental"); + result.Should().Contain("saccharine"); + } + + [Fact] + public void Build_ContainsNoEmOrEnDashes() + { + var ctx = new PromptContext(new List(), new List(), false, null, null, null, null); + var result = new EncouragingToneSection().Build(ctx); + + result.Should().NotContain("—"); + result.Should().NotContain("–"); + } } public class GlobalRulesSectionTests diff --git a/tests/Orbit.Infrastructure.Tests/Services/StreakFreezeAutoActivationServiceTests.cs b/tests/Orbit.Infrastructure.Tests/Services/StreakFreezeAutoActivationServiceTests.cs index 14ee4b3d..1e18d74b 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/StreakFreezeAutoActivationServiceTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/StreakFreezeAutoActivationServiceTests.cs @@ -335,6 +335,45 @@ public async Task ActivateMissedDayFreezes_PushFiresOnlyAfterFreezePersisted() user.StreakFreezesAccumulated.Should().Be(0); } + [Fact] + public async Task ActivateMissedDayFreezes_FreeUser_FlagOff_DoesNotActivate() + { + var user = CreateEligibleFreeUser(); + + await using var dbContext = CreateInMemoryDbContext(); + var pushService = Substitute.For(); + + dbContext.Users.Add(user); + await dbContext.SaveChangesAsync(); + + var service = CreateService(dbContext, pushService); + await service.ActivateMissedDayFreezes(CancellationToken.None); + + (await dbContext.StreakFreezes.AsNoTracking().CountAsync(f => f.UserId == user.Id)) + .Should().Be(0); + user.StreakFreezesAccumulated.Should().Be(1); + } + + [Fact] + public async Task ActivateMissedDayFreezes_FreeUser_FlagOn_ActivatesFreeze() + { + var user = CreateEligibleFreeUser(); + + await using var dbContext = CreateInMemoryDbContext(); + var pushService = Substitute.For(); + + dbContext.Users.Add(user); + dbContext.AppFeatureFlags.Add(AppFeatureFlag.Create(FeatureFlagKeys.GamificationFreeTier, enabled: true)); + await dbContext.SaveChangesAsync(); + + var service = CreateService(dbContext, pushService); + await service.ActivateMissedDayFreezes(CancellationToken.None); + + (await dbContext.StreakFreezes.AsNoTracking().CountAsync(f => f.UserId == user.Id)) + .Should().Be(1); + user.StreakFreezesAccumulated.Should().Be(0); + } + private static User CreateEligibleProUser() { var user = User.Create($"User-{Guid.NewGuid():N}", $"{Guid.NewGuid():N}@test.com").Value; @@ -344,6 +383,16 @@ private static User CreateEligibleProUser() return user; } + private static User CreateEligibleFreeUser() + { + var user = User.Create($"User-{Guid.NewGuid():N}", $"{Guid.NewGuid():N}@test.com").Value; + user.StartTrial(DateTime.UtcNow.AddDays(-1)); + var twoDaysAgo = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(-2); + user.SetStreakState(currentStreak: 10, longestStreak: 10, lastActiveDate: twoDaysAgo); + user.AwardStreakFreezeIfEligible(); + return user; + } + private static OrbitDbContext CreateInMemoryDbContext() => new(new DbContextOptionsBuilder() .UseInMemoryDatabase($"StreakFreezeAutoActivationServiceTests_{Guid.NewGuid()}") diff --git a/tests/Orbit.Infrastructure.Tests/Services/SystemPromptBuilderTests.cs b/tests/Orbit.Infrastructure.Tests/Services/SystemPromptBuilderTests.cs index bee4d023..47d96189 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/SystemPromptBuilderTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/SystemPromptBuilderTests.cs @@ -150,6 +150,31 @@ public void Build_IncludesStructuringStrategy() result.Should().Contain("sub_habits"); } + [Fact] + public void Build_IncludesEncouragingTone() + { + var result = BuildPrompt(Array.Empty(), Array.Empty()); + + result.Should().Contain("Tone and Encouragement"); + result.Should().Contain("non-judgmental"); + } + + [Fact] + public void BuildStatic_OrdersEncouragingToneAfterIdentityAndBeforeRules() + { + ISystemPromptBuilder builder = new SystemPromptBuilder(); + var staticPrompt = builder.BuildStatic(new PromptBuildRequest(Array.Empty(), Array.Empty())); + + staticPrompt.Should().Contain("Tone and Encouragement"); + + var identityIndex = staticPrompt.IndexOf("Orbit AI", StringComparison.Ordinal); + var toneIndex = staticPrompt.IndexOf("Tone and Encouragement", StringComparison.Ordinal); + var rulesIndex = staticPrompt.IndexOf("Core Rules", StringComparison.Ordinal); + + toneIndex.Should().BeGreaterThan(identityIndex); + rulesIndex.Should().BeGreaterThan(toneIndex); + } + [Fact] public void Build_IncludesSecurityRulesForUntrustedContext() { diff --git a/tests/Orbit.Infrastructure.Tests/Services/UserStreakServiceTests.cs b/tests/Orbit.Infrastructure.Tests/Services/UserStreakServiceTests.cs index 771fd7f0..bf4b56f0 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/UserStreakServiceTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/UserStreakServiceTests.cs @@ -1,5 +1,6 @@ using FluentAssertions; using NSubstitute; +using Orbit.Application.Social.Services; using Orbit.Domain.Entities; using Orbit.Domain.Enums; using Orbit.Domain.Interfaces; @@ -14,6 +15,7 @@ public class UserStreakServiceTests private readonly IGenericRepository _habitLogRepository = Substitute.For>(); private readonly IGenericRepository _streakFreezeRepository = Substitute.For>(); private readonly IUserDateService _userDateService = Substitute.For(); + private readonly IFriendFeedEventEmitter _feedEmitter = Substitute.For(); private readonly UserStreakService _sut; @@ -26,7 +28,8 @@ public UserStreakServiceTests() _habitRepository, _habitLogRepository, _streakFreezeRepository, - _userDateService); + _userDateService, + _feedEmitter); } private void SetupUser(User user, DateOnly today)