diff --git a/src/Orbit.Application/Common/AppConstants.cs b/src/Orbit.Application/Common/AppConstants.cs index f88d8d34..1d317c1d 100644 --- a/src/Orbit.Application/Common/AppConstants.cs +++ b/src/Orbit.Application/Common/AppConstants.cs @@ -41,6 +41,8 @@ public static class AppConstants public const int AdRewardBonusMessages = 5; public const int AdRewardDailyCap = 3; public const int MaxStreakFreezesPerMonth = 3; + public const int MaxStreakFreezesAccumulated = 3; + public const int StreakDaysPerFreeze = 7; public const int MaxStreakLookbackDays = 365; public static readonly string[] SupportedLanguages = ["en", "pt-BR"]; } diff --git a/src/Orbit.Application/Common/ErrorCodes.cs b/src/Orbit.Application/Common/ErrorCodes.cs index 8efd08c2..d628a227 100644 --- a/src/Orbit.Application/Common/ErrorCodes.cs +++ b/src/Orbit.Application/Common/ErrorCodes.cs @@ -27,6 +27,7 @@ public static class ErrorCodes public const string ChatHistoryTooLarge = "CHAT_HISTORY_TOO_LARGE"; public const string MessageTooLong = "MESSAGE_TOO_LONG"; public const string StreakFreezeNotAvailable = "STREAK_FREEZE_NOT_AVAILABLE"; + public const string StreakFreezeMonthlyLimit = "STREAK_FREEZE_MONTHLY_LIMIT"; public const string AlreadyUsedStreakFreezeToday = "ALREADY_USED_STREAK_FREEZE_TODAY"; public const string NoActiveStreak = "NO_ACTIVE_STREAK"; public const string NotEnoughCoins = "NOT_ENOUGH_COINS"; diff --git a/src/Orbit.Application/Common/ErrorMessages.cs b/src/Orbit.Application/Common/ErrorMessages.cs index a74516e5..c6398893 100644 --- a/src/Orbit.Application/Common/ErrorMessages.cs +++ b/src/Orbit.Application/Common/ErrorMessages.cs @@ -25,6 +25,7 @@ public static class ErrorMessages public const string ChatHistoryTooLarge = "Chat history too large."; public const string MessageTooLong = "Message must be between 1 and 4000 characters."; public const string StreakFreezeNotAvailable = "No streak freeze available."; + public const string StreakFreezeMonthlyLimit = "You've used all streak freezes this month."; public const string AlreadyUsedStreakFreezeToday = "Streak freeze already used today."; public const string NoActiveStreak = "No active streak to protect."; public const string NotEnoughCoins = "Not enough coins."; diff --git a/src/Orbit.Application/Gamification/Commands/ActivateStreakFreezeCommand.cs b/src/Orbit.Application/Gamification/Commands/ActivateStreakFreezeCommand.cs index 0f257555..e9f12ea7 100644 --- a/src/Orbit.Application/Gamification/Commands/ActivateStreakFreezeCommand.cs +++ b/src/Orbit.Application/Gamification/Commands/ActivateStreakFreezeCommand.cs @@ -10,7 +10,8 @@ namespace Orbit.Application.Gamification.Commands; public record StreakFreezeResponse( int FreezesRemainingThisMonth, DateOnly FrozenDate, - int CurrentStreak); + int CurrentStreak, + int StreakFreezesAccumulated); public record ActivateStreakFreezeCommand(Guid UserId) : IRequest>; @@ -38,11 +39,12 @@ public async Task> Handle(ActivateStreakFreezeComma var today = await userDateService.GetUserTodayAsync(request.UserId, cancellationToken); - // Validate streak > 0 if (existingStreak.CurrentStreak <= 0) return Result.Failure(ErrorMessages.NoActiveStreak, ErrorCodes.NoActiveStreak); - // Check if user already logged a habit today + if (user.StreakFreezesAccumulated <= 0) + return Result.Failure(ErrorMessages.StreakFreezeNotAvailable, ErrorCodes.StreakFreezeNotAvailable); + var userHabits = await habitRepository.FindAsync(h => h.UserId == request.UserId, cancellationToken); var habitIds = userHabits.Select(h => h.Id).ToList(); @@ -56,7 +58,6 @@ public async Task> Handle(ActivateStreakFreezeComma return Result.Failure("You already completed a habit today. No freeze needed!"); } - // Check if already frozen today var existingFreeze = await streakFreezeRepository.FindAsync( sf => sf.UserId == request.UserId && sf.UsedOnDate == today, cancellationToken); @@ -64,16 +65,19 @@ public async Task> Handle(ActivateStreakFreezeComma if (existingFreeze.Count > 0) return Result.Failure(ErrorMessages.AlreadyUsedStreakFreezeToday, ErrorCodes.AlreadyUsedStreakFreezeToday); - // Count freezes in rolling 30-day window - var windowStart = today.AddDays(-29); - var recentFreezes = await streakFreezeRepository.FindAsync( - sf => sf.UserId == request.UserId && sf.UsedOnDate >= windowStart, + var monthStart = new DateOnly(today.Year, today.Month, 1); + var monthEnd = monthStart.AddMonths(1); + var freezesThisMonth = await streakFreezeRepository.FindAsync( + sf => sf.UserId == request.UserId && sf.UsedOnDate >= monthStart && sf.UsedOnDate < monthEnd, cancellationToken); - if (recentFreezes.Count >= AppConstants.MaxStreakFreezesPerMonth) - return Result.Failure(ErrorMessages.StreakFreezeNotAvailable, ErrorCodes.StreakFreezeNotAvailable); + if (freezesThisMonth.Count >= AppConstants.MaxStreakFreezesPerMonth) + return Result.Failure(ErrorMessages.StreakFreezeMonthlyLimit, ErrorCodes.StreakFreezeMonthlyLimit); + + var consume = user.ConsumeStreakFreeze(); + if (consume.IsFailure) + return Result.Failure(consume.Error!, ErrorCodes.StreakFreezeNotAvailable); - // Create freeze var freeze = StreakFreeze.Create(request.UserId, today); await streakFreezeRepository.AddAsync(freeze, cancellationToken); @@ -81,11 +85,12 @@ public async Task> Handle(ActivateStreakFreezeComma var updatedStreak = await userStreakService.RecalculateAsync(request.UserId, cancellationToken); await unitOfWork.SaveChangesAsync(cancellationToken); - var freezesRemaining = AppConstants.MaxStreakFreezesPerMonth - (recentFreezes.Count + 1); + var freezesRemaining = Math.Max(0, AppConstants.MaxStreakFreezesPerMonth - (freezesThisMonth.Count + 1)); return Result.Success(new StreakFreezeResponse( freezesRemaining, today, - updatedStreak?.CurrentStreak ?? 0)); + updatedStreak?.CurrentStreak ?? 0, + user.StreakFreezesAccumulated)); } } diff --git a/src/Orbit.Application/Gamification/Queries/GetStreakInfoQuery.cs b/src/Orbit.Application/Gamification/Queries/GetStreakInfoQuery.cs index 1373d50d..39a23b9c 100644 --- a/src/Orbit.Application/Gamification/Queries/GetStreakInfoQuery.cs +++ b/src/Orbit.Application/Gamification/Queries/GetStreakInfoQuery.cs @@ -14,7 +14,12 @@ public record StreakInfoResponse( int FreezesAvailable, int MaxFreezesPerMonth, bool IsFrozenToday, - IReadOnlyList RecentFreezeDates); + IReadOnlyList RecentFreezeDates, + int StreakFreezesAccumulated, + int MaxStreakFreezesAccumulated, + int DaysUntilNextFreeze, + int FreezesAvailableToUse, + bool CanEarnMore); public record GetStreakInfoQuery(Guid UserId) : IRequest>; @@ -31,15 +36,34 @@ public async Task> Handle(GetStreakInfoQuery request, var today = await userDateService.GetUserTodayAsync(request.UserId, cancellationToken); - // Query recent freezes within the rolling 30-day window + var monthStart = new DateOnly(today.Year, today.Month, 1); + var monthEnd = monthStart.AddMonths(1); + var freezesThisMonth = await streakFreezeRepository.FindAsync( + sf => sf.UserId == request.UserId && sf.UsedOnDate >= monthStart && sf.UsedOnDate < monthEnd, + cancellationToken); + var windowStart = today.AddDays(-29); var recentFreezes = await streakFreezeRepository.FindAsync( sf => sf.UserId == request.UserId && sf.UsedOnDate >= windowStart, cancellationToken); var isFrozenToday = recentFreezes.Any(sf => sf.UsedOnDate == today); - var freezesUsed = recentFreezes.Count; - var freezesAvailable = Math.Max(0, AppConstants.MaxStreakFreezesPerMonth - freezesUsed); + var freezesUsedThisMonth = freezesThisMonth.Count; + var remainingMonthlyQuota = Math.Max(0, AppConstants.MaxStreakFreezesPerMonth - freezesUsedThisMonth); + + var freezesAvailableToUse = Math.Min(user.StreakFreezesAccumulated, remainingMonthlyQuota); + + var daysSinceLastAward = Math.Max(0, user.CurrentStreak - user.LastFreezeAwardStreak); + var daysUntilNextFreeze = user.CurrentStreak <= 0 + ? AppConstants.StreakDaysPerFreeze + : Math.Max(0, AppConstants.StreakDaysPerFreeze - (daysSinceLastAward % AppConstants.StreakDaysPerFreeze)); + if (daysUntilNextFreeze == 0 && user.CurrentStreak > 0 && user.StreakFreezesAccumulated >= AppConstants.MaxStreakFreezesAccumulated) + { + daysUntilNextFreeze = AppConstants.StreakDaysPerFreeze; + } + + var canEarnMore = user.StreakFreezesAccumulated < AppConstants.MaxStreakFreezesAccumulated; + var recentFreezeDates = recentFreezes .Select(sf => sf.UsedOnDate) .OrderByDescending(d => d) @@ -49,10 +73,15 @@ public async Task> Handle(GetStreakInfoQuery request, user.CurrentStreak, user.LongestStreak, user.LastActiveDate, - freezesUsed, - freezesAvailable, + freezesUsedThisMonth, + freezesAvailableToUse, AppConstants.MaxStreakFreezesPerMonth, isFrozenToday, - recentFreezeDates)); + recentFreezeDates, + user.StreakFreezesAccumulated, + AppConstants.MaxStreakFreezesAccumulated, + daysUntilNextFreeze, + freezesAvailableToUse, + canEarnMore)); } } diff --git a/src/Orbit.Domain/Entities/User.cs b/src/Orbit.Domain/Entities/User.cs index 75dd0dd7..c3ef29d0 100644 --- a/src/Orbit.Domain/Entities/User.cs +++ b/src/Orbit.Domain/Entities/User.cs @@ -51,6 +51,8 @@ public partial class User : Entity public int CurrentStreak { get; private set; } = 0; public int LongestStreak { get; private set; } = 0; public DateOnly? LastActiveDate { get; private set; } + public int StreakFreezesAccumulated { get; private set; } = 0; + public int LastFreezeAwardStreak { get; private set; } = 0; public string? ThemePreference { get; private set; } public string? ColorScheme { get; private set; } @@ -323,11 +325,47 @@ public void ApplyStreakFreeze(DateOnly today) public void SetStreakState(int currentStreak, int longestStreak, DateOnly? lastActiveDate) { - CurrentStreak = Math.Max(0, currentStreak); + var normalizedStreak = Math.Max(0, currentStreak); + if (normalizedStreak < CurrentStreak) + { + // Streak broke (went to 0, or restarted from a lower value such as 14 -> 1 after a missed day); + // reset the "last award" marker so the next 7-day run triggers a fresh award. + LastFreezeAwardStreak = 0; + } + CurrentStreak = normalizedStreak; LongestStreak = Math.Max(CurrentStreak, longestStreak); LastActiveDate = lastActiveDate; } + public bool AwardStreakFreezeIfEligible(int maxAccumulated = 3, int daysPerFreeze = 7) + { + if (CurrentStreak < daysPerFreeze) + return false; + + if (StreakFreezesAccumulated >= maxAccumulated) + { + // Cap reached; advance marker to the most recent earned milestone so we don't overshoot later. + LastFreezeAwardStreak = CurrentStreak - (CurrentStreak % daysPerFreeze); + return false; + } + + var eligibleMilestone = CurrentStreak - (CurrentStreak % daysPerFreeze); + if (eligibleMilestone <= LastFreezeAwardStreak) + return false; + + StreakFreezesAccumulated++; + LastFreezeAwardStreak = LastFreezeAwardStreak + daysPerFreeze; + return true; + } + + public Result ConsumeStreakFreeze() + { + if (StreakFreezesAccumulated <= 0) + return Result.Failure("No streak freezes accumulated"); + StreakFreezesAccumulated--; + return Result.Success(); + } + /// /// Resets all user profile fields to their default state while preserving /// identity, preferences, and subscription data. @@ -341,6 +379,8 @@ public void ResetAccount() CurrentStreak = 0; LongestStreak = 0; LastActiveDate = null; + StreakFreezesAccumulated = 0; + LastFreezeAwardStreak = 0; AiMessagesUsedThisMonth = 0; AiMessagesResetAt = null; AdRewardBonusMessages = 0; diff --git a/src/Orbit.Infrastructure/Migrations/20260415175239_AddStreakFreezeAccumulation.Designer.cs b/src/Orbit.Infrastructure/Migrations/20260415175239_AddStreakFreezeAccumulation.Designer.cs new file mode 100644 index 00000000..dca04011 --- /dev/null +++ b/src/Orbit.Infrastructure/Migrations/20260415175239_AddStreakFreezeAccumulation.Designer.cs @@ -0,0 +1,1501 @@ +// +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("20260415175239_AddStreakFreezeAccumulation")] + partial class AddStreakFreezeAccumulation + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("HabitGoals", b => + { + b.Property("GoalId") + .HasColumnType("uuid"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.HasKey("GoalId", "HabitId"); + + b.HasIndex("HabitId"); + + b.ToTable("HabitGoals"); + }); + + modelBuilder.Entity("HabitTags", b => + { + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("TagId") + .HasColumnType("uuid"); + + b.HasKey("HabitId", "TagId"); + + b.HasIndex("TagId"); + + b.ToTable("HabitTags"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AgentAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthMethod") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CapabilityId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CorrelationId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Error") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("OutcomeStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("PolicyDecision") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RedactedArguments") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RiskClass") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ShadowPolicyDecision") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ShadowReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("SourceName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Summary") + .HasColumnType("text"); + + b.Property("Surface") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TargetId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TargetName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CapabilityId", "CreatedAtUtc"); + + b.HasIndex("UserId", "CreatedAtUtc"); + + b.ToTable("AgentAuditLogs"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AgentStepUpChallengeState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttemptCount") + .HasColumnType("integer"); + + b.Property("CodeHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PendingOperationId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("VerifiedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PendingOperationId", "CreatedAtUtc"); + + b.ToTable("AgentStepUpChallenges"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.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" + }); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AppFeatureFlag", b => + { + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("PlanRequirement") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Key"); + + b.ToTable("AppFeatureFlags"); + + b.HasData( + new + { + Key = "offline_mode", + Description = "Enable offline mode with background sync", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "ai_chat", + Description = "AI chat assistant", + Enabled = true, + PlanRequirement = "Free", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "ai_summary", + Description = "AI daily summary", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "ai_retrospective", + Description = "AI retrospective analysis", + Enabled = true, + PlanRequirement = "YearlyPro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "sub_habits", + Description = "Sub-habit nesting", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "goal_tracking", + Description = "Goal tracking with progress", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "push_notifications", + Description = "Push notification reminders", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "scheduled_reminders", + Description = "Custom scheduled reminders", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "slip_alerts", + Description = "Slip detection alerts", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "checklist_templates", + Description = "Reusable checklist templates", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "habit_duplication", + Description = "Duplicate habits", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "bulk_operations", + Description = "Bulk create/delete/log habits", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "calendar_integration", + Description = "Google Calendar integration", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "api_keys", + Description = "Personal API keys", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ChecklistTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("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.ToTable("ChecklistTemplates"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ContentBlock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Key", "Locale") + .IsUnique(); + + b.ToTable("ContentBlocks"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.DistributedRateLimitBucket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Count") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PartitionKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PolicyName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WindowEndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WindowStartUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PolicyName", "PartitionKey", "WindowStartUtc") + .IsUnique(); + + b.ToTable("DistributedRateLimitBuckets"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Goal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentValue") + .HasColumnType("numeric"); + + b.Property("Deadline") + .HasColumnType("date"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("StreakSyncedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TargetValue") + .HasColumnType("numeric"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("Unit") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "IsDeleted"); + + b.ToTable("Goals"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.GoalProgressLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GoalId") + .HasColumnType("uuid"); + + 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.ToTable("GoalProgressLogs"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.GoogleCalendarSyncSuggestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DiscoveredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DismissedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GoogleEventId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ImportedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ImportedHabitId") + .HasColumnType("uuid"); + + b.Property("RawEventJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartDateUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "GoogleEventId") + .IsUnique(); + + b.HasIndex("UserId", "DismissedAtUtc", "ImportedAtUtc"); + + b.ToTable("GoogleCalendarSyncSuggestions"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Habit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChecklistItems") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Days") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DueDate") + .HasColumnType("date"); + + b.Property("DueEndTime") + .HasColumnType("time without time zone"); + + b.Property("DueTime") + .HasColumnType("time without time zone"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("FrequencyQuantity") + .HasColumnType("integer"); + + b.Property("FrequencyUnit") + .HasColumnType("integer"); + + b.Property("GoogleEventId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsBadHabit") + .HasColumnType("boolean"); + + b.Property("IsCompleted") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsFlexible") + .HasColumnType("boolean"); + + b.Property("IsGeneral") + .HasColumnType("boolean"); + + b.Property("ParentHabitId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ReminderEnabled") + .HasColumnType("boolean"); + + b.Property("ReminderTimes") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[15]'::jsonb"); + + b.Property("ScheduledReminders") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("SlipAlertEnabled") + .HasColumnType("boolean"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ParentHabitId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "GoogleEventId") + .IsUnique() + .HasFilter("\"GoogleEventId\" IS NOT NULL"); + + b.HasIndex("UserId", "IsDeleted"); + + b.ToTable("Habits"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.HabitLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("HabitId", "Date"); + + 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("HabitId") + .HasColumnType("uuid"); + + b.Property("IsRead") + .HasColumnType("boolean"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Url") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Url") + .HasFilter("\"Url\" IS NOT NULL"); + + b.HasIndex("UserId", "IsRead"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.PendingAgentOperationState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ArgumentsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CapabilityId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfirmationRequirement") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ConfirmationTokenHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ConfirmedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OperationFingerprint") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OperationId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RiskClass") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("StepUpSatisfiedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Surface") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CapabilityId"); + + b.HasIndex("UserId", "OperationFingerprint"); + + b.ToTable("PendingAgentOperations"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.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.HasKey("Id"); + + b.HasIndex("ReferredUserId") + .IsUnique(); + + b.HasIndex("ReferrerId"); + + b.ToTable("Referrals"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.SentReminder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("MinutesBefore") + .HasColumnType("integer"); + + b.Property("ReminderTimeUtc") + .HasColumnType("time without time zone"); + + b.Property("SentAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("HabitId", "Date", "MinutesBefore") + .IsUnique(); + + 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() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "IsDeleted"); + + b.HasIndex("UserId", "Name") + .IsUnique(); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AdRewardBonusMessages") + .HasColumnType("integer"); + + b.Property("AdRewardsClaimedToday") + .HasColumnType("integer"); + + b.Property("AiMemoryEnabled") + .HasColumnType("boolean"); + + b.Property("AiMessagesResetAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AiMessagesUsedThisMonth") + .HasColumnType("integer"); + + b.Property("AiSummaryEnabled") + .HasColumnType("boolean"); + + b.Property("ColorScheme") + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentStreak") + .HasColumnType("integer"); + + b.Property("DeactivatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("GoogleAccessToken") + .HasColumnType("text"); + + b.Property("GoogleCalendarAutoSyncEnabled") + .HasColumnType("boolean"); + + b.Property("GoogleCalendarAutoSyncStatus") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("GoogleCalendarLastSyncError") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("GoogleCalendarLastSyncedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GoogleCalendarSyncReconciledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GoogleRefreshToken") + .HasColumnType("text"); + + b.Property("HasCompletedOnboarding") + .HasColumnType("boolean"); + + b.Property("HasCompletedTour") + .HasColumnType("boolean"); + + b.Property("HasImportedCalendar") + .HasColumnType("boolean"); + + b.Property("IsDeactivated") + .HasColumnType("boolean"); + + b.Property("IsLifetimePro") + .HasColumnType("boolean"); + + b.Property("Language") + .HasColumnType("text"); + + b.Property("LastActiveDate") + .HasColumnType("date"); + + b.Property("LastAdRewardAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastFreezeAwardStreak") + .HasColumnType("integer"); + + b.Property("Level") + .HasColumnType("integer"); + + b.Property("LongestStreak") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Plan") + .HasColumnType("integer"); + + b.Property("PlanExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferralCode") + .HasColumnType("text"); + + b.Property("ReferralCouponId") + .HasColumnType("text"); + + b.Property("ReferredByUserId") + .HasColumnType("uuid"); + + b.Property("ScheduledDeletionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("StreakFreezesAccumulated") + .HasColumnType("integer"); + + b.Property("StripeCustomerId") + .HasColumnType("text"); + + b.Property("StripeSubscriptionId") + .HasColumnType("text"); + + b.Property("SubscriptionInterval") + .HasColumnType("integer"); + + b.Property("ThemePreference") + .HasColumnType("text"); + + b.Property("TimeZone") + .HasColumnType("text"); + + b.Property("TotalXp") + .HasColumnType("integer"); + + b.Property("TrialEndsAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WeekStartDay") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("ReferralCode") + .IsUnique() + .HasFilter("\"ReferralCode\" IS NOT NULL"); + + b.HasIndex("GoogleCalendarAutoSyncEnabled", "GoogleCalendarLastSyncedAt") + .HasFilter("\"GoogleCalendarAutoSyncEnabled\" = TRUE"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.UserAchievement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AchievementId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EarnedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "AchievementId") + .IsUnique(); + + b.ToTable("UserAchievements"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.UserFact", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Category") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExtractedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FactText") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "IsDeleted"); + + b.ToTable("UserFacts"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUsedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("UserSessions"); + }); + + modelBuilder.Entity("HabitGoals", b => + { + b.HasOne("Orbit.Domain.Entities.Goal", null) + .WithMany() + .HasForeignKey("GoalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany() + .HasForeignKey("HabitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("HabitTags", b => + { + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany() + .HasForeignKey("HabitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ApiKey", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ChecklistTemplate", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.GoalProgressLog", b => + { + b.HasOne("Orbit.Domain.Entities.Goal", null) + .WithMany("ProgressLogs") + .HasForeignKey("GoalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.GoogleCalendarSyncSuggestion", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Habit", b => + { + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany("Children") + .HasForeignKey("ParentHabitId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.HabitLog", b => + { + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany("Logs") + .HasForeignKey("HabitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.PushSubscription", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.StreakFreeze", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.UserSession", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Goal", b => + { + b.Navigation("ProgressLogs"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Habit", b => + { + b.Navigation("Children"); + + b.Navigation("Logs"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Orbit.Infrastructure/Migrations/20260415175239_AddStreakFreezeAccumulation.cs b/src/Orbit.Infrastructure/Migrations/20260415175239_AddStreakFreezeAccumulation.cs new file mode 100644 index 00000000..c25a197b --- /dev/null +++ b/src/Orbit.Infrastructure/Migrations/20260415175239_AddStreakFreezeAccumulation.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Orbit.Infrastructure.Migrations +{ + /// + public partial class AddStreakFreezeAccumulation : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "LastFreezeAwardStreak", + table: "Users", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "StreakFreezesAccumulated", + table: "Users", + type: "integer", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "LastFreezeAwardStreak", + table: "Users"); + + migrationBuilder.DropColumn( + name: "StreakFreezesAccumulated", + table: "Users"); + } + } +} diff --git a/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs b/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs index 64950cde..92604197 100644 --- a/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs +++ b/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs @@ -1200,6 +1200,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("LastAdRewardAt") .HasColumnType("timestamp with time zone"); + b.Property("LastFreezeAwardStreak") + .HasColumnType("integer"); + b.Property("Level") .HasColumnType("integer"); @@ -1228,6 +1231,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ScheduledDeletionAt") .HasColumnType("timestamp with time zone"); + b.Property("StreakFreezesAccumulated") + .HasColumnType("integer"); + b.Property("StripeCustomerId") .HasColumnType("text"); diff --git a/src/Orbit.Infrastructure/Services/UserStreakService.cs b/src/Orbit.Infrastructure/Services/UserStreakService.cs index 817094c5..a41f720a 100644 --- a/src/Orbit.Infrastructure/Services/UserStreakService.cs +++ b/src/Orbit.Infrastructure/Services/UserStreakService.cs @@ -51,6 +51,9 @@ public class UserStreakService( if (currentStreak > longestStreak) longestStreak = currentStreak; user.SetStreakState(currentStreak, longestStreak, lastActiveDate); + user.AwardStreakFreezeIfEligible( + AppConstants.MaxStreakFreezesAccumulated, + AppConstants.StreakDaysPerFreeze); return new UserStreakState(currentStreak, longestStreak, lastActiveDate); } @@ -195,6 +198,9 @@ private static UserStreakState CalendarFallback( } user.SetStreakState(currentStreak, longestStreak, lastActiveDate); + user.AwardStreakFreezeIfEligible( + AppConstants.MaxStreakFreezesAccumulated, + AppConstants.StreakDaysPerFreeze); return new UserStreakState(currentStreak, longestStreak, lastActiveDate); } } diff --git a/tests/Orbit.Application.Tests/Chat/Tools/ChecklistUserFactPlatformToolTests.cs b/tests/Orbit.Application.Tests/Chat/Tools/ChecklistUserFactPlatformToolTests.cs index 1c27c8c7..95cb2b73 100644 --- a/tests/Orbit.Application.Tests/Chat/Tools/ChecklistUserFactPlatformToolTests.cs +++ b/tests/Orbit.Application.Tests/Chat/Tools/ChecklistUserFactPlatformToolTests.cs @@ -361,7 +361,7 @@ public async Task GetGamificationOverviewTool_ReturnsSuccessForAllSections() mediator.Send(Arg.Any(), Arg.Any()) .Returns(Result.Success(new AchievementsResponse([]))); mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success(new StreakInfoResponse(7, 10, new DateOnly(2026, 4, 14), 0, 3, 3, false, []))); + .Returns(Result.Success(new StreakInfoResponse(7, 10, new DateOnly(2026, 4, 14), 0, 3, 3, false, [], 3, 3, 0, 3, false))); var tool = new GetGamificationOverviewTool(mediator); var result = await tool.ExecuteAsync(Parse("{}"), UserId, CancellationToken.None); @@ -408,7 +408,7 @@ public async Task ActivateStreakFreezeTool_ReturnsSuccess() { var mediator = Substitute.For(); mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success(new StreakFreezeResponse(1, new DateOnly(2026, 4, 14), 5))); + .Returns(Result.Success(new StreakFreezeResponse(1, new DateOnly(2026, 4, 14), 5, 0))); var tool = new ActivateStreakFreezeTool(mediator); var result = await tool.ExecuteAsync(Parse("{}"), UserId, CancellationToken.None); diff --git a/tests/Orbit.Application.Tests/Commands/Gamification/ActivateStreakFreezeCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Gamification/ActivateStreakFreezeCommandHandlerTests.cs index 8176f2df..64f90a6f 100644 --- a/tests/Orbit.Application.Tests/Commands/Gamification/ActivateStreakFreezeCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Gamification/ActivateStreakFreezeCommandHandlerTests.cs @@ -30,15 +30,22 @@ public ActivateStreakFreezeCommandHandlerTests() _userDateService.GetUserTodayAsync(UserId, Arg.Any()).Returns(Today); } - private static User CreateUserWithStreak(int streak = 5) + private static User CreateUserWithStreak(int streak = 5, int accumulatedFreezes = 1) { var user = User.Create("Test User", "test@example.com").Value; user.UpdateStreak(Today.AddDays(-1)); - // Build streak by calling UpdateStreak for consecutive days for (int i = streak - 1; i >= 1; i--) { user.UpdateStreak(Today.AddDays(-i)); } + // Simulate accumulated freezes without requiring the streak to be a multiple of 7. + while (user.StreakFreezesAccumulated < accumulatedFreezes) + { + if (!user.AwardStreakFreezeIfEligible(accumulatedFreezes, 1)) + { + break; + } + } return user; } @@ -156,7 +163,7 @@ public async Task Handle_MaxFreezesReached_ReturnsFailure() var result = await _handler.Handle(command, CancellationToken.None); result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("No streak freeze available"); + result.Error.Should().Contain("streak freezes this month"); } [Fact] diff --git a/tests/Orbit.Application.Tests/Queries/Gamification/GetStreakInfoQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Gamification/GetStreakInfoQueryHandlerTests.cs index fd554a45..0f2b881c 100644 --- a/tests/Orbit.Application.Tests/Queries/Gamification/GetStreakInfoQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/Gamification/GetStreakInfoQueryHandlerTests.cs @@ -47,8 +47,12 @@ public async Task Handle_UserFound_ReturnsStreakInfo() result.Value.CurrentStreak.Should().Be(0); result.Value.LongestStreak.Should().Be(0); result.Value.FreezesUsedThisMonth.Should().Be(0); - result.Value.FreezesAvailable.Should().Be(3); + result.Value.FreezesAvailable.Should().Be(0); + result.Value.FreezesAvailableToUse.Should().Be(0); result.Value.MaxFreezesPerMonth.Should().Be(3); + result.Value.StreakFreezesAccumulated.Should().Be(0); + result.Value.MaxStreakFreezesAccumulated.Should().Be(3); + result.Value.CanEarnMore.Should().BeTrue(); result.Value.IsFrozenToday.Should().BeFalse(); result.Value.RecentFreezeDates.Should().BeEmpty(); } @@ -89,7 +93,9 @@ public async Task Handle_WithRecentFreezes_CalculatesCorrectly() result.IsSuccess.Should().BeTrue(); result.Value.FreezesUsedThisMonth.Should().Be(2); - result.Value.FreezesAvailable.Should().Be(1); + // FreezesAvailable now reflects min(accumulated, monthly remaining). User hasn't + // earned any freezes, so available is 0 despite monthly room. + result.Value.FreezesAvailable.Should().Be(0); result.Value.RecentFreezeDates.Should().HaveCount(2); result.Value.IsFrozenToday.Should().BeFalse(); } diff --git a/tests/Orbit.Infrastructure.Tests/Mcp/GamificationToolsTests.cs b/tests/Orbit.Infrastructure.Tests/Mcp/GamificationToolsTests.cs index 68513dd8..265a6199 100644 --- a/tests/Orbit.Infrastructure.Tests/Mcp/GamificationToolsTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Mcp/GamificationToolsTests.cs @@ -102,7 +102,8 @@ public async Task GetStreakInfo_Success_ReturnsFormattedStreak() var streak = new StreakInfoResponse( 15, 30, new DateOnly(2026, 4, 2), 1, 1, 2, false, - [new DateOnly(2026, 3, 15)]); + [new DateOnly(2026, 3, 15)], + 1, 3, 7, 1, true); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Result.Success(streak)); @@ -128,7 +129,7 @@ public async Task GetStreakInfo_Failure_ReturnsError() [Fact] public async Task ActivateStreakFreeze_Success_ReturnsActivatedMessage() { - var response = new StreakFreezeResponse(1, new DateOnly(2026, 4, 3), 15); + var response = new StreakFreezeResponse(1, new DateOnly(2026, 4, 3), 15, 0); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Result.Success(response));