From 27bc5b01b8b04347ea54deb0cad99f52ad4abc75 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Sun, 23 Aug 2026 17:23:01 -0300 Subject: [PATCH 1/5] chore: start ORB-187 From 3dbc329864177486c7de3dcd417dfed54a30670d Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Sun, 23 Aug 2026 17:41:59 -0300 Subject: [PATCH 2/5] feat: make AI quota daily --- src/Orbit.Api/Mcp/Tools/SubscriptionTools.cs | 2 +- .../Content/FeatureExplanations/paygate.md | 4 +- src/Orbit.Application/Common/AppConfigKeys.cs | 4 +- src/Orbit.Application/Common/AppConstants.cs | 4 +- .../Common/PayGateService.cs | 41 +- .../Profile/Queries/GetProfileQuery.cs | 2 +- .../Queries/GetSubscriptionStatusQuery.cs | 2 +- src/Orbit.Domain/Entities/User.cs | 16 +- .../Interfaces/IPayGateService.cs | 2 +- ...203026_DailyAiQuotaByLocalDate.Designer.cs | 2644 +++++++++++++++++ .../20260823203026_DailyAiQuotaByLocalDate.cs | 91 + .../Migrations/OrbitDbContextModelSnapshot.cs | 6 +- .../Services/AiUsageSummaryService.cs | 50 +- .../Services/UserDateService.cs | 5 +- .../ProcessUserChatCommandHandlerTests.cs | 28 +- .../Common/PayGateServiceExpiredCycleTests.cs | 63 +- .../Common/PayGateServiceTests.cs | 78 +- .../Profile/GetProfileQueryHandlerTests.cs | 16 +- .../GetSubscriptionStatusQueryHandlerTests.cs | 8 +- .../Orbit.Domain.Tests/Entities/UserTests.cs | 66 +- .../Mcp/SubscriptionToolsTests.cs | 4 +- .../DailyAiQuotaByLocalDateMigrationTests.cs | 87 + .../AiUsageSummaryServiceGenerationTests.cs | 48 +- .../Services/AiUsageSummaryServiceTests.cs | 40 + .../Services/UserDateServiceTests.cs | 43 +- 25 files changed, 3211 insertions(+), 143 deletions(-) create mode 100644 src/Orbit.Infrastructure/Migrations/20260823203026_DailyAiQuotaByLocalDate.Designer.cs create mode 100644 src/Orbit.Infrastructure/Migrations/20260823203026_DailyAiQuotaByLocalDate.cs create mode 100644 tests/Orbit.Infrastructure.Tests/Persistence/DailyAiQuotaByLocalDateMigrationTests.cs diff --git a/src/Orbit.Api/Mcp/Tools/SubscriptionTools.cs b/src/Orbit.Api/Mcp/Tools/SubscriptionTools.cs index 0bc748ae..a6152bc8 100644 --- a/src/Orbit.Api/Mcp/Tools/SubscriptionTools.cs +++ b/src/Orbit.Api/Mcp/Tools/SubscriptionTools.cs @@ -43,7 +43,7 @@ public async Task GetSubscriptionStatus( return $"Plan: {(u.HasProAccess ? "Pro" : "Free")}\n" + (u.IsTrialActive ? $"Trial active, ends: {u.TrialEndsAt:yyyy-MM-dd}\n" : "") + (u.PlanExpiresAt is not null ? $"Plan expires: {u.PlanExpiresAt:yyyy-MM-dd}\n" : "") + - $"AI Messages: {u.AiMessagesUsedThisMonth}/{aiLimit}\n" + + $"Daily AI Messages: {u.AiMessagesUsedToday}/{aiLimit}\n" + (u.IsLifetimePro ? "Lifetime Pro: Yes\n" : "") + (u.SubscriptionInterval is not null ? $"Billing: {u.SubscriptionInterval.ToString()!.ToLowerInvariant()}" : ""); } diff --git a/src/Orbit.Application/Chat/Content/FeatureExplanations/paygate.md b/src/Orbit.Application/Chat/Content/FeatureExplanations/paygate.md index 5092d2a0..8d8d058e 100644 --- a/src/Orbit.Application/Chat/Content/FeatureExplanations/paygate.md +++ b/src/Orbit.Application/Chat/Content/FeatureExplanations/paygate.md @@ -18,9 +18,7 @@ Orbit has a free plan and a Pro plan. The free plan is fully usable for daily ha ## Limits on the free plan - **Habits** are capped at **10** top-level habits. Sub-habits, completed habits, and soft-deleted habits don't count toward the cap. Pro removes the cap. -- **AI messages** are capped at **20** per month. Pro raises this to **500** per month. - -Both plans can also earn a small bonus of extra AI messages from ad rewards, added on top of the plan limit. +- **AI messages** are capped at **5** per day. Pro raises this to **50** per day. ## What Pro unlocks diff --git a/src/Orbit.Application/Common/AppConfigKeys.cs b/src/Orbit.Application/Common/AppConfigKeys.cs index e9d5a0d4..599f15f7 100644 --- a/src/Orbit.Application/Common/AppConfigKeys.cs +++ b/src/Orbit.Application/Common/AppConfigKeys.cs @@ -9,8 +9,8 @@ public static class AppConfigKeys public const string MaxReferrals = "MaxReferrals"; public const string FreeMaxHabits = "FreeMaxHabits"; public const string SubHabitsProOnly = "SubHabitsProOnly"; - public const string FreeAiMessagesPerMonth = "FreeAiMessagesPerMonth"; - public const string ProAiMessagesPerMonth = "ProAiMessagesPerMonth"; + public const string FreeAiMessagesPerDay = "FreeAiMessagesPerDay"; + public const string ProAiMessagesPerDay = "ProAiMessagesPerDay"; public const string DailySummaryProOnly = "DailySummaryProOnly"; public const string SmartRescheduleProOnly = "SmartRescheduleProOnly"; public const string RetrospectiveProOnly = "RetrospectiveProOnly"; diff --git a/src/Orbit.Application/Common/AppConstants.cs b/src/Orbit.Application/Common/AppConstants.cs index cc7f80c4..45fe6ace 100644 --- a/src/Orbit.Application/Common/AppConstants.cs +++ b/src/Orbit.Application/Common/AppConstants.cs @@ -18,8 +18,8 @@ public static class AppConstants public const int MaxHabitLogsReturned = 1000; public const int DefaultReminderMinutes = 15; public const int DefaultFreeMaxHabits = 10; - public const int DefaultFreeAiMessages = 20; - public const int DefaultProAiMessages = 500; + public const int DefaultFreeAiMessages = 5; + public const int DefaultProAiMessages = 50; public const int MaxBulkOperationSize = 100; public const int MaxGoalsPerHabit = 10; public const int MaxHabitsPerGoal = 20; diff --git a/src/Orbit.Application/Common/PayGateService.cs b/src/Orbit.Application/Common/PayGateService.cs index 5cd499dc..6700af12 100644 --- a/src/Orbit.Application/Common/PayGateService.cs +++ b/src/Orbit.Application/Common/PayGateService.cs @@ -8,7 +8,8 @@ namespace Orbit.Application.Common; public class PayGateService( IGenericRepository habitRepository, IGenericRepository userRepository, - IAppConfigService appConfig) : IPayGateService + IAppConfigService appConfig, + IUserDateService userDateService) : IPayGateService { public async Task CanCreateHabits(Guid userId, int count = 1, CancellationToken ct = default) { @@ -51,16 +52,16 @@ public async Task CanSendAiMessage(Guid userId, CancellationToken ct = d if (IsProductionSmokeAccount(user.Email)) return Result.Success(); - var freeLimit = await appConfig.GetAsync(AppConfigKeys.FreeAiMessagesPerMonth, AppConstants.DefaultFreeAiMessages, ct); - var proLimit = await appConfig.GetAsync(AppConfigKeys.ProAiMessagesPerMonth, AppConstants.DefaultProAiMessages, ct); - var baseLimit = user.HasProAccess ? proLimit : freeLimit; - var messageLimit = baseLimit + user.AdRewardBonusMessages; + var freeLimit = await appConfig.GetAsync(AppConfigKeys.FreeAiMessagesPerDay, AppConstants.DefaultFreeAiMessages, ct); + var proLimit = await appConfig.GetAsync(AppConfigKeys.ProAiMessagesPerDay, AppConstants.DefaultProAiMessages, ct); + var messageLimit = user.HasProAccess ? proLimit : freeLimit; + var userToday = await userDateService.GetUserTodayAsync(userId, ct); - if (user.AiMessagesUsedThisMonth >= messageLimit) + if (user.AiMessagesLocalDate == userToday && user.AiMessagesUsedToday >= messageLimit) { var errorMessage = user.HasProAccess - ? $"You've reached your monthly AI message limit ({messageLimit})." - : $"You've reached your monthly AI message limit ({messageLimit}). Upgrade to Pro for {proLimit} messages per month."; + ? $"You've reached your daily AI message limit ({messageLimit})." + : $"You've reached your daily AI message limit ({messageLimit}). Upgrade to Pro for {proLimit} messages per day."; return Result.PayGateFailure(errorMessage); } @@ -73,8 +74,9 @@ public async Task TryConsumeAiMessage( IUnitOfWork unitOfWork, CancellationToken ct = default) { - var freeLimit = await appConfig.GetAsync(AppConfigKeys.FreeAiMessagesPerMonth, AppConstants.DefaultFreeAiMessages, ct); - var proLimit = await appConfig.GetAsync(AppConfigKeys.ProAiMessagesPerMonth, AppConstants.DefaultProAiMessages, ct); + var freeLimit = await appConfig.GetAsync(AppConfigKeys.FreeAiMessagesPerDay, AppConstants.DefaultFreeAiMessages, ct); + var proLimit = await appConfig.GetAsync(AppConfigKeys.ProAiMessagesPerDay, AppConstants.DefaultProAiMessages, ct); + var userToday = await userDateService.GetUserTodayAsync(userId, ct); var consumption = await ConcurrencyRetry.ExecuteAsync( userRepository, @@ -82,22 +84,20 @@ public async Task TryConsumeAiMessage( token => userRepository.FindOneTrackedAsync(user => user.Id == userId, cancellationToken: token), user => { - var currentAtUtc = DateTime.UtcNow; if (!IsProductionSmokeAccount(user.Email)) { - var messageLimit = (user.HasProAccess ? proLimit : freeLimit) + user.AdRewardBonusMessages; - var cycleIsActive = user.AiMessagesResetAt.HasValue && user.AiMessagesResetAt.Value > currentAtUtc; - if (cycleIsActive && user.AiMessagesUsedThisMonth >= messageLimit) + var messageLimit = user.HasProAccess ? proLimit : freeLimit; + if (user.AiMessagesLocalDate == userToday && user.AiMessagesUsedToday >= messageLimit) { var errorMessage = user.HasProAccess - ? $"You've reached your monthly AI message limit ({messageLimit})." - : $"You've reached your monthly AI message limit ({messageLimit}). Upgrade to Pro for {proLimit} messages per month."; + ? $"You've reached your daily AI message limit ({messageLimit})." + : $"You've reached your daily AI message limit ({messageLimit}). Upgrade to Pro for {proLimit} messages per day."; return Task.FromResult(Result.PayGateFailure(errorMessage)); } } - user.IncrementAiMessageCount(currentAtUtc); + user.IncrementAiMessageCount(userToday); return Task.FromResult(Result.Success()); }, ErrorMessages.UserNotFound, @@ -210,10 +210,9 @@ public async Task GetAiMessageLimit(Guid userId, CancellationToken ct = def var user = await userRepository.GetByIdAsync(userId, ct); if (user is null) return AppConstants.DefaultFreeAiMessages; - var freeLimit = await appConfig.GetAsync(AppConfigKeys.FreeAiMessagesPerMonth, AppConstants.DefaultFreeAiMessages, ct); - var proLimit = await appConfig.GetAsync(AppConfigKeys.ProAiMessagesPerMonth, AppConstants.DefaultProAiMessages, ct); - var baseLimit = user.HasProAccess ? proLimit : freeLimit; - return baseLimit + user.AdRewardBonusMessages; + var freeLimit = await appConfig.GetAsync(AppConfigKeys.FreeAiMessagesPerDay, AppConstants.DefaultFreeAiMessages, ct); + var proLimit = await appConfig.GetAsync(AppConfigKeys.ProAiMessagesPerDay, AppConstants.DefaultProAiMessages, ct); + return user.HasProAccess ? proLimit : freeLimit; } private static bool IsProductionSmokeAccount(string email) diff --git a/src/Orbit.Application/Profile/Queries/GetProfileQuery.cs b/src/Orbit.Application/Profile/Queries/GetProfileQuery.cs index 1dea7e5c..2100177e 100644 --- a/src/Orbit.Application/Profile/Queries/GetProfileQuery.cs +++ b/src/Orbit.Application/Profile/Queries/GetProfileQuery.cs @@ -118,7 +118,7 @@ public async Task> Handle(GetProfileQuery request, Cance user.IsTrialActive, user.TrialEndsAt, user.PlanExpiresAt, - user.AiMessagesUsedThisMonth, + user.AiMessagesUsedToday, aiMessageLimit, user.HasImportedCalendar, user.HasSeenImportPrompt, diff --git a/src/Orbit.Application/Subscriptions/Queries/GetSubscriptionStatusQuery.cs b/src/Orbit.Application/Subscriptions/Queries/GetSubscriptionStatusQuery.cs index b37b749f..621125d1 100644 --- a/src/Orbit.Application/Subscriptions/Queries/GetSubscriptionStatusQuery.cs +++ b/src/Orbit.Application/Subscriptions/Queries/GetSubscriptionStatusQuery.cs @@ -24,7 +24,7 @@ public async Task> Handle(GetSubscriptionStat user.IsTrialActive, user.TrialEndsAt, user.PlanExpiresAt, - user.AiMessagesUsedThisMonth, + user.AiMessagesUsedToday, await payGate.GetAiMessageLimit(user.Id, cancellationToken), user.IsLifetimePro, user.SubscriptionInterval?.ToString().ToLowerInvariant(), diff --git a/src/Orbit.Domain/Entities/User.cs b/src/Orbit.Domain/Entities/User.cs index a0d2ba5c..379ab546 100644 --- a/src/Orbit.Domain/Entities/User.cs +++ b/src/Orbit.Domain/Entities/User.cs @@ -32,8 +32,8 @@ public partial class User : Entity public DateTime? PlanExpiresAt { get; private set; } public DateTime? TrialEndsAt { get; private set; } public bool IsLifetimePro { get; private set; } = false; - public int AiMessagesUsedThisMonth { get; private set; } = 0; - public DateTime? AiMessagesResetAt { get; private set; } + public int AiMessagesUsedToday { get; private set; } = 0; + public DateOnly? AiMessagesLocalDate { get; private set; } public SubscriptionInterval? SubscriptionInterval { get; private set; } public SubscriptionSource? SubscriptionSource { get; private set; } public SubscriptionLapseReason? SubscriptionLapseReason { get; private set; } @@ -342,17 +342,15 @@ private bool TryAcceptStripeEvent(DateTime? eventCreatedAtUtc, bool acceptEqualT public void StartTrial(DateTime endsAt) => TrialEndsAt = endsAt; - public void IncrementAiMessageCount() => IncrementAiMessageCount(DateTime.UtcNow); - - public void IncrementAiMessageCount(DateTime utcNow) + public void IncrementAiMessageCount(DateOnly userToday) { - if (!AiMessagesResetAt.HasValue || AiMessagesResetAt.Value <= utcNow) + if (!AiMessagesLocalDate.HasValue || AiMessagesLocalDate.Value < userToday) { - AiMessagesUsedThisMonth = 0; + AiMessagesUsedToday = 0; AdRewardBonusMessages = 0; - AiMessagesResetAt = utcNow.AddDays(30); + AiMessagesLocalDate = userToday; } - AiMessagesUsedThisMonth++; + AiMessagesUsedToday++; } public Result GrantAdReward(DateOnly userToday, int bonusMessages = 5, int dailyCap = 3) diff --git a/src/Orbit.Domain/Interfaces/IPayGateService.cs b/src/Orbit.Domain/Interfaces/IPayGateService.cs index c16405b2..5d9bac40 100644 --- a/src/Orbit.Domain/Interfaces/IPayGateService.cs +++ b/src/Orbit.Domain/Interfaces/IPayGateService.cs @@ -15,7 +15,7 @@ public interface IPayGateService Task CanCreateSubHabits(Guid userId, CancellationToken ct = default); /// - /// Checks if the user can send AI messages (free: 20/month, Pro: 500/month). + /// Checks if the user can send AI messages (free: 5/day, Pro: 50/day). /// Task CanSendAiMessage(Guid userId, CancellationToken ct = default); diff --git a/src/Orbit.Infrastructure/Migrations/20260823203026_DailyAiQuotaByLocalDate.Designer.cs b/src/Orbit.Infrastructure/Migrations/20260823203026_DailyAiQuotaByLocalDate.Designer.cs new file mode 100644 index 00000000..74017768 --- /dev/null +++ b/src/Orbit.Infrastructure/Migrations/20260823203026_DailyAiQuotaByLocalDate.Designer.cs @@ -0,0 +1,2644 @@ +// +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("20260823203026_DailyAiQuotaByLocalDate")] + partial class DailyAiQuotaByLocalDate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .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.AccountabilityCheckIn", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("Note") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PairId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PairId", "CreatedAtUtc"); + + b.HasIndex("PairId", "UserId", "Date") + .IsUnique(); + + b.ToTable("AccountabilityCheckIns"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AccountabilityPair", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AddresseeId") + .HasColumnType("uuid"); + + b.Property("Cadence") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EndedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("AddresseeId"); + + b.HasIndex("RequesterId"); + + b.ToTable("AccountabilityPairs"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AccountabilityPairHabit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("PairId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("HabitId"); + + b.HasIndex("PairId", "UserId", "HabitId") + .IsUnique(); + + b.ToTable("AccountabilityPairHabits"); + }); + + 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.AiUsageDaily", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CachedTokens") + .HasColumnType("bigint"); + + b.Property("Calls") + .HasColumnType("bigint"); + + b.Property("CompletionTokens") + .HasColumnType("bigint"); + + b.Property("CostUsd") + .HasColumnType("numeric"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PromptTokens") + .HasColumnType("bigint"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TotalTokens") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Date", "Model", "Purpose", "UserId") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("Date", "Model", "Purpose", "UserId"), false); + + b.ToTable("AiUsageDaily"); + }); + + 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" + }, + new + { + Key = "RequireApiKeyCreationStepUp", + Description = "Turn on once a client build carrying the API key creation challenge flow is live in the Play fleet", + Value = "false" + }); + }); + + 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 = "Pro", + 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.Challenge", 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("CreatorId") + .HasColumnType("uuid"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("JoinCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("PeriodEndUtc") + .HasColumnType("date"); + + b.Property("PeriodStartUtc") + .HasColumnType("date"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TargetCount") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatorId"); + + b.HasIndex("JoinCode") + .IsUnique(); + + b.ToTable("Challenges"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ChallengeParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChallengeId") + .HasColumnType("uuid"); + + b.Property("JoinedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LeftAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("ChallengeId", "UserId") + .IsUnique() + .HasFilter("\"LeftAtUtc\" IS NULL"); + + b.ToTable("ChallengeParticipants"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ChallengeParticipantHabit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChallengeParticipantId") + .HasColumnType("uuid"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("HabitId"); + + b.HasIndex("ChallengeParticipantId", "HabitId") + .IsUnique(); + + b.ToTable("ChallengeParticipantHabits"); + }); + + 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.HasIndex("UserId", "UpdatedAtUtc"); + + 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.ClosedMonthRecap", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DateFrom") + .HasColumnType("date"); + + b.Property("DateTo") + .HasColumnType("date"); + + b.Property("ResponseJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "DateFrom", "DateTo") + .IsUnique(); + + b.ToTable("ClosedMonthRecaps"); + }); + + 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("FirstCompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + 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() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + 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.HasIndex("UserId", "UpdatedAtUtc"); + + 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.HasIndex("GoalId", "UpdatedAtUtc"); + + 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(1024) + .HasColumnType("character varying(1024)"); + + 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(1024) + .HasColumnType("character varying(1024)"); + + 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("ScheduledStartDate") + .HasColumnType("date"); + + 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.HasIndex("UserId", "UpdatedAtUtc"); + + 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("HabitId", "UpdatedAtUtc"); + + 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("DedupeKey") + .HasColumnType("text"); + + 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("DedupeKey") + .IsUnique() + .HasFilter("\"DedupeKey\" IS NOT NULL"); + + b.HasIndex("Url") + .HasFilter("\"Url\" IS NOT NULL"); + + b.HasIndex("UserId", "CreatedAtUtc") + .IsDescending(false, true); + + b.HasIndex("UserId", "IsDeleted"); + + b.HasIndex("UserId", "IsRead"); + + b.HasIndex("UserId", "UpdatedAtUtc"); + + 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.ProcessedRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RequestType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ResponseBody") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAtUtc"); + + b.HasIndex("UserId", "IdempotencyKey", "RequestType") + .IsUnique(); + + b.ToTable("ProcessedRequests"); + }); + + 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.SentProactiveCheckin", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("SentAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Date") + .IsUnique(); + + b.ToTable("SentProactiveCheckins"); + }); + + 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.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() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + 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() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + 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() + .HasFilter("\"IsDeleted\" = FALSE"); + + b.HasIndex("UserId", "UpdatedAtUtc"); + + 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("AiMessagesLocalDate") + .HasColumnType("date"); + + b.Property("AiMessagesUsedToday") + .HasColumnType("integer"); + + b.Property("AiSummaryEnabled") + .HasColumnType("boolean"); + + b.Property("ColorScheme") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + 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("HasSeenImportPrompt") + .HasColumnType("boolean"); + + b.Property("HasTriedAstra") + .HasColumnType("boolean"); + + b.Property("IsAdmin") + .HasColumnType("boolean"); + + b.Property("IsDeactivated") + .HasColumnType("boolean"); + + b.Property("IsLifetimePro") + .HasColumnType("boolean"); + + b.Property("Language") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + 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("MarketingConsentUpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MarketingEmailConsent") + .HasColumnType("boolean"); + + 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("ProactiveAstraEnabled") + .HasColumnType("boolean"); + + b.Property("PublicProfileShowAchievements") + .HasColumnType("boolean"); + + b.Property("PublicProfileShowLevel") + .HasColumnType("boolean"); + + b.Property("PublicProfileShowStreak") + .HasColumnType("boolean"); + + b.Property("PublicProfileShowTopHabits") + .HasColumnType("boolean"); + + b.Property("PublicProfileSlug") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + 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("StripeSubscriptionEventCreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("StripeSubscriptionId") + .HasColumnType("text"); + + b.Property("SubscriptionEndedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SubscriptionInterval") + .HasColumnType("integer"); + + b.Property("SubscriptionLapseReason") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SubscriptionSource") + .HasColumnType("integer"); + + b.Property("ThemePreference") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("TimeZone") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + 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("PublicProfileSlug") + .IsUnique() + .HasFilter("\"PublicProfileSlug\" 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.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("UserSessions"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.XpAwardLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("integer"); + + b.Property("AwardedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SourceId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "AwardedAtUtc"); + + b.ToTable("XpAwardLogs"); + }); + + 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.AccountabilityCheckIn", b => + { + b.HasOne("Orbit.Domain.Entities.AccountabilityPair", null) + .WithMany() + .HasForeignKey("PairId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AccountabilityPair", 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.AccountabilityPairHabit", b => + { + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany() + .HasForeignKey("HabitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.AccountabilityPair", null) + .WithMany() + .HasForeignKey("PairId") + .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.Challenge", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("CreatorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ChallengeParticipant", b => + { + b.HasOne("Orbit.Domain.Entities.Challenge", null) + .WithMany("Participants") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ChallengeParticipantHabit", b => + { + b.HasOne("Orbit.Domain.Entities.ChallengeParticipant", null) + .WithMany("LinkedHabits") + .HasForeignKey("ChallengeParticipantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany() + .HasForeignKey("HabitId") + .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.Cheer", b => + { + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany() + .HasForeignKey("HabitId") + .OnDelete(DeleteBehavior.SetNull); + + 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.ClosedMonthRecap", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .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); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + 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.ProcessedRequest", 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.SentProactiveCheckin", 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.Tag", 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.XpAwardLog", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Challenge", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ChallengeParticipant", b => + { + b.Navigation("LinkedHabits"); + }); + + 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/20260823203026_DailyAiQuotaByLocalDate.cs b/src/Orbit.Infrastructure/Migrations/20260823203026_DailyAiQuotaByLocalDate.cs new file mode 100644 index 00000000..335f5d0b --- /dev/null +++ b/src/Orbit.Infrastructure/Migrations/20260823203026_DailyAiQuotaByLocalDate.cs @@ -0,0 +1,91 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Orbit.Infrastructure.Migrations +{ + /// + public partial class DailyAiQuotaByLocalDate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AiMessagesResetAt", + table: "Users"); + + migrationBuilder.RenameColumn( + name: "AiMessagesUsedThisMonth", + table: "Users", + newName: "AiMessagesUsedToday"); + + migrationBuilder.AddColumn( + name: "AiMessagesLocalDate", + table: "Users", + type: "date", + nullable: true); + + migrationBuilder.Sql("UPDATE \"Users\" SET \"AiMessagesUsedToday\" = 0;"); + + migrationBuilder.DeleteData( + table: "AppConfigs", + keyColumn: "Key", + keyValue: "FreeAiMessagesPerMonth"); + + migrationBuilder.DeleteData( + table: "AppConfigs", + keyColumn: "Key", + keyValue: "ProAiMessagesPerMonth"); + + migrationBuilder.InsertData( + table: "AppConfigs", + columns: new[] { "Key", "Description", "Value" }, + values: new object[] { "FreeAiMessagesPerDay", "Daily AI message limit for free plan users", "5" }); + + migrationBuilder.InsertData( + table: "AppConfigs", + columns: new[] { "Key", "Description", "Value" }, + values: new object[] { "ProAiMessagesPerDay", "Daily AI message limit for Pro plan users", "50" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AiMessagesLocalDate", + table: "Users"); + + migrationBuilder.RenameColumn( + name: "AiMessagesUsedToday", + table: "Users", + newName: "AiMessagesUsedThisMonth"); + + migrationBuilder.AddColumn( + name: "AiMessagesResetAt", + table: "Users", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.DeleteData( + table: "AppConfigs", + keyColumn: "Key", + keyValue: "FreeAiMessagesPerDay"); + + migrationBuilder.DeleteData( + table: "AppConfigs", + keyColumn: "Key", + keyValue: "ProAiMessagesPerDay"); + + migrationBuilder.InsertData( + table: "AppConfigs", + columns: new[] { "Key", "Description", "Value" }, + values: new object[] { "FreeAiMessagesPerMonth", "Monthly AI message limit for free plan users", "20" }); + + migrationBuilder.InsertData( + table: "AppConfigs", + columns: new[] { "Key", "Description", "Value" }, + values: new object[] { "ProAiMessagesPerMonth", "Monthly AI message limit for Pro plan users", "500" }); + } + } +} diff --git a/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs b/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs index 5d8ab169..8844ed06 100644 --- a/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs +++ b/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs @@ -1906,10 +1906,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("AiMemoryEnabled") .HasColumnType("boolean"); - b.Property("AiMessagesResetAt") - .HasColumnType("timestamp with time zone"); + b.Property("AiMessagesLocalDate") + .HasColumnType("date"); - b.Property("AiMessagesUsedThisMonth") + b.Property("AiMessagesUsedToday") .HasColumnType("integer"); b.Property("AiSummaryEnabled") diff --git a/src/Orbit.Infrastructure/Services/AiUsageSummaryService.cs b/src/Orbit.Infrastructure/Services/AiUsageSummaryService.cs index 359656d1..0c08d114 100644 --- a/src/Orbit.Infrastructure/Services/AiUsageSummaryService.cs +++ b/src/Orbit.Infrastructure/Services/AiUsageSummaryService.cs @@ -1,9 +1,12 @@ +using System.Globalization; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Orbit.Application.Common; using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; using Orbit.Infrastructure.BackgroundJobs; using Orbit.Infrastructure.Configuration; using Orbit.Infrastructure.Persistence; @@ -12,9 +15,9 @@ namespace Orbit.Infrastructure.Services; /// -/// Emits exactly one Information line per day summarising the previous UTC day's AI dollar cost, call -/// count, and top purposes from the aggregate. An in-memory marker keeps it -/// idempotent across the hourly tick; a process restart re-emits the prior day's line once. +/// Emits exactly one Information line per day summarising the previous UTC day's AI dollar cost and +/// local-date Astra quota metrics. An in-memory marker keeps it idempotent across the hourly tick; a +/// process restart re-emits the prior day's line once. /// public partial class AiUsageSummaryService( IServiceScopeFactory scopeFactory, @@ -67,9 +70,25 @@ internal async Task SummarizeYesterdayAsync(CancellationToken cancellationToken) .Where(usage => usage.Date == yesterday) .ToListAsync(cancellationToken); + var activeUsers = await dbContext.Users + .AsNoTracking() + .Where(user => user.AiMessagesLocalDate == yesterday && user.AiMessagesUsedToday > 0) + .ToListAsync(cancellationToken); + + var appConfig = scope.ServiceProvider.GetRequiredService(); + var freeLimit = await appConfig.GetAsync( + AppConfigKeys.FreeAiMessagesPerDay, + AppConstants.DefaultFreeAiMessages, + cancellationToken); + var proLimit = await appConfig.GetAsync( + AppConfigKeys.ProAiMessagesPerDay, + AppConstants.DefaultProAiMessages, + cancellationToken); + if (logger.IsEnabled(LogLevel.Information)) { - var summaryLine = BuildSummaryLine(yesterday, rows, _pricing); + var summaryLine = $"{BuildSummaryLine(yesterday, rows, _pricing)}; " + + BuildQuotaSummaryLine(yesterday, activeUsers, freeLimit, proLimit); LogAiUsageSummary(logger, summaryLine); } _lastSummarizedDate = yesterday; @@ -112,6 +131,29 @@ internal static string BuildSummaryLine( return line; } + internal static string BuildQuotaSummaryLine( + DateOnly date, + IReadOnlyList activeUsers, + int freeLimit, + int proLimit) + { + if (activeUsers.Count == 0) + return $"Astra quota {date:yyyy-MM-dd}: no active users"; + + var messageCounts = activeUsers + .Select(user => user.AiMessagesUsedToday) + .Order() + .ToList(); + var mean = messageCounts.Average(); + var p95Index = (int)Math.Ceiling(messageCounts.Count * 0.95) - 1; + var freeCapHits = activeUsers.Count(user => !user.HasProAccess && user.AiMessagesUsedToday >= freeLimit); + var proCapHits = activeUsers.Count(user => user.HasProAccess && user.AiMessagesUsedToday >= proLimit); + + return $"Astra quota {date:yyyy-MM-dd}: free_cap_hits={freeCapHits}; pro_cap_hits={proCapHits}; " + + $"mean_messages_per_active_user={mean.ToString("F2", CultureInfo.InvariantCulture)}; " + + $"p95_messages_per_active_user={messageCounts[p95Index]}"; + } + [LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "AiUsageSummaryService started")] private static partial void LogServiceStarted(ILogger logger); diff --git a/src/Orbit.Infrastructure/Services/UserDateService.cs b/src/Orbit.Infrastructure/Services/UserDateService.cs index 8bda3fe7..d2e660e8 100644 --- a/src/Orbit.Infrastructure/Services/UserDateService.cs +++ b/src/Orbit.Infrastructure/Services/UserDateService.cs @@ -8,7 +8,8 @@ namespace Orbit.Infrastructure.Services; public class UserDateService( IGenericRepository userRepository, - IDistributedCache cache) : IUserDateService + IDistributedCache cache, + TimeProvider timeProvider) : IUserDateService { private static readonly DistributedCacheEntryOptions CacheEntryOptions = new() { @@ -32,7 +33,7 @@ public Task GetUserTodayAsync( { cancellationToken.ThrowIfCancellationRequested(); var timeZone = TimeZoneHelper.FindTimeZone(timeZoneId, userId: userId); - var today = DateOnly.FromDateTime(TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, timeZone)); + var today = DateOnly.FromDateTime(TimeZoneInfo.ConvertTimeFromUtc(timeProvider.GetUtcNow().UtcDateTime, timeZone)); return Task.FromResult(today); } diff --git a/tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.cs index cf840c2b..82d16b34 100644 --- a/tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Chat/ProcessUserChatCommandHandlerTests.cs @@ -253,12 +253,12 @@ private PayGateService CreateRealPayGate(User user) private PayGateService CreateRealPayGate(IGenericRepository userRepository) { var appConfig = Substitute.For(); - appConfig.GetAsync(AppConfigKeys.FreeAiMessagesPerMonth, AppConstants.DefaultFreeAiMessages, Arg.Any()) - .Returns(20); - appConfig.GetAsync(AppConfigKeys.ProAiMessagesPerMonth, AppConstants.DefaultProAiMessages, Arg.Any()) - .Returns(500); + appConfig.GetAsync(AppConfigKeys.FreeAiMessagesPerDay, AppConstants.DefaultFreeAiMessages, Arg.Any()) + .Returns(5); + appConfig.GetAsync(AppConfigKeys.ProAiMessagesPerDay, AppConstants.DefaultProAiMessages, Arg.Any()) + .Returns(50); - return new PayGateService(_habitRepo, userRepository, appConfig); + return new PayGateService(_habitRepo, userRepository, appConfig, _userDateService); } private static readonly AiConversationContext TestConversationContext = new() @@ -378,7 +378,7 @@ await _aiIntentService.Received(1).SendWithToolsAsync( public async Task Handle_TenConcurrentRequestsWithOneMessageRemaining_OnlyOneCallsAi() { const int requestCount = 10; - var persistence = new StaleSnapshotQuotaStore(initialCount: 19, requestCount); + var persistence = new StaleSnapshotQuotaStore(initialCount: 4, requestCount); SetupAiResponse(new AiResponse { TextMessage = "Reserved response", ToolCalls = null }); var command = new ProcessUserChatCommand(UserId, "Hello AI"); var handlers = Enumerable.Range(0, requestCount) @@ -397,7 +397,7 @@ public async Task Handle_TenConcurrentRequestsWithOneMessageRemaining_OnlyOneCal results.Should().ContainSingle(result => result.IsSuccess); results.Count(result => result.IsFailure && result.ErrorCode == Result.PayGateErrorCode).Should().Be(requestCount - 1); - persistence.PersistedCount.Should().Be(20); + persistence.PersistedCount.Should().Be(5); persistence.InitialSnapshotCount.Should().Be(requestCount); persistence.DistinctInitialSnapshotCount.Should().Be(requestCount); persistence.ConcurrencyFailureCount.Should().Be(requestCount - 1); @@ -428,8 +428,8 @@ public async Task Handle_AiServiceFails_RetainsConsumedQuota() { var user = User.Create("Thomas", "thomas@test.com").Value; user.StartTrial(DateTime.UtcNow.AddDays(-1)); - for (var i = 0; i < 19; i++) - user.IncrementAiMessageCount(); + for (var i = 0; i < 4; i++) + user.IncrementAiMessageCount(Today); _aiIntentService.SendWithToolsAsync( Arg.Any(), @@ -437,7 +437,7 @@ public async Task Handle_AiServiceFails_RetainsConsumedQuota() Arg.Any()) .Returns(_ => { - user.AiMessagesUsedThisMonth.Should().Be(20); + user.AiMessagesUsedToday.Should().Be(5); return Result.Failure("AI service unavailable"); }); var handler = CreateHandler(CreateRealPayGate(user)); @@ -448,7 +448,7 @@ public async Task Handle_AiServiceFails_RetainsConsumedQuota() result.IsFailure.Should().BeTrue(); result.Error.Should().Be("AI service unavailable"); - user.AiMessagesUsedThisMonth.Should().Be(20); + user.AiMessagesUsedToday.Should().Be(5); } [Fact] @@ -2263,10 +2263,10 @@ public int DistinctInitialSnapshotCount throw new DbUpdateConcurrencyException("The quota snapshot is stale."); } - if (trackedUser.AiMessagesUsedThisMonth == _persistedCount) + if (trackedUser.AiMessagesUsedToday == _persistedCount) return 0; - _persistedCount = trackedUser.AiMessagesUsedThisMonth; + _persistedCount = trackedUser.AiMessagesUsedToday; _version++; trackedVersion = _version; return 1; @@ -2281,7 +2281,7 @@ private static User CreateFreeUserSnapshot(int messageCount) var user = User.Create("Thomas", "thomas@test.com").Value; user.StartTrial(DateTime.UtcNow.AddDays(-1)); for (var i = 0; i < messageCount; i++) - user.IncrementAiMessageCount(); + user.IncrementAiMessageCount(Today); return user; } } diff --git a/tests/Orbit.Application.Tests/Common/PayGateServiceExpiredCycleTests.cs b/tests/Orbit.Application.Tests/Common/PayGateServiceExpiredCycleTests.cs index cdb2ed0c..e652ad2f 100644 --- a/tests/Orbit.Application.Tests/Common/PayGateServiceExpiredCycleTests.cs +++ b/tests/Orbit.Application.Tests/Common/PayGateServiceExpiredCycleTests.cs @@ -11,14 +11,14 @@ namespace Orbit.Application.Tests.Common; public class PayGateServiceExpiredCycleTests { [Fact] - public async Task TryConsumeAiMessage_AtExpiredCycleLimit_ResetsCycleAndConsumesFirstMessage() + public async Task TryConsumeAiMessage_FifthAllowedSixthRefusedThenNextLocalDateAllowed() { var userId = Guid.NewGuid(); + var today = new DateOnly(2026, 8, 5); var user = User.Create("Test User", "test@example.com").Value; user.StartTrial(DateTime.UtcNow.AddDays(-1)); - var previousCycleNow = DateTime.UtcNow.AddDays(-31); - for (var i = 0; i < 20; i++) - user.IncrementAiMessageCount(previousCycleNow); + for (var i = 0; i < 4; i++) + user.IncrementAiMessageCount(today); var userRepository = Substitute.For>(); userRepository.FindOneTrackedAsync( @@ -27,19 +27,64 @@ public async Task TryConsumeAiMessage_AtExpiredCycleLimit_ResetsCycleAndConsumes Arg.Any()) .Returns(user); var appConfig = Substitute.For(); - appConfig.GetAsync("FreeAiMessagesPerMonth", 20, Arg.Any()).Returns(20); - appConfig.GetAsync("ProAiMessagesPerMonth", 500, Arg.Any()).Returns(500); + appConfig.GetAsync("FreeAiMessagesPerDay", 5, Arg.Any()).Returns(5); + appConfig.GetAsync("ProAiMessagesPerDay", 50, Arg.Any()).Returns(50); + var userDateService = Substitute.For(); + userDateService.GetUserTodayAsync(userId, Arg.Any()) + .Returns(today, today, today.AddDays(1)); var unitOfWork = Substitute.For(); var sut = new PayGateService( Substitute.For>(), userRepository, - appConfig); + appConfig, + userDateService); + + var fifth = await sut.TryConsumeAiMessage(userId, unitOfWork); + var sixth = await sut.TryConsumeAiMessage(userId, unitOfWork); + var nextDay = await sut.TryConsumeAiMessage(userId, unitOfWork); + + fifth.IsSuccess.Should().BeTrue(); + sixth.IsFailure.Should().BeTrue(); + sixth.ErrorCode.Should().Be("PAY_GATE"); + nextDay.IsSuccess.Should().BeTrue(); + user.AiMessagesUsedToday.Should().Be(1); + user.AiMessagesLocalDate.Should().Be(today.AddDays(1)); + await unitOfWork.Received(2).SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task TryConsumeAiMessage_OnNextLocalDate_ResetsCounterAndConsumesFirstMessage() + { + var userId = Guid.NewGuid(); + var user = User.Create("Test User", "test@example.com").Value; + user.StartTrial(DateTime.UtcNow.AddDays(-1)); + var today = new DateOnly(2026, 8, 5); + for (var i = 0; i < 5; i++) + user.IncrementAiMessageCount(today.AddDays(-1)); + + var userRepository = Substitute.For>(); + userRepository.FindOneTrackedAsync( + Arg.Any>>(), + Arg.Any, IQueryable>?>(), + Arg.Any()) + .Returns(user); + var appConfig = Substitute.For(); + appConfig.GetAsync("FreeAiMessagesPerDay", 5, Arg.Any()).Returns(5); + appConfig.GetAsync("ProAiMessagesPerDay", 50, Arg.Any()).Returns(50); + var userDateService = Substitute.For(); + userDateService.GetUserTodayAsync(userId, Arg.Any()).Returns(today); + var unitOfWork = Substitute.For(); + var sut = new PayGateService( + Substitute.For>(), + userRepository, + appConfig, + userDateService); var result = await sut.TryConsumeAiMessage(userId, unitOfWork); result.IsSuccess.Should().BeTrue(); - user.AiMessagesUsedThisMonth.Should().Be(1); - user.AiMessagesResetAt.Should().BeAfter(DateTime.UtcNow); + user.AiMessagesUsedToday.Should().Be(1); + user.AiMessagesLocalDate.Should().Be(today); await unitOfWork.Received(1).SaveChangesAsync(Arg.Any()); } } diff --git a/tests/Orbit.Application.Tests/Common/PayGateServiceTests.cs b/tests/Orbit.Application.Tests/Common/PayGateServiceTests.cs index 3d78cd86..e6e5177a 100644 --- a/tests/Orbit.Application.Tests/Common/PayGateServiceTests.cs +++ b/tests/Orbit.Application.Tests/Common/PayGateServiceTests.cs @@ -14,19 +14,22 @@ public class PayGateServiceTests private readonly IGenericRepository _habitRepo = Substitute.For>(); private readonly IGenericRepository _userRepo = Substitute.For>(); private readonly IAppConfigService _appConfig = Substitute.For(); + private readonly IUserDateService _userDateService = Substitute.For(); private readonly PayGateService _sut; private static readonly Guid UserId = Guid.NewGuid(); + private static readonly DateOnly Today = new(2026, 8, 5); private static readonly DateOnly ReactivationToday = new(2026, 8, 5); public PayGateServiceTests() { - _sut = new PayGateService(_habitRepo, _userRepo, _appConfig); + _sut = new PayGateService(_habitRepo, _userRepo, _appConfig, _userDateService); _appConfig.GetAsync("FreeMaxHabits", 10, Arg.Any()).Returns(10); _appConfig.GetAsync("SubHabitsProOnly", true, Arg.Any()).Returns(true); - _appConfig.GetAsync("FreeAiMessagesPerMonth", 20, Arg.Any()).Returns(20); - _appConfig.GetAsync("ProAiMessagesPerMonth", 500, Arg.Any()).Returns(500); + _appConfig.GetAsync("FreeAiMessagesPerDay", 5, Arg.Any()).Returns(5); + _appConfig.GetAsync("ProAiMessagesPerDay", 50, Arg.Any()).Returns(50); + _userDateService.GetUserTodayAsync(UserId, Arg.Any()).Returns(Today); _appConfig.GetAsync("DailySummaryProOnly", true, Arg.Any()).Returns(true); _appConfig.GetAsync("RetrospectiveProOnly", true, Arg.Any()).Returns(true); _appConfig.GetAsync("GoalsProOnly", true, Arg.Any()).Returns(true); @@ -260,6 +263,21 @@ public async Task CanCreateSubHabits_FreeUser_PayGateFailure() result.ErrorCode.Should().Be("PAY_GATE"); } + [Fact] + public async Task CanSendAiMessage_ProUserAtDailyLimit_PayGateFailure() + { + var user = CreateProUser(); + for (var i = 0; i < 50; i++) + user.IncrementAiMessageCount(Today); + _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); + + var result = await _sut.CanSendAiMessage(UserId); + + result.IsFailure.Should().BeTrue(); + result.ErrorCode.Should().Be("PAY_GATE"); + result.Error.Should().Be("You've reached your daily AI message limit (50)."); + } + [Fact] public async Task CanCreateSubHabits_ConfigDisabled_FreeUserAllowed() { @@ -290,14 +308,16 @@ public async Task CanSendAiMessage_AtLimit_PayGateFailure() { var user = CreateFreeUser(); user.StartTrial(DateTime.UtcNow.AddDays(-1)); - for (int i = 0; i < 20; i++) - user.IncrementAiMessageCount(); + for (int i = 0; i < 5; i++) + user.IncrementAiMessageCount(Today); _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); var result = await _sut.CanSendAiMessage(UserId); result.IsFailure.Should().BeTrue(); result.ErrorCode.Should().Be("PAY_GATE"); + result.Error.Should().Be( + "You've reached your daily AI message limit (5). Upgrade to Pro for 50 messages per day."); } [Fact] @@ -305,8 +325,8 @@ public async Task CanSendAiMessage_ProductionSmokeAccount_OverLimit_Bypasses() { var user = CreateFreeUser(); user.StartTrial(DateTime.UtcNow.AddDays(-1)); - for (int i = 0; i < 20; i++) - user.IncrementAiMessageCount(); + for (int i = 0; i < 5; i++) + user.IncrementAiMessageCount(Today); _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); await WithEnvironment("Production", user.Email, async () => @@ -321,8 +341,8 @@ public async Task CanSendAiMessage_ProductionNonSmokeEmail_OverLimit_StillBlocke { var user = CreateFreeUser(); user.StartTrial(DateTime.UtcNow.AddDays(-1)); - for (int i = 0; i < 20; i++) - user.IncrementAiMessageCount(); + for (int i = 0; i < 5; i++) + user.IncrementAiMessageCount(Today); _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); await WithEnvironment("Production", "not-the-smoke@example.com", async () => @@ -338,8 +358,8 @@ public async Task CanSendAiMessage_NonProductionSmokeEmail_OverLimit_StillBlocke { var user = CreateFreeUser(); user.StartTrial(DateTime.UtcNow.AddDays(-1)); - for (int i = 0; i < 20; i++) - user.IncrementAiMessageCount(); + for (int i = 0; i < 5; i++) + user.IncrementAiMessageCount(Today); _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); await WithEnvironment("Development", user.Email, async () => @@ -382,7 +402,7 @@ public async Task GetAiMessageLimit_ProUser_ReturnsProLimit() var limit = await _sut.GetAiMessageLimit(UserId); - limit.Should().Be(500); + limit.Should().Be(50); } [Fact] @@ -394,17 +414,28 @@ public async Task GetAiMessageLimit_FreeUser_ReturnsFreeLimit() var limit = await _sut.GetAiMessageLimit(UserId); - limit.Should().Be(20); + limit.Should().Be(5); } [Fact] - public async Task GetAiMessageLimit_UserNotFound_ReturnsDefault20() + public async Task GetAiMessageLimit_UserNotFound_ReturnsDefaultFreeDailyLimit() { _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns((User?)null); var limit = await _sut.GetAiMessageLimit(UserId); - limit.Should().Be(20); + limit.Should().Be(5); + } + + [Fact] + public async Task GetAiMessageLimit_ActiveTrial_ReturnsProDailyLimit() + { + var user = CreateFreeUser(); + _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); + + var limit = await _sut.GetAiMessageLimit(UserId); + + limit.Should().Be(50); } [Fact] @@ -613,31 +644,32 @@ public async Task CanCreateSubHabits_TrialUser_HasProAccess() } [Fact] - public async Task CanSendAiMessage_WithAdRewardBonus_IncreasedLimit() + public async Task CanSendAiMessage_WithAdRewardBonus_StillUsesConfiguredLimit() { var user = CreateFreeUser(); user.StartTrial(DateTime.UtcNow.AddDays(-1)); - for (int i = 0; i < 20; i++) - user.IncrementAiMessageCount(); - user.GrantAdReward(DateOnly.FromDateTime(DateTime.UtcNow), 5); + for (int i = 0; i < 5; i++) + user.IncrementAiMessageCount(Today); + user.GrantAdReward(Today, 15); _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); var result = await _sut.CanSendAiMessage(UserId); - result.IsSuccess.Should().BeTrue(); + result.IsFailure.Should().BeTrue(); + result.ErrorCode.Should().Be("PAY_GATE"); } [Fact] - public async Task GetAiMessageLimit_WithAdRewardBonus_IncludesBonus() + public async Task GetAiMessageLimit_WithAdRewardBonus_IgnoresBonus() { var user = CreateFreeUser(); user.StartTrial(DateTime.UtcNow.AddDays(-1)); - user.GrantAdReward(DateOnly.FromDateTime(DateTime.UtcNow), 5); + user.GrantAdReward(Today, 15); _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); var limit = await _sut.GetAiMessageLimit(UserId); - limit.Should().Be(25); + limit.Should().Be(5); } [Fact] diff --git a/tests/Orbit.Application.Tests/Queries/Profile/GetProfileQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Profile/GetProfileQueryHandlerTests.cs index 0eb9e3db..7d40831a 100644 --- a/tests/Orbit.Application.Tests/Queries/Profile/GetProfileQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/Profile/GetProfileQueryHandlerTests.cs @@ -67,7 +67,7 @@ public async Task Handle_UserFound_ReturnsProfile() { var user = CreateTestUser("John Doe"); _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); - _payGate.GetAiMessageLimit(UserId, Arg.Any()).Returns(20); + _payGate.GetAiMessageLimit(UserId, Arg.Any()).Returns(50); _streakFreezeRepo.FindAsync( Arg.Any>>(), Arg.Any()) @@ -97,8 +97,8 @@ public async Task Handle_UserFound_ReturnsProfile() user.IsTrialActive, user.TrialEndsAt, user.PlanExpiresAt, - AiMessagesUsed = user.AiMessagesUsedThisMonth, - AiMessagesLimit = 20, + AiMessagesUsed = user.AiMessagesUsedToday, + AiMessagesLimit = 50, user.HasImportedCalendar, user.HasSeenImportPrompt, HasGoogleConnection = false, @@ -182,7 +182,7 @@ public async Task Handle_ReturnsOnboardingChecklistFlags() user.MarkFirstHabitCreated(); user.MarkAstraUsed(); _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); - _payGate.GetAiMessageLimit(UserId, Arg.Any()).Returns(20); + _payGate.GetAiMessageLimit(UserId, Arg.Any()).Returns(5); StubFreezeRepoEmpty(); var result = await _handler.Handle(new GetProfileQuery(UserId), CancellationToken.None); @@ -200,7 +200,7 @@ public async Task Handle_UserWithStreak_ReturnsCurrentAndLongest() var user = CreateTestUser(); user.SetStreakState(5, 12, Today); _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); - _payGate.GetAiMessageLimit(UserId, Arg.Any()).Returns(20); + _payGate.GetAiMessageLimit(UserId, Arg.Any()).Returns(5); _streakFreezeRepo.FindAsync( Arg.Any>>(), Arg.Any()) @@ -238,7 +238,7 @@ public async Task Handle_FreeUser_ReturnsFreeplan() user.StartTrial(DateTime.UtcNow.AddDays(-1)); _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); - _payGate.GetAiMessageLimit(UserId, Arg.Any()).Returns(20); + _payGate.GetAiMessageLimit(UserId, Arg.Any()).Returns(5); _streakFreezeRepo.FindAsync( Arg.Any>>(), Arg.Any()) @@ -251,6 +251,7 @@ public async Task Handle_FreeUser_ReturnsFreeplan() result.IsSuccess.Should().BeTrue(); result.Value.Plan.Should().Be("free"); result.Value.HasProAccess.Should().BeFalse(); + result.Value.AiMessagesLimit.Should().Be(5); } [Fact] @@ -260,7 +261,7 @@ public async Task Handle_ProUser_ReturnsProPlan() user.SetStripeSubscription("sub_123", DateTime.UtcNow.AddYears(1)); _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); - _payGate.GetAiMessageLimit(UserId, Arg.Any()).Returns(500); + _payGate.GetAiMessageLimit(UserId, Arg.Any()).Returns(50); _streakFreezeRepo.FindAsync( Arg.Any>>(), Arg.Any()) @@ -273,6 +274,7 @@ public async Task Handle_ProUser_ReturnsProPlan() result.IsSuccess.Should().BeTrue(); result.Value.Plan.Should().Be("pro"); result.Value.HasProAccess.Should().BeTrue(); + result.Value.AiMessagesLimit.Should().Be(50); } [Fact] diff --git a/tests/Orbit.Application.Tests/Queries/Subscriptions/GetSubscriptionStatusQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Subscriptions/GetSubscriptionStatusQueryHandlerTests.cs index ef53da20..044a55af 100644 --- a/tests/Orbit.Application.Tests/Queries/Subscriptions/GetSubscriptionStatusQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/Subscriptions/GetSubscriptionStatusQueryHandlerTests.cs @@ -30,7 +30,7 @@ public async Task Handle_UserFound_ReturnsStatus() { var user = CreateTestUser(); _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); - _payGate.GetAiMessageLimit(Arg.Any(), Arg.Any()).Returns(500); + _payGate.GetAiMessageLimit(Arg.Any(), Arg.Any()).Returns(50); var query = new GetSubscriptionStatusQuery(UserId); @@ -41,7 +41,7 @@ public async Task Handle_UserFound_ReturnsStatus() result.Value.HasProAccess.Should().BeTrue(); result.Value.IsTrialActive.Should().BeTrue(); result.Value.AiMessagesUsed.Should().Be(0); - result.Value.AiMessagesLimit.Should().Be(500); + result.Value.AiMessagesLimit.Should().Be(50); result.Value.LapseReason.Should().BeNull(); result.Value.SubscriptionEndedAtUtc.Should().BeNull(); } @@ -66,7 +66,7 @@ public async Task Handle_PlaySubscription_ReturnsPlaySource() var user = CreateTestUser(); user.SetPlaySubscription("tok_123", DateTime.UtcNow.AddMonths(1), SubscriptionInterval.Monthly); _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); - _payGate.GetAiMessageLimit(Arg.Any(), Arg.Any()).Returns(500); + _payGate.GetAiMessageLimit(Arg.Any(), Arg.Any()).Returns(50); var result = await _handler.Handle(new GetSubscriptionStatusQuery(UserId), CancellationToken.None); @@ -80,7 +80,7 @@ public async Task Handle_TrialUser_ReturnsTrialActive() { var user = CreateTestUser(); _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); - _payGate.GetAiMessageLimit(Arg.Any(), Arg.Any()).Returns(500); + _payGate.GetAiMessageLimit(Arg.Any(), Arg.Any()).Returns(50); var query = new GetSubscriptionStatusQuery(UserId); diff --git a/tests/Orbit.Domain.Tests/Entities/UserTests.cs b/tests/Orbit.Domain.Tests/Entities/UserTests.cs index 0d563ded..256f515f 100644 --- a/tests/Orbit.Domain.Tests/Entities/UserTests.cs +++ b/tests/Orbit.Domain.Tests/Entities/UserTests.cs @@ -443,53 +443,56 @@ public void CancelStripeSubscription_InvalidReason_Throws() public void IncrementAiMessageCount_FirstTime_ResetsAndIncrements() { var user = CreateValidUser(); + var today = new DateOnly(2026, 3, 1); - user.IncrementAiMessageCount(); + user.IncrementAiMessageCount(today); - user.AiMessagesUsedThisMonth.Should().Be(1); - user.AiMessagesResetAt.Should().NotBeNull(); + user.AiMessagesUsedToday.Should().Be(1); + user.AiMessagesLocalDate.Should().Be(today); } [Fact] - public void IncrementAiMessageCount_WithinPeriod_JustIncrements() + public void IncrementAiMessageCount_SameLocalDate_JustIncrements() { var user = CreateValidUser(); - user.IncrementAiMessageCount(); var resetAt = user.AiMessagesResetAt; + var today = new DateOnly(2026, 3, 1); + user.IncrementAiMessageCount(today); - user.IncrementAiMessageCount(); + user.IncrementAiMessageCount(today); - user.AiMessagesUsedThisMonth.Should().Be(2); - user.AiMessagesResetAt.Should().Be(resetAt); + user.AiMessagesUsedToday.Should().Be(2); + user.AiMessagesLocalDate.Should().Be(today); } [Fact] - public void IncrementAiMessageCount_PastReset_ResetsCounter() + public void IncrementAiMessageCount_NextLocalDate_ResetsCounter() { var user = CreateValidUser(); - var now = new DateTime(2026, 3, 1, 12, 0, 0, DateTimeKind.Utc); - user.IncrementAiMessageCount(now); - user.IncrementAiMessageCount(now); - user.AiMessagesUsedThisMonth.Should().Be(2); + var today = new DateOnly(2026, 3, 1); + user.IncrementAiMessageCount(today); + user.IncrementAiMessageCount(today); + user.AiMessagesUsedToday.Should().Be(2); - user.IncrementAiMessageCount(now.AddDays(31)); + user.IncrementAiMessageCount(today.AddDays(1)); - user.AiMessagesUsedThisMonth.Should().Be(1); + user.AiMessagesUsedToday.Should().Be(1); + user.AiMessagesLocalDate.Should().Be(today.AddDays(1)); } [Fact] - public void IncrementAiMessageCount_PastReset_ZeroesAdRewardBonus() + public void IncrementAiMessageCount_NextLocalDate_ZeroesAdRewardBonus() { var user = CreateValidUser(); - var now = new DateTime(2026, 3, 1, 12, 0, 0, DateTimeKind.Utc); + var today = new DateOnly(2026, 3, 1); user.StartTrial(DateTime.UtcNow.AddDays(-1)); - user.IncrementAiMessageCount(now); - user.GrantAdReward(DateOnly.FromDateTime(now), bonusMessages: 5); - user.IncrementAiMessageCount(now); + user.IncrementAiMessageCount(today); + user.GrantAdReward(today, bonusMessages: 5); + user.IncrementAiMessageCount(today); user.AdRewardBonusMessages.Should().Be(5); - user.IncrementAiMessageCount(now.AddDays(31)); + user.IncrementAiMessageCount(today.AddDays(1)); - user.AiMessagesUsedThisMonth.Should().Be(1); + user.AiMessagesUsedToday.Should().Be(1); user.AdRewardBonusMessages.Should().Be(0); } @@ -498,18 +501,18 @@ public void ResetAccount_PreservesMeteredAiUsageAndAdRewards() { var user = CreateValidUser(); user.StartTrial(DateTime.UtcNow.AddDays(-1)); + var today = new DateOnly(2026, 3, 1); for (int i = 0; i < 20; i++) - user.IncrementAiMessageCount(); - user.GrantAdReward(DateOnly.FromDateTime(DateTime.UtcNow), bonusMessages: 5); - var resetAt = user.AiMessagesResetAt; + user.IncrementAiMessageCount(today); + user.GrantAdReward(today, bonusMessages: 5); user.ResetAccount(); - user.AiMessagesUsedThisMonth.Should().Be(20); - user.AiMessagesResetAt.Should().Be(resetAt); + user.AiMessagesUsedToday.Should().Be(20); + user.AiMessagesLocalDate.Should().Be(today); user.AdRewardBonusMessages.Should().Be(5); user.AdRewardsClaimedToday.Should().Be(1); - user.LastAdRewardLocalDate.Should().Be(DateOnly.FromDateTime(DateTime.UtcNow)); + user.LastAdRewardLocalDate.Should().Be(today); } [Fact] @@ -533,13 +536,14 @@ public void ResetAccount_ClearsOnboardingChecklistEvidence() public void ResetAccount_ThenSendMessage_DoesNotRefillQuota() { var user = CreateValidUser(); + var today = new DateOnly(2026, 3, 1); for (int i = 0; i < 20; i++) - user.IncrementAiMessageCount(); + user.IncrementAiMessageCount(today); user.ResetAccount(); - user.IncrementAiMessageCount(); + user.IncrementAiMessageCount(today); - user.AiMessagesUsedThisMonth.Should().Be(21); + user.AiMessagesUsedToday.Should().Be(21); } [Fact] diff --git a/tests/Orbit.Infrastructure.Tests/Mcp/SubscriptionToolsTests.cs b/tests/Orbit.Infrastructure.Tests/Mcp/SubscriptionToolsTests.cs index 95a0f36e..19b7a012 100644 --- a/tests/Orbit.Infrastructure.Tests/Mcp/SubscriptionToolsTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Mcp/SubscriptionToolsTests.cs @@ -66,7 +66,7 @@ public async Task GetSubscriptionStatus_UserWithTrial_ShowsPlanAndAiMessages() var result = await _tools.GetSubscriptionStatus(_user); result.Should().Contain("Plan: Pro"); - result.Should().Contain("AI Messages: 0/5"); + result.Should().Contain("Daily AI Messages: 0/5"); } [Fact] @@ -94,7 +94,7 @@ public async Task GetSubscriptionStatus_AiMessageUsage_ShowsCorrectCount() var result = await _tools.GetSubscriptionStatus(_user); - result.Should().Contain("AI Messages: 0/50"); + result.Should().Contain("Daily AI Messages: 0/50"); } [Fact] diff --git a/tests/Orbit.Infrastructure.Tests/Persistence/DailyAiQuotaByLocalDateMigrationTests.cs b/tests/Orbit.Infrastructure.Tests/Persistence/DailyAiQuotaByLocalDateMigrationTests.cs new file mode 100644 index 00000000..65e33916 --- /dev/null +++ b/tests/Orbit.Infrastructure.Tests/Persistence/DailyAiQuotaByLocalDateMigrationTests.cs @@ -0,0 +1,87 @@ +using System.Reflection; +using FluentAssertions; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations.Operations; +using Orbit.Infrastructure.Migrations; + +namespace Orbit.Infrastructure.Tests.Persistence; + +public class DailyAiQuotaByLocalDateMigrationTests +{ + [Fact] + public void Up_ReplacesMonthlySchemaAndConfigsAndZeroesEveryCounter() + { + var operations = GetOperations("Up"); + + operations.OfType().Should().ContainSingle() + .Which.Name.Should().Be("AiMessagesResetAt"); + var rename = operations.OfType().Should().ContainSingle().Subject; + rename.Name.Should().Be("AiMessagesUsedThisMonth"); + rename.NewName.Should().Be("AiMessagesUsedToday"); + var localDate = operations.OfType().Should().ContainSingle().Subject; + localDate.Name.Should().Be("AiMessagesLocalDate"); + localDate.ColumnType.Should().Be("date"); + localDate.IsNullable.Should().BeTrue(); + + operations.OfType().Should().ContainSingle() + .Which.Sql.Should().Be("UPDATE \"Users\" SET \"AiMessagesUsedToday\" = 0;"); + + DeletedKeys(operations).Should().BeEquivalentTo( + ["FreeAiMessagesPerMonth", "ProAiMessagesPerMonth"]); + InsertedRows(operations).Should().BeEquivalentTo( + [ + ("FreeAiMessagesPerDay", "Daily AI message limit for free plan users", "5"), + ("ProAiMessagesPerDay", "Daily AI message limit for Pro plan users", "50") + ]); + } + + [Fact] + public void Down_RestoresMonthlySchemaAndConfigs() + { + var operations = GetOperations("Down"); + + operations.OfType().Should().ContainSingle() + .Which.Name.Should().Be("AiMessagesLocalDate"); + var rename = operations.OfType().Should().ContainSingle().Subject; + rename.Name.Should().Be("AiMessagesUsedToday"); + rename.NewName.Should().Be("AiMessagesUsedThisMonth"); + var resetAt = operations.OfType().Should().ContainSingle().Subject; + resetAt.Name.Should().Be("AiMessagesResetAt"); + resetAt.ColumnType.Should().Be("timestamp with time zone"); + resetAt.IsNullable.Should().BeTrue(); + + DeletedKeys(operations).Should().BeEquivalentTo( + ["FreeAiMessagesPerDay", "ProAiMessagesPerDay"]); + InsertedRows(operations).Should().BeEquivalentTo( + [ + ("FreeAiMessagesPerMonth", "Monthly AI message limit for free plan users", "20"), + ("ProAiMessagesPerMonth", "Monthly AI message limit for Pro plan users", "500") + ]); + } + + private static IReadOnlyList GetOperations(string methodName) + { + var migration = new DailyAiQuotaByLocalDate(); + var builder = new MigrationBuilder("Npgsql.EntityFrameworkCore.PostgreSQL"); + typeof(DailyAiQuotaByLocalDate) + .GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(migration, [builder]); + return builder.Operations; + } + + private static IReadOnlyList DeletedKeys(IEnumerable operations) => + operations + .OfType() + .Select(operation => (string)operation.KeyValues[0, 0]!) + .ToList(); + + private static IReadOnlyList<(string Key, string Description, string Value)> InsertedRows( + IEnumerable operations) => + operations + .OfType() + .Select(operation => ( + (string)operation.Values[0, 0]!, + (string)operation.Values[0, 1]!, + (string)operation.Values[0, 2]!)) + .ToList(); +} diff --git a/tests/Orbit.Infrastructure.Tests/Services/AiUsageSummaryServiceGenerationTests.cs b/tests/Orbit.Infrastructure.Tests/Services/AiUsageSummaryServiceGenerationTests.cs index c0e48e7a..7cb9df6b 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/AiUsageSummaryServiceGenerationTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/AiUsageSummaryServiceGenerationTests.cs @@ -4,7 +4,10 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using NSubstitute; +using Orbit.Application.Common; using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; using Orbit.Infrastructure.Configuration; using Orbit.Infrastructure.Persistence; using Orbit.Infrastructure.Services; @@ -36,7 +39,9 @@ public async Task SummarizeYesterdayAsync_NoRows_EmitsNoUsageRecorded() await harness.Service.SummarizeYesterdayAsync(CancellationToken.None); - harness.SingleSummaryLine().Should().Be($"AI cost {Yesterday:yyyy-MM-dd}: no usage recorded"); + harness.SingleSummaryLine().Should().Be( + $"AI cost {Yesterday:yyyy-MM-dd}: no usage recorded; " + + $"Astra quota {Yesterday:yyyy-MM-dd}: no active users"); } [Fact] @@ -51,11 +56,40 @@ public async Task SummarizeYesterdayAsync_CalledTwice_EmitsSummaryOnlyOnce() harness.Logger.Entries.Count(entry => entry.Message.StartsWith("AI cost")).Should().Be(1); } + [Fact] + public async Task SummarizeYesterdayAsync_WithQuotaUsers_EmitsDailyQuotaMetrics() + { + var harness = new Harness(); + await harness.SeedUsersAsync( + UserWithMessages(5, hasProAccess: false), + UserWithMessages(50, hasProAccess: true)); + + await harness.Service.SummarizeYesterdayAsync(CancellationToken.None); + + harness.SingleSummaryLine().Should().Contain( + "free_cap_hits=1; pro_cap_hits=1; mean_messages_per_active_user=27.50; " + + "p95_messages_per_active_user=50"); + } + private static AiUsageDaily Row(string purpose, long calls, decimal costUsd) => AiUsageDaily.Create( Yesterday, "gpt-4.1-mini", purpose, new AiUsageTotals(calls, CachedTokens: 0, PromptTokens: 0, CompletionTokens: 0, TotalTokens: 0, CostUsd: costUsd)); + private static User UserWithMessages(int count, bool hasProAccess) + { + var user = User.Create("Metrics User", $"metrics-{Guid.NewGuid():N}@example.com").Value; + if (hasProAccess) + user.GrantLifetimePro(); + else + user.StartTrial(DateTime.UtcNow.AddDays(-1)); + + for (var i = 0; i < count; i++) + user.IncrementAiMessageCount(Yesterday); + + return user; + } + private sealed class Harness { private readonly string _databaseName = $"AiUsageSummary_{Guid.NewGuid()}"; @@ -67,8 +101,12 @@ private sealed class Harness public Harness() { + var appConfig = Substitute.For(); + appConfig.GetAsync(AppConfigKeys.FreeAiMessagesPerDay, 5, Arg.Any()).Returns(5); + appConfig.GetAsync(AppConfigKeys.ProAiMessagesPerDay, 50, Arg.Any()).Returns(50); _provider = new ServiceCollection() .AddDbContext(options => options.UseInMemoryDatabase(_databaseName)) + .AddSingleton(appConfig) .BuildServiceProvider(); var settings = new AiSettings @@ -98,6 +136,14 @@ public async Task SeedAsync(params AiUsageDaily[] rows) await dbContext.SaveChangesAsync(); } + public async Task SeedUsersAsync(params User[] users) + { + using var scope = _provider.GetRequiredService().CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + dbContext.Users.AddRange(users); + await dbContext.SaveChangesAsync(); + } + public string SingleSummaryLine() => Logger.Entries.Single(entry => entry.Message.StartsWith("AI cost")).Message; } diff --git a/tests/Orbit.Infrastructure.Tests/Services/AiUsageSummaryServiceTests.cs b/tests/Orbit.Infrastructure.Tests/Services/AiUsageSummaryServiceTests.cs index 1d4d790a..b9b0e9c1 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/AiUsageSummaryServiceTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/AiUsageSummaryServiceTests.cs @@ -88,8 +88,48 @@ public void BuildSummaryLine_FlagsModelsWithoutConfiguredPrice() line.Should().EndWith("; unpriced: gpt-5.4-nano"); } + [Fact] + public void BuildQuotaSummaryLine_ReportsCapHitsMeanAndP95() + { + var users = new List + { + UserWithMessages(5, hasProAccess: false), + UserWithMessages(2, hasProAccess: false), + UserWithMessages(50, hasProAccess: true), + UserWithMessages(10, hasProAccess: true) + }; + + var line = AiUsageSummaryService.BuildQuotaSummaryLine(Date, users, freeLimit: 5, proLimit: 50); + + line.Should().Be( + "Astra quota 2026-06-29: free_cap_hits=1; pro_cap_hits=1; " + + "mean_messages_per_active_user=16.75; p95_messages_per_active_user=50"); + } + + [Fact] + public void BuildQuotaSummaryLine_NoActiveUsers_ReportsEmptyPopulation() + { + var line = AiUsageSummaryService.BuildQuotaSummaryLine(Date, [], freeLimit: 5, proLimit: 50); + + line.Should().Be("Astra quota 2026-06-29: no active users"); + } + private static AiUsageDaily Row(string purpose, string model, long calls, decimal costUsd) => AiUsageDaily.Create( Date, model, purpose, new AiUsageTotals(calls, CachedTokens: 0, PromptTokens: 0, CompletionTokens: 0, TotalTokens: 0, CostUsd: costUsd)); + + private static User UserWithMessages(int count, bool hasProAccess) + { + var user = User.Create("Metrics User", $"metrics-{Guid.NewGuid():N}@example.com").Value; + if (hasProAccess) + user.GrantLifetimePro(); + else + user.StartTrial(DateTime.UtcNow.AddDays(-1)); + + for (var i = 0; i < count; i++) + user.IncrementAiMessageCount(Date); + + return user; + } } diff --git a/tests/Orbit.Infrastructure.Tests/Services/UserDateServiceTests.cs b/tests/Orbit.Infrastructure.Tests/Services/UserDateServiceTests.cs index 2f4c1b3f..4fe4b070 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/UserDateServiceTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/UserDateServiceTests.cs @@ -19,7 +19,34 @@ public class UserDateServiceTests public UserDateServiceTests() { - _sut = new UserDateService(_userRepo, _cache); + _sut = new UserDateService(_userRepo, _cache, TimeProvider.System); + } + + [Fact] + public async Task GetUserTodayAsync_AucklandAndSaoPauloRollOverIndependently() + { + var clock = new FixedTimeProvider(new DateTimeOffset(2026, 8, 23, 12, 30, 0, TimeSpan.Zero)); + var service = new UserDateService(_userRepo, _cache, clock); + + var aucklandToday = await service.GetUserTodayAsync("Pacific/Auckland", Guid.NewGuid()); + var saoPauloToday = await service.GetUserTodayAsync("America/Sao_Paulo", Guid.NewGuid()); + + aucklandToday.Should().Be(new DateOnly(2026, 8, 24)); + saoPauloToday.Should().Be(new DateOnly(2026, 8, 23)); + } + + [Fact] + public async Task GetUserTodayAsync_NullTimezone_RollsOverAtUtcMidnight() + { + var clock = new MutableTimeProvider(new DateTimeOffset(2026, 8, 23, 23, 30, 0, TimeSpan.Zero)); + var service = new UserDateService(_userRepo, _cache, clock); + + var beforeMidnight = await service.GetUserTodayAsync(null, UserId); + clock.Advance(TimeSpan.FromHours(1)); + var afterMidnight = await service.GetUserTodayAsync(null, UserId); + + beforeMidnight.Should().Be(new DateOnly(2026, 8, 23)); + afterMidnight.Should().Be(new DateOnly(2026, 8, 24)); } [Fact] @@ -120,7 +147,7 @@ public async Task GetUserWeekStartDayAsync_SecondInstanceSharingCache_ReadsCache await _sut.GetUserWeekStartDayAsync(UserId); var secondInstanceRepo = Substitute.For>(); - var secondInstance = new UserDateService(secondInstanceRepo, _cache); + var secondInstance = new UserDateService(secondInstanceRepo, _cache, TimeProvider.System); var result = await secondInstance.GetUserWeekStartDayAsync(UserId); @@ -130,4 +157,16 @@ public async Task GetUserWeekStartDayAsync_SecondInstanceSharingCache_ReadsCache private static MemoryDistributedCache NewDistributedCache() => new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions())); + + private sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => now; + } + + private sealed class MutableTimeProvider(DateTimeOffset now) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => now; + + public void Advance(TimeSpan amount) => now = now.Add(amount); + } } From 31385c5ad6dd5f879d4a4ae5b8c42ad379a024ef Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Sun, 23 Aug 2026 17:47:08 -0300 Subject: [PATCH 3/5] chore: refresh architecture map --- architecture.html | 2 +- architecture.json | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/architecture.html b/architecture.html index df661106..da4818cf 100644 --- a/architecture.html +++ b/architecture.html @@ -47,7 +47,7 @@

Handlers with no endpoint

RequestHandler file

Entities

EntityDomain file
- +