From 89dcb8b761e623eb1d0fafba53e2022423b89582 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Thu, 4 Jun 2026 14:50:15 -0300 Subject: [PATCH 1/3] feat(api): auto-activate streak freeze on inactive day (#108) Add a dedicated StreakFreezeAutoActivationService BackgroundService that auto-activates a streak freeze for a Pro user who held an active streak but logged nothing on their fully-elapsed local "yesterday". - Mirrors SlipAlertSchedulerService / HabitDueDateAdvancementService: poll interval, conservative UTC pre-filter, authoritative per-user TimeZoneHelper local-yesterday guard, single SaveChanges per tick. - Pro-only (matches ActivateStreakFreezeCommand). Spends one freeze per missed day, bounded by MaxStreakFreezesAccumulated (inventory) and MaxStreakFreezesPerMonth (monthly). - Presence-based: inserting a StreakFreeze row for the missed date preserves the streak on the next on-read RecalculateAsync; no direct streak mutation. - Idempotent: new SentStreakFreezeAlert guard entity (unique UserId+FrozenDate) plus the existing StreakFreeze unique index, both re-checked before spending. - Notifies via in-app Notification + push (IPushNotificationService); localized copy via LocaleHelper, mirroring GoalDeadlineNotificationService. - EF migration AddSentStreakFreezeAlert; account reset purges the guard table; registered in ServiceCollectionExtensions and BackgroundServiceHealthCheck. Unit tests cover the eligibility predicate, local-yesterday computation, interval default, notification copy, and the guard entity. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Extensions/ServiceCollectionExtensions.cs | 1 + .../Entities/SentStreakFreezeAlert.cs | 22 + ...73818_AddSentStreakFreezeAlert.Designer.cs | 1601 +++++++++++++++++ ...20260604173818_AddSentStreakFreezeAlert.cs | 48 + .../Migrations/OrbitDbContextModelSnapshot.cs | 32 + .../Persistence/AccountResetRepository.cs | 4 + .../Persistence/OrbitDbContext.cs | 7 + .../Services/BackgroundServiceHealthCheck.cs | 1 + .../StreakFreezeAutoActivationService.cs | 208 +++ .../StreakFreezeAutoActivationServiceTests.cs | 203 +++ 10 files changed, 2127 insertions(+) create mode 100644 src/Orbit.Domain/Entities/SentStreakFreezeAlert.cs create mode 100644 src/Orbit.Infrastructure/Migrations/20260604173818_AddSentStreakFreezeAlert.Designer.cs create mode 100644 src/Orbit.Infrastructure/Migrations/20260604173818_AddSentStreakFreezeAlert.cs create mode 100644 src/Orbit.Infrastructure/Services/StreakFreezeAutoActivationService.cs create mode 100644 tests/Orbit.Infrastructure.Tests/Services/StreakFreezeAutoActivationServiceTests.cs diff --git a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs index a283f1e0..f400b984 100644 --- a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs +++ b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs @@ -345,6 +345,7 @@ public static WebApplicationBuilder AddOrbitInfrastructure(this WebApplicationBu builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); + builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); diff --git a/src/Orbit.Domain/Entities/SentStreakFreezeAlert.cs b/src/Orbit.Domain/Entities/SentStreakFreezeAlert.cs new file mode 100644 index 00000000..b044ca0f --- /dev/null +++ b/src/Orbit.Domain/Entities/SentStreakFreezeAlert.cs @@ -0,0 +1,22 @@ +using Orbit.Domain.Common; + +namespace Orbit.Domain.Entities; + +public class SentStreakFreezeAlert : Entity +{ + public Guid UserId { get; private set; } + public DateOnly FrozenDate { get; private set; } + public DateTime SentAtUtc { get; private set; } + + private SentStreakFreezeAlert() { } + + public static SentStreakFreezeAlert Create(Guid userId, DateOnly frozenDate) + { + return new SentStreakFreezeAlert + { + UserId = userId, + FrozenDate = frozenDate, + SentAtUtc = DateTime.UtcNow + }; + } +} diff --git a/src/Orbit.Infrastructure/Migrations/20260604173818_AddSentStreakFreezeAlert.Designer.cs b/src/Orbit.Infrastructure/Migrations/20260604173818_AddSentStreakFreezeAlert.Designer.cs new file mode 100644 index 00000000..e287d87e --- /dev/null +++ b/src/Orbit.Infrastructure/Migrations/20260604173818_AddSentStreakFreezeAlert.Designer.cs @@ -0,0 +1,1601 @@ +// +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("20260604173818_AddSentStreakFreezeAlert")] + partial class AddSentStreakFreezeAlert + { + /// + 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("Emoji") + .HasColumnType("text"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("FrequencyQuantity") + .HasColumnType("integer"); + + b.Property("FrequencyUnit") + .HasColumnType("integer"); + + b.Property("GoogleEventId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsBadHabit") + .HasColumnType("boolean"); + + b.Property("IsCompleted") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsFlexible") + .HasColumnType("boolean"); + + b.Property("IsGeneral") + .HasColumnType("boolean"); + + b.Property("OriginalDayOfMonth") + .HasColumnType("integer"); + + b.Property("ParentHabitId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ReminderEnabled") + .HasColumnType("boolean"); + + b.Property("ReminderTimes") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[15]'::jsonb"); + + b.Property("ScheduledReminders") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("SlipAlertEnabled") + .HasColumnType("boolean"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ParentHabitId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "GoogleEventId") + .IsUnique() + .HasFilter("\"GoogleEventId\" IS NOT NULL AND \"IsDeleted\" = FALSE"); + + b.HasIndex("UserId", "IsDeleted"); + + b.ToTable("Habits"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.HabitLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("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.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.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.SentStreakFreezeAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("FrozenDate") + .HasColumnType("date"); + + b.Property("SentAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "FrozenDate") + .IsUnique(); + + b.ToTable("SentStreakFreezeAlerts"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.StreakFreeze", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UsedOnDate") + .HasColumnType("date"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "UsedOnDate") + .IsUnique(); + + b.ToTable("StreakFreezes"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Color") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "IsDeleted"); + + b.HasIndex("UserId", "Name") + .IsUnique(); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AdRewardBonusMessages") + .HasColumnType("integer"); + + b.Property("AdRewardsClaimedToday") + .HasColumnType("integer"); + + b.Property("AiMemoryEnabled") + .HasColumnType("boolean"); + + b.Property("AiMessagesResetAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AiMessagesUsedThisMonth") + .HasColumnType("integer"); + + b.Property("AiSummaryEnabled") + .HasColumnType("boolean"); + + b.Property("ColorScheme") + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentStreak") + .HasColumnType("integer"); + + b.Property("DeactivatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("GoogleAccessToken") + .HasColumnType("text"); + + b.Property("GoogleCalendarAutoSyncEnabled") + .HasColumnType("boolean"); + + b.Property("GoogleCalendarAutoSyncStatus") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("GoogleCalendarLastSyncError") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("GoogleCalendarLastSyncedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GoogleCalendarSyncReconciledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GoogleRefreshToken") + .HasColumnType("text"); + + b.Property("HasCompletedOnboarding") + .HasColumnType("boolean"); + + b.Property("HasCompletedTour") + .HasColumnType("boolean"); + + b.Property("HasImportedCalendar") + .HasColumnType("boolean"); + + b.Property("IsDeactivated") + .HasColumnType("boolean"); + + b.Property("IsLifetimePro") + .HasColumnType("boolean"); + + b.Property("Language") + .HasColumnType("text"); + + b.Property("LastActiveDate") + .HasColumnType("date"); + + b.Property("LastAdRewardAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastAdRewardLocalDate") + .HasColumnType("date"); + + b.Property("LastFreezeAwardStreak") + .HasColumnType("integer"); + + b.Property("Level") + .HasColumnType("integer"); + + b.Property("LongestStreak") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Plan") + .HasColumnType("integer"); + + b.Property("PlanExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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.PendingClarification", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.PushSubscription", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.SentStreakFreezeAlert", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.StreakFreeze", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.UserSession", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Goal", b => + { + b.Navigation("ProgressLogs"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Habit", b => + { + b.Navigation("Children"); + + b.Navigation("Logs"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Orbit.Infrastructure/Migrations/20260604173818_AddSentStreakFreezeAlert.cs b/src/Orbit.Infrastructure/Migrations/20260604173818_AddSentStreakFreezeAlert.cs new file mode 100644 index 00000000..d177294d --- /dev/null +++ b/src/Orbit.Infrastructure/Migrations/20260604173818_AddSentStreakFreezeAlert.cs @@ -0,0 +1,48 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Orbit.Infrastructure.Migrations +{ + /// + public partial class AddSentStreakFreezeAlert : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "SentStreakFreezeAlerts", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + FrozenDate = table.Column(type: "date", nullable: false), + SentAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SentStreakFreezeAlerts", x => x.Id); + table.ForeignKey( + name: "FK_SentStreakFreezeAlerts_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_SentStreakFreezeAlerts_UserId_FrozenDate", + table: "SentStreakFreezeAlerts", + columns: new[] { "UserId", "FrozenDate" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "SentStreakFreezeAlerts"); + } + } +} diff --git a/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs b/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs index 0bb23c38..df386208 100644 --- a/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs +++ b/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs @@ -1107,6 +1107,29 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("SentSlipAlerts"); }); + modelBuilder.Entity("Orbit.Domain.Entities.SentStreakFreezeAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("FrozenDate") + .HasColumnType("date"); + + b.Property("SentAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "FrozenDate") + .IsUnique(); + + b.ToTable("SentStreakFreezeAlerts"); + }); + modelBuilder.Entity("Orbit.Domain.Entities.StreakFreeze", b => { b.Property("Id") @@ -1531,6 +1554,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("Orbit.Domain.Entities.SentStreakFreezeAlert", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("Orbit.Domain.Entities.StreakFreeze", b => { b.HasOne("Orbit.Domain.Entities.User", null) diff --git a/src/Orbit.Infrastructure/Persistence/AccountResetRepository.cs b/src/Orbit.Infrastructure/Persistence/AccountResetRepository.cs index fd1487d4..dd902cc8 100644 --- a/src/Orbit.Infrastructure/Persistence/AccountResetRepository.cs +++ b/src/Orbit.Infrastructure/Persistence/AccountResetRepository.cs @@ -63,6 +63,10 @@ await context.StreakFreezes .Where(sf => sf.UserId == userId) .ExecuteDeleteAsync(cancellationToken); + await context.SentStreakFreezeAlerts + .Where(a => a.UserId == userId) + .ExecuteDeleteAsync(cancellationToken); + await context.ApiKeys .Where(k => k.UserId == userId) .ExecuteDeleteAsync(cancellationToken); diff --git a/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs b/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs index 1da9700a..2fb81d11 100644 --- a/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs +++ b/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs @@ -35,6 +35,7 @@ public OrbitDbContext(DbContextOptions options, IEncryptionServi public DbSet PushSubscriptions => Set(); public DbSet SentReminders => Set(); public DbSet SentSlipAlerts => Set(); + public DbSet SentStreakFreezeAlerts => Set(); public DbSet Notifications => Set(); public DbSet Goals => Set(); public DbSet GoalProgressLogs => Set(); @@ -102,6 +103,12 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasIndex(a => new { a.HabitId, a.WeekStart }).IsUnique(); }); + modelBuilder.Entity(entity => + { + entity.HasIndex(a => new { a.UserId, a.FrozenDate }).IsUnique(); + entity.HasOne().WithMany().HasForeignKey(a => a.UserId).OnDelete(DeleteBehavior.Cascade); + }); + modelBuilder.Entity(entity => { entity.HasIndex(n => new { n.UserId, n.IsRead }); diff --git a/src/Orbit.Infrastructure/Services/BackgroundServiceHealthCheck.cs b/src/Orbit.Infrastructure/Services/BackgroundServiceHealthCheck.cs index cf789987..172936c1 100644 --- a/src/Orbit.Infrastructure/Services/BackgroundServiceHealthCheck.cs +++ b/src/Orbit.Infrastructure/Services/BackgroundServiceHealthCheck.cs @@ -13,6 +13,7 @@ public class BackgroundServiceHealthCheck : IHealthCheck ["GoalDeadlineNotification"] = TimeSpan.FromMinutes(90), ["SlipAlertScheduler"] = TimeSpan.FromMinutes(15), ["HabitDueDateAdvancement"] = TimeSpan.FromMinutes(90), + ["StreakFreezeAutoActivation"] = TimeSpan.FromMinutes(180), ["AccountDeletion"] = TimeSpan.FromHours(72), ["SyncCleanup"] = TimeSpan.FromHours(48) }; diff --git a/src/Orbit.Infrastructure/Services/StreakFreezeAutoActivationService.cs b/src/Orbit.Infrastructure/Services/StreakFreezeAutoActivationService.cs new file mode 100644 index 00000000..d0d53320 --- /dev/null +++ b/src/Orbit.Infrastructure/Services/StreakFreezeAutoActivationService.cs @@ -0,0 +1,208 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Orbit.Application.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; +using Orbit.Infrastructure.Persistence; + +namespace Orbit.Infrastructure.Services; + +/// +/// Auto-activates a streak freeze for a Pro user who held an active streak but logged +/// nothing on their fully-elapsed local "yesterday". Inserting a +/// row for the missed date is sufficient to preserve the streak: the presence-based +/// resolver in treats any date carrying a freeze as covered, +/// so the next on-read RecalculateAsync keeps CurrentStreak intact without mutating it here. +/// Idempotency is enforced by two unique guards — the StreakFreeze (UserId, UsedOnDate) index +/// and the SentStreakFreezeAlert (UserId, FrozenDate) index — both re-checked before spending. +/// +public partial class StreakFreezeAutoActivationService( + IServiceScopeFactory scopeFactory, + ILogger logger, + IConfiguration configuration) : BackgroundService +{ + private readonly TimeSpan _interval = TimeSpan.FromMinutes( + configuration.GetValue("BackgroundServices:StreakFreezeIntervalMinutes", 60)); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + LogServiceStarted(logger); + + try + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + await ActivateMissedDayFreezes(stoppingToken); + BackgroundServiceHealthCheck.RecordTick("StreakFreezeAutoActivation"); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + LogServiceError(logger, ex); + } + + await Task.Delay(_interval, stoppingToken); + } + } + finally + { + LogServiceStopped(logger); + } + } + + private async Task ActivateMissedDayFreezes(CancellationToken ct) + { + using var scope = scopeFactory.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var pushService = scope.ServiceProvider.GetRequiredService(); + + // Conservative UTC pre-filter: a user can only have a fully-elapsed missed day if their + // last active date is already before UTC yesterday. The per-user local-yesterday guard + // below is the authoritative check. HasProAccess is computed (not mapped) so it is gated + // in memory per user rather than in SQL. + var utcYesterday = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-1)); + var candidates = await dbContext.Users + .Where(u => u.CurrentStreak > 0 + && u.StreakFreezesAccumulated > 0 + && u.LastActiveDate != null + && u.LastActiveDate < utcYesterday) + .ToListAsync(ct); + + if (candidates.Count == 0) return; + + var candidateIds = candidates.Select(u => u.Id).ToList(); + + var monthFloor = utcYesterday.AddDays(-1).AddMonths(-1); + var freezesByUser = (await dbContext.StreakFreezes + .Where(f => candidateIds.Contains(f.UserId) && f.UsedOnDate >= monthFloor) + .ToListAsync(ct)) + .GroupBy(f => f.UserId) + .ToDictionary(g => g.Key, g => g.ToList()); + + var guardedByUser = (await dbContext.SentStreakFreezeAlerts + .Where(a => candidateIds.Contains(a.UserId) && a.FrozenDate >= monthFloor) + .ToListAsync(ct)) + .GroupBy(a => a.UserId) + .ToDictionary(g => g.Key, g => g.Select(a => a.FrozenDate).ToHashSet()); + + var completionsByUser = await LoadRecentCompletionsAsync(dbContext, candidateIds, monthFloor, ct); + + var anyChanges = false; + foreach (var user in candidates) + { + anyChanges |= await ProcessUserAsync(user, freezesByUser, guardedByUser, completionsByUser, pushService, dbContext, ct); + } + + if (anyChanges) + await dbContext.SaveChangesAsync(ct); + } + + private static async Task>> LoadRecentCompletionsAsync( + OrbitDbContext dbContext, List userIds, DateOnly since, CancellationToken ct) + { + var habitOwners = await dbContext.Habits + .Where(h => userIds.Contains(h.UserId) && !h.IsBadHabit) + .Select(h => new { h.Id, h.UserId }) + .ToListAsync(ct); + + var ownerByHabit = habitOwners.ToDictionary(h => h.Id, h => h.UserId); + var habitIds = habitOwners.Select(h => h.Id).ToList(); + if (habitIds.Count == 0) return new Dictionary>(); + + var logs = await dbContext.HabitLogs + .Where(l => habitIds.Contains(l.HabitId) && l.Value > 0 && l.Date >= since) + .Select(l => new { l.HabitId, l.Date }) + .ToListAsync(ct); + + var completions = new Dictionary>(); + foreach (var log in logs) + { + if (!ownerByHabit.TryGetValue(log.HabitId, out var ownerId)) continue; + if (!completions.TryGetValue(ownerId, out var dates)) + { + dates = []; + completions[ownerId] = dates; + } + dates.Add(log.Date); + } + return completions; + } + + private async Task ProcessUserAsync( + User user, + Dictionary> freezesByUser, + Dictionary> guardedByUser, + Dictionary> completionsByUser, + IPushNotificationService pushService, + OrbitDbContext dbContext, + CancellationToken ct) + { + if (!user.HasProAccess) return false; + + var tz = TimeZoneHelper.FindTimeZone(user.TimeZone, logger, user.Id); + var userToday = DateOnly.FromDateTime(TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, tz)); + var missedDate = userToday.AddDays(-1); + + // Authoritative local guard: the missed day must be fully elapsed (strictly before today) + // and the user must not already be credited as active on or after it. + if (user.LastActiveDate is null || user.LastActiveDate >= missedDate) return false; + + var existingFreezes = freezesByUser.GetValueOrDefault(user.Id) ?? []; + if (existingFreezes.Any(f => f.UsedOnDate == missedDate)) return false; + + var guardedDates = guardedByUser.GetValueOrDefault(user.Id) ?? []; + if (guardedDates.Contains(missedDate)) return false; + + var completions = completionsByUser.GetValueOrDefault(user.Id) ?? []; + if (completions.Contains(missedDate)) return false; + + var monthStart = new DateOnly(missedDate.Year, missedDate.Month, 1); + var monthEnd = monthStart.AddMonths(1); + var freezesThisMonth = existingFreezes.Count(f => f.UsedOnDate >= monthStart && f.UsedOnDate < monthEnd); + if (freezesThisMonth >= AppConstants.MaxStreakFreezesPerMonth) return false; + + var consume = user.ConsumeStreakFreeze(); + if (consume.IsFailure) return false; + + var freeze = StreakFreeze.Create(user.Id, missedDate); + dbContext.StreakFreezes.Add(freeze); + existingFreezes.Add(freeze); + freezesByUser[user.Id] = existingFreezes; + + dbContext.SentStreakFreezeAlerts.Add(SentStreakFreezeAlert.Create(user.Id, missedDate)); + + var (title, body) = BuildNotification(user.CurrentStreak, user.Language ?? "en"); + dbContext.Notifications.Add(Notification.Create(user.Id, title, body, StreakUrl)); + await pushService.SendToUserAsync(user.Id, title, body, StreakUrl, ct); + + if (logger.IsEnabled(LogLevel.Information)) + LogFreezeActivated(logger, user.Id, missedDate); + return true; + } + + private const string StreakUrl = "/streak"; + + internal static (string Title, string Body) BuildNotification(int currentStreak, string lang) + { + var isPt = LocaleHelper.IsPortuguese(lang); + return isPt + ? ("Sequência protegida", $"Usamos um congelamento para manter sua sequência de {currentStreak} dias depois de um dia sem registro.") + : ("Streak protected", $"We used a freeze to keep your {currentStreak}-day streak alive after a day with no check-ins."); + } + + [LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "StreakFreezeAutoActivationService started")] + private static partial void LogServiceStarted(ILogger logger); + + [LoggerMessage(EventId = 2, Level = LogLevel.Information, Message = "StreakFreezeAutoActivationService stopped")] + private static partial void LogServiceStopped(ILogger logger); + + [LoggerMessage(EventId = 3, Level = LogLevel.Error, Message = "Error in streak freeze auto-activation")] + private static partial void LogServiceError(ILogger logger, Exception ex); + + [LoggerMessage(EventId = 4, Level = LogLevel.Information, Message = "Auto-activated streak freeze for user {UserId} on {FrozenDate}")] + private static partial void LogFreezeActivated(ILogger logger, Guid userId, DateOnly frozenDate); +} diff --git a/tests/Orbit.Infrastructure.Tests/Services/StreakFreezeAutoActivationServiceTests.cs b/tests/Orbit.Infrastructure.Tests/Services/StreakFreezeAutoActivationServiceTests.cs new file mode 100644 index 00000000..241ea0dd --- /dev/null +++ b/tests/Orbit.Infrastructure.Tests/Services/StreakFreezeAutoActivationServiceTests.cs @@ -0,0 +1,203 @@ +using System.Reflection; +using FluentAssertions; +using NSubstitute; +using Orbit.Application.Common; +using Orbit.Domain.Entities; +using Orbit.Infrastructure.Services; + +namespace Orbit.Infrastructure.Tests.Services; + +/// +/// Tests the pure pieces of StreakFreezeAutoActivationService: the localized notification +/// copy, the interval default, the SentStreakFreezeAlert guard entity, and the eligibility +/// predicate (replicated here and asserted across include/exclude cases). The background +/// loop and DB interactions are integration concerns. +/// +public class StreakFreezeAutoActivationServiceTests +{ + private static readonly BindingFlags PrivateStatic = + BindingFlags.NonPublic | BindingFlags.Static; + + // --- Notification copy --- + + [Fact] + public void BuildNotification_English_MentionsStreakLengthAndFreeze() + { + var (title, body) = InvokeBuildNotification(14, "en"); + + title.Should().Be("Streak protected"); + body.Should().Contain("14-day"); + body.Should().Contain("freeze"); + } + + [Fact] + public void BuildNotification_Portuguese_UsesPortugueseCopy() + { + var (title, body) = InvokeBuildNotification(14, "pt-BR"); + + title.Should().Be("Sequência protegida"); + body.Should().Contain("14 dias"); + body.Should().Contain("congelamento"); + } + + [Fact] + public void BuildNotification_UnknownLanguage_FallsBackToEnglish() + { + var (title, _) = InvokeBuildNotification(3, "fr"); + + title.Should().Be("Streak protected"); + } + + // --- Interval default --- + + [Fact] + public void IntervalDefault_Is60Minutes() + { + // The service reads BackgroundServices:StreakFreezeIntervalMinutes with a default of 60. + // GetValue is invoked at field init; assert the documented default constant directly + // by constructing with empty configuration. + var defaultMinutes = GetConfiguredIntervalMinutesDefault(); + defaultMinutes.Should().Be(60); + } + + // --- SentStreakFreezeAlert entity --- + + [Fact] + public void SentStreakFreezeAlert_Create_SetsFieldsCorrectly() + { + var userId = Guid.NewGuid(); + var frozenDate = new DateOnly(2026, 6, 3); + + var alert = SentStreakFreezeAlert.Create(userId, frozenDate); + + alert.UserId.Should().Be(userId); + alert.FrozenDate.Should().Be(frozenDate); + alert.SentAtUtc.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)); + } + + // --- Local-yesterday computation --- + + [Fact] + public void MissedDate_IsLocalToday_MinusOneDay() + { + var userToday = new DateOnly(2026, 6, 4); + var missedDate = userToday.AddDays(-1); + + missedDate.Should().Be(new DateOnly(2026, 6, 3)); + } + + // --- Eligibility predicate (replicates ProcessUserAsync guards) --- + + private static bool IsEligible(EligibilityCase c) + { + if (!c.HasProAccess) return false; + if (c.LastActiveDate is null || c.LastActiveDate >= c.MissedDate) return false; + if (c.HasFreezeOnMissedDate) return false; + if (c.HasGuardOnMissedDate) return false; + if (c.HasCompletionOnMissedDate) return false; + if (c.FreezesThisMonth >= AppConstants.MaxStreakFreezesPerMonth) return false; + if (c.StreakFreezesAccumulated <= 0) return false; + return true; + } + + private sealed record EligibilityCase + { + public bool HasProAccess { get; init; } = true; + public DateOnly MissedDate { get; init; } = new(2026, 6, 3); + public DateOnly? LastActiveDate { get; init; } = new(2026, 6, 2); + public bool HasFreezeOnMissedDate { get; init; } + public bool HasGuardOnMissedDate { get; init; } + public bool HasCompletionOnMissedDate { get; init; } + public int FreezesThisMonth { get; init; } + public int StreakFreezesAccumulated { get; init; } = 2; + } + + [Fact] + public void Eligibility_AllConditionsMet_IsEligible() + { + IsEligible(new EligibilityCase()).Should().BeTrue(); + } + + [Fact] + public void Eligibility_NotPro_Excluded() + { + IsEligible(new EligibilityCase { HasProAccess = false }).Should().BeFalse(); + } + + [Fact] + public void Eligibility_LastActiveOnMissedDate_Excluded() + { + // User was credited active on the "missed" day -> not actually missed. + IsEligible(new EligibilityCase { LastActiveDate = new DateOnly(2026, 6, 3) }) + .Should().BeFalse(); + } + + [Fact] + public void Eligibility_LastActiveAfterMissedDate_Excluded() + { + IsEligible(new EligibilityCase { LastActiveDate = new DateOnly(2026, 6, 4) }) + .Should().BeFalse(); + } + + [Fact] + public void Eligibility_NoLastActiveDate_Excluded() + { + IsEligible(new EligibilityCase { LastActiveDate = null }).Should().BeFalse(); + } + + [Fact] + public void Eligibility_FreezeAlreadyOnMissedDate_Excluded() + { + IsEligible(new EligibilityCase { HasFreezeOnMissedDate = true }).Should().BeFalse(); + } + + [Fact] + public void Eligibility_GuardAlreadyOnMissedDate_Excluded() + { + IsEligible(new EligibilityCase { HasGuardOnMissedDate = true }).Should().BeFalse(); + } + + [Fact] + public void Eligibility_CompletionOnMissedDate_Excluded() + { + IsEligible(new EligibilityCase { HasCompletionOnMissedDate = true }).Should().BeFalse(); + } + + [Fact] + public void Eligibility_MonthlyCapReached_Excluded() + { + IsEligible(new EligibilityCase { FreezesThisMonth = AppConstants.MaxStreakFreezesPerMonth }) + .Should().BeFalse(); + } + + [Fact] + public void Eligibility_NoInventory_Excluded() + { + IsEligible(new EligibilityCase { StreakFreezesAccumulated = 0 }).Should().BeFalse(); + } + + // --- Helpers --- + + private static (string Title, string Body) InvokeBuildNotification(int currentStreak, string lang) + { + var method = typeof(StreakFreezeAutoActivationService) + .GetMethod("BuildNotification", PrivateStatic)!; + return ((string, string))method.Invoke(null, [currentStreak, lang])!; + } + + private static int GetConfiguredIntervalMinutesDefault() + { + // Construct the service with an empty configuration so the field initializer resolves + // to the hard-coded default, then read back the private _interval field. + var configuration = new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(); + var service = (StreakFreezeAutoActivationService)Activator.CreateInstance( + typeof(StreakFreezeAutoActivationService), + Substitute.For(), + Substitute.For>(), + configuration)!; + var interval = (TimeSpan)typeof(StreakFreezeAutoActivationService) + .GetField("_interval", BindingFlags.NonPublic | BindingFlags.Instance)! + .GetValue(service)!; + return (int)interval.TotalMinutes; + } +} From 803ea4cbe2932686ffba501ed03fb866f5f87f64 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Thu, 4 Jun 2026 15:50:17 -0300 Subject: [PATCH 2/3] refactor(api): remove manual streak-freeze activation (auto-only) (#108) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Controllers/GamificationController.cs | 15 -- .../Extensions/ServiceCollectionExtensions.cs | 1 - src/Orbit.Api/Mcp/Tools/GamificationTools.cs | 19 -- .../Tools/Implementations/PlatformTools.cs | 21 -- .../Commands/ActivateStreakFreezeCommand.cs | 99 --------- .../ActivateStreakFreezeCommandValidator.cs | 12 - src/Orbit.Domain/Models/AgentContracts.cs | 3 - .../Services/AgentCatalogService.cs | 19 +- .../ChecklistUserFactPlatformToolTests.cs | 33 --- ...ActivateStreakFreezeCommandHandlerTests.cs | 208 ------------------ ...tivateStreakFreezeCommandValidatorTests.cs | 27 --- .../GamificationControllerTests.cs | 25 --- .../Mcp/GamificationToolsTests.cs | 26 --- 13 files changed, 2 insertions(+), 506 deletions(-) delete mode 100644 src/Orbit.Application/Gamification/Commands/ActivateStreakFreezeCommand.cs delete mode 100644 src/Orbit.Application/Gamification/Validators/ActivateStreakFreezeCommandValidator.cs delete mode 100644 tests/Orbit.Application.Tests/Commands/Gamification/ActivateStreakFreezeCommandHandlerTests.cs delete mode 100644 tests/Orbit.Application.Tests/Validators/ActivateStreakFreezeCommandValidatorTests.cs diff --git a/src/Orbit.Api/Controllers/GamificationController.cs b/src/Orbit.Api/Controllers/GamificationController.cs index eb27367e..f49473ef 100644 --- a/src/Orbit.Api/Controllers/GamificationController.cs +++ b/src/Orbit.Api/Controllers/GamificationController.cs @@ -2,7 +2,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Orbit.Api.Extensions; -using Orbit.Application.Gamification.Commands; using Orbit.Application.Gamification.Queries; namespace Orbit.Api.Controllers; @@ -51,18 +50,4 @@ public async Task GetStreakInfo(CancellationToken cancellationTok ? Ok(result.Value) : BadRequest(new { error = result.Error }); } - - [HttpPost("streak/freeze")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - public async Task ActivateStreakFreeze(CancellationToken cancellationToken) - { - var command = new ActivateStreakFreezeCommand(HttpContext.GetUserId()); - var result = await mediator.Send(command, cancellationToken); - - return result.IsSuccess - ? Ok(result.Value) - : BadRequest(new { error = result.Error }); - } } diff --git a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs index f400b984..31863f09 100644 --- a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs +++ b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs @@ -188,7 +188,6 @@ public static WebApplicationBuilder AddOrbitAiServices(this WebApplicationBuilde builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); - builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/Orbit.Api/Mcp/Tools/GamificationTools.cs b/src/Orbit.Api/Mcp/Tools/GamificationTools.cs index e8ae52d0..60d55c42 100644 --- a/src/Orbit.Api/Mcp/Tools/GamificationTools.cs +++ b/src/Orbit.Api/Mcp/Tools/GamificationTools.cs @@ -2,7 +2,6 @@ using System.Security.Claims; using MediatR; using ModelContextProtocol.Server; -using Orbit.Application.Gamification.Commands; using Orbit.Application.Gamification.Queries; namespace Orbit.Api.Mcp.Tools; @@ -91,24 +90,6 @@ public async Task GetStreakInfo( (s.RecentFreezeDates.Count > 0 ? $"Recent freeze dates: {string.Join(", ", s.RecentFreezeDates)}" : ""); } - [McpServerTool(Name = "activate_streak_freeze"), Description("Activate a streak freeze to protect the current streak. Limited to 2 per month.")] - public async Task ActivateStreakFreeze( - ClaimsPrincipal user, - CancellationToken cancellationToken = default) - { - var userId = GetUserId(user); - var command = new ActivateStreakFreezeCommand(userId); - var result = await mediator.Send(command, cancellationToken); - - if (result.IsFailure) - return $"Error: {result.Error}"; - - var r = result.Value; - return $"Streak freeze activated for {r.FrozenDate}\n" + - $"Current streak preserved: {r.CurrentStreak} days\n" + - $"Freezes remaining this month: {r.FreezesRemainingThisMonth}"; - } - private static Guid GetUserId(ClaimsPrincipal user) { var claim = user.FindFirst(ClaimTypes.NameIdentifier)?.Value diff --git a/src/Orbit.Application/Chat/Tools/Implementations/PlatformTools.cs b/src/Orbit.Application/Chat/Tools/Implementations/PlatformTools.cs index 1283864d..3b5ef23d 100644 --- a/src/Orbit.Application/Chat/Tools/Implementations/PlatformTools.cs +++ b/src/Orbit.Application/Chat/Tools/Implementations/PlatformTools.cs @@ -4,7 +4,6 @@ using Orbit.Application.ApiKeys.Commands; using Orbit.Application.ApiKeys.Queries; using Orbit.Application.Chat.Tools; -using Orbit.Application.Gamification.Commands; using Orbit.Application.Gamification.Queries; using Orbit.Application.Referrals.Queries; using Orbit.Application.Subscriptions.Commands; @@ -67,26 +66,6 @@ public async Task ExecuteAsync(JsonElement args, Guid userId, Cancel } } -public class ActivateStreakFreezeTool(IMediator mediator) : IAiTool -{ - public string Name => "activate_streak_freeze"; - public string Description => "Activate one available streak freeze for the user."; - - public object GetParameterSchema() => new - { - type = JsonSchemaTypes.Object, - properties = new { } - }; - - public async Task ExecuteAsync(JsonElement args, Guid userId, CancellationToken ct) - { - var result = await mediator.Send(new ActivateStreakFreezeCommand(userId), ct); - return result.IsSuccess - ? new ToolResult(true, EntityId: userId.ToString(), EntityName: "Activated streak freeze", Payload: result.Value) - : new ToolResult(false, EntityId: userId.ToString(), Error: result.Error); - } -} - public class GetReferralOverviewTool(IMediator mediator) : IAiTool { public string Name => "get_referral_overview"; diff --git a/src/Orbit.Application/Gamification/Commands/ActivateStreakFreezeCommand.cs b/src/Orbit.Application/Gamification/Commands/ActivateStreakFreezeCommand.cs deleted file mode 100644 index 41d932a6..00000000 --- a/src/Orbit.Application/Gamification/Commands/ActivateStreakFreezeCommand.cs +++ /dev/null @@ -1,99 +0,0 @@ -using MediatR; -using Microsoft.EntityFrameworkCore; -using Orbit.Application.Common; -using Orbit.Domain.Common; -using Orbit.Domain.Entities; -using Orbit.Domain.Interfaces; - -namespace Orbit.Application.Gamification.Commands; - -public record StreakFreezeResponse( - int FreezesRemainingThisMonth, - DateOnly FrozenDate, - int CurrentStreak, - int StreakFreezesAccumulated); - -public record ActivateStreakFreezeCommand(Guid UserId) : IRequest>; - -public class ActivateStreakFreezeCommandHandler( - IGenericRepository userRepository, - IGenericRepository streakFreezeRepository, - IGenericRepository habitLogRepository, - IGenericRepository habitRepository, - IUserStreakService userStreakService, - IUserDateService userDateService, - IUnitOfWork unitOfWork) : IRequestHandler> -{ - public async Task> Handle(ActivateStreakFreezeCommand request, CancellationToken cancellationToken) - { - var user = await userRepository.FindOneTrackedAsync( - u => u.Id == request.UserId, - cancellationToken: cancellationToken); - - if (user is null) - return Result.Failure(ErrorMessages.UserNotFound, ErrorCodes.UserNotFound); - - if (!user.HasProAccess) - return Result.PayGateFailure("Streak freezes are a Pro feature. Upgrade to unlock!"); - - var existingStreak = await userStreakService.RecalculateAsync(request.UserId, cancellationToken); - if (existingStreak is null) - return Result.Failure(ErrorMessages.UserNotFound, ErrorCodes.UserNotFound); - - var today = await userDateService.GetUserTodayAsync(request.UserId, cancellationToken); - - if (existingStreak.CurrentStreak <= 0) - return Result.Failure(ErrorMessages.NoActiveStreak, ErrorCodes.NoActiveStreak); - - 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(); - - if (habitIds.Count > 0) - { - var todayLogs = await habitLogRepository.FindAsync( - l => habitIds.Contains(l.HabitId) && l.Date == today && l.Value > 0, - cancellationToken); - - if (todayLogs.Count > 0) - return Result.Failure("You already completed a habit today. No freeze needed!"); - } - - var existingFreeze = await streakFreezeRepository.FindAsync( - sf => sf.UserId == request.UserId && sf.UsedOnDate == today, - cancellationToken); - - if (existingFreeze.Count > 0) - return Result.Failure(ErrorMessages.AlreadyUsedStreakFreezeToday, ErrorCodes.AlreadyUsedStreakFreezeToday); - - 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 (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); - - var freeze = StreakFreeze.Create(request.UserId, today); - await streakFreezeRepository.AddAsync(freeze, cancellationToken); - - await unitOfWork.SaveChangesAsync(cancellationToken); - var updatedStreak = await userStreakService.RecalculateAsync(request.UserId, cancellationToken); - await unitOfWork.SaveChangesAsync(cancellationToken); - - var freezesRemaining = Math.Max(0, AppConstants.MaxStreakFreezesPerMonth - (freezesThisMonth.Count + 1)); - - return Result.Success(new StreakFreezeResponse( - freezesRemaining, - today, - updatedStreak?.CurrentStreak ?? 0, - user.StreakFreezesAccumulated)); - } -} diff --git a/src/Orbit.Application/Gamification/Validators/ActivateStreakFreezeCommandValidator.cs b/src/Orbit.Application/Gamification/Validators/ActivateStreakFreezeCommandValidator.cs deleted file mode 100644 index 210fa775..00000000 --- a/src/Orbit.Application/Gamification/Validators/ActivateStreakFreezeCommandValidator.cs +++ /dev/null @@ -1,12 +0,0 @@ -using FluentValidation; -using Orbit.Application.Gamification.Commands; - -namespace Orbit.Application.Gamification.Validators; - -public class ActivateStreakFreezeCommandValidator : AbstractValidator -{ - public ActivateStreakFreezeCommandValidator() - { - RuleFor(x => x.UserId).NotEmpty(); - } -} diff --git a/src/Orbit.Domain/Models/AgentContracts.cs b/src/Orbit.Domain/Models/AgentContracts.cs index eba204e7..8e9917c5 100644 --- a/src/Orbit.Domain/Models/AgentContracts.cs +++ b/src/Orbit.Domain/Models/AgentContracts.cs @@ -264,7 +264,6 @@ public static class AgentCapabilityIds public const string CalendarRead = "calendar.read"; public const string CalendarSyncManage = "calendar.sync.manage"; public const string GamificationRead = "gamification.read"; - public const string GamificationWrite = "gamification.write"; public const string ChecklistTemplatesRead = "checklist-templates.read"; public const string ChecklistTemplatesWrite = "checklist-templates.write"; public const string UserFactsRead = "user-facts.read"; @@ -305,7 +304,6 @@ public static class AgentScopes public const string ReadCalendar = "read_calendar"; public const string ManageCalendarSync = "manage_calendar_sync"; public const string ReadGamification = "read_gamification"; - public const string WriteGamification = "write_gamification"; public const string ReadChecklistTemplates = "read_checklist_templates"; public const string WriteChecklistTemplates = "write_checklist_templates"; public const string ReadUserFacts = "read_user_facts"; @@ -344,7 +342,6 @@ public static class AgentScopes ReadCalendar, ManageCalendarSync, ReadGamification, - WriteGamification, ReadChecklistTemplates, WriteChecklistTemplates, ReadUserFacts, diff --git a/src/Orbit.Infrastructure/Services/AgentCatalogService.cs b/src/Orbit.Infrastructure/Services/AgentCatalogService.cs index c457d825..52d69797 100644 --- a/src/Orbit.Infrastructure/Services/AgentCatalogService.cs +++ b/src/Orbit.Infrastructure/Services/AgentCatalogService.cs @@ -877,21 +877,6 @@ private static IReadOnlyList BuildCapabilities() "GamificationController.GetStreakInfo" ]), - CreateCapability( - AgentCapabilityIds.GamificationWrite, - "Write Gamification", - "Uses streak-freeze or equivalent game-state mutations.", - "gamification", - AgentScopes.WriteGamification, - AgentRiskClass.Low, - isMutation: true, - isPhaseOneReadOnly: false, - AgentConfirmationRequirement.None, - planRequirement: "Pro", - chatTools: ["activate_streak_freeze"], - mcpTools: ["activate_streak_freeze"], - controllerActions: ["GamificationController.ActivateStreakFreeze"]), - CreateCapability( AgentCapabilityIds.ChecklistTemplatesRead, "Read Checklist Templates", @@ -1226,8 +1211,8 @@ private static IReadOnlyList BuildSurfaces() "Shows streaks, freezes, XP, levels, and achievements.", ["Open streak profile or achievements.", "Review freeze availability.", "Activate a freeze when needed."], ["Freeze activation is a mutation.", "Level and XP are derived state."], - [AgentCapabilityIds.GamificationRead, AgentCapabilityIds.GamificationWrite], - ["GamificationController.GetProfile", "GamificationController.ActivateStreakFreeze"]), + [AgentCapabilityIds.GamificationRead], + ["GamificationController.GetProfile"]), new AppSurface( "referrals", diff --git a/tests/Orbit.Application.Tests/Chat/Tools/ChecklistUserFactPlatformToolTests.cs b/tests/Orbit.Application.Tests/Chat/Tools/ChecklistUserFactPlatformToolTests.cs index 95cb2b73..3a375a14 100644 --- a/tests/Orbit.Application.Tests/Chat/Tools/ChecklistUserFactPlatformToolTests.cs +++ b/tests/Orbit.Application.Tests/Chat/Tools/ChecklistUserFactPlatformToolTests.cs @@ -9,7 +9,6 @@ using Orbit.Application.Chat.Tools.Implementations; using Orbit.Application.ChecklistTemplates.Commands; using Orbit.Application.ChecklistTemplates.Queries; -using Orbit.Application.Gamification.Commands; using Orbit.Application.Gamification.Queries; using Orbit.Application.Referrals.Queries; using Orbit.Application.Profile.Commands; @@ -37,7 +36,6 @@ public void ToolMetadata_ExposesNamesAndSchemas() var userFactsTool = new GetUserFactsTool(mediator); var deleteUserFactsTool = new DeleteUserFactsTool(mediator); var gamificationTool = new GetGamificationOverviewTool(mediator); - var activateStreakFreezeTool = new ActivateStreakFreezeTool(mediator); var referralTool = new GetReferralOverviewTool(mediator); var subscriptionOverviewTool = new GetSubscriptionOverviewTool(mediator); var manageSubscriptionTool = new ManageSubscriptionTool(mediator); @@ -67,9 +65,6 @@ public void ToolMetadata_ExposesNamesAndSchemas() gamificationTool.IsReadOnly.Should().BeTrue(); JsonSerializer.Serialize(gamificationTool.GetParameterSchema()).Should().Contain("include_achievements"); - activateStreakFreezeTool.Name.Should().Be("activate_streak_freeze"); - JsonSerializer.Serialize(activateStreakFreezeTool.GetParameterSchema()).Should().Contain("properties"); - referralTool.Name.Should().Be("get_referral_overview"); referralTool.IsReadOnly.Should().BeTrue(); @@ -403,34 +398,6 @@ public async Task GetGamificationOverviewTool_ReturnsFailureWhenStreakFails() result.Error.Should().Be("streak_failed"); } - [Fact] - 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, 0))); - var tool = new ActivateStreakFreezeTool(mediator); - - var result = await tool.ExecuteAsync(Parse("{}"), UserId, CancellationToken.None); - - result.Success.Should().BeTrue(); - result.EntityName.Should().Be("Activated streak freeze"); - } - - [Fact] - public async Task ActivateStreakFreezeTool_ReturnsFailure() - { - var mediator = Substitute.For(); - mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Failure("freeze_failed")); - var tool = new ActivateStreakFreezeTool(mediator); - - var result = await tool.ExecuteAsync(Parse("{}"), UserId, CancellationToken.None); - - result.Success.Should().BeFalse(); - result.Error.Should().Be("freeze_failed"); - } - [Fact] public async Task GetReferralOverviewTool_ReturnsFailure() { diff --git a/tests/Orbit.Application.Tests/Commands/Gamification/ActivateStreakFreezeCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Gamification/ActivateStreakFreezeCommandHandlerTests.cs deleted file mode 100644 index 64f90a6f..00000000 --- a/tests/Orbit.Application.Tests/Commands/Gamification/ActivateStreakFreezeCommandHandlerTests.cs +++ /dev/null @@ -1,208 +0,0 @@ -using FluentAssertions; -using NSubstitute; -using Orbit.Application.Gamification.Commands; -using Orbit.Domain.Entities; -using Orbit.Domain.Models; -using Orbit.Domain.Enums; -using Orbit.Domain.Interfaces; -using System.Linq.Expressions; - -namespace Orbit.Application.Tests.Commands.Gamification; - -public class ActivateStreakFreezeCommandHandlerTests -{ - private readonly IGenericRepository _userRepo = Substitute.For>(); - private readonly IGenericRepository _streakFreezeRepo = Substitute.For>(); - private readonly IGenericRepository _habitLogRepo = Substitute.For>(); - private readonly IGenericRepository _habitRepo = Substitute.For>(); - private readonly IUserStreakService _userStreakService = Substitute.For(); - private readonly IUserDateService _userDateService = Substitute.For(); - private readonly IUnitOfWork _unitOfWork = Substitute.For(); - private readonly ActivateStreakFreezeCommandHandler _handler; - - private static readonly Guid UserId = Guid.NewGuid(); - private static readonly DateOnly Today = new(2026, 4, 3); - - public ActivateStreakFreezeCommandHandlerTests() - { - _handler = new ActivateStreakFreezeCommandHandler( - _userRepo, _streakFreezeRepo, _habitLogRepo, _habitRepo, _userStreakService, _userDateService, _unitOfWork); - _userDateService.GetUserTodayAsync(UserId, Arg.Any()).Returns(Today); - } - - 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)); - 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; - } - - [Fact] - public async Task Handle_UserNotFound_ReturnsFailure() - { - _userRepo.FindOneTrackedAsync( - Arg.Any>>(), - Arg.Any, IQueryable>?>(), - Arg.Any()) - .Returns((User?)null); - - var command = new ActivateStreakFreezeCommand(UserId); - - var result = await _handler.Handle(command, CancellationToken.None); - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("User not found"); - } - - [Fact] - public async Task Handle_NoActiveStreak_ReturnsFailure() - { - var user = User.Create("Test", "test@example.com").Value; - // Streak is 0 by default - - _userRepo.FindOneTrackedAsync( - Arg.Any>>(), - Arg.Any, IQueryable>?>(), - Arg.Any()) - .Returns(user); - _userStreakService.RecalculateAsync(UserId, Arg.Any()) - .Returns(new UserStreakState(0, 0, null)); - - var command = new ActivateStreakFreezeCommand(UserId); - - var result = await _handler.Handle(command, CancellationToken.None); - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("No active streak"); - } - - [Fact] - public async Task Handle_AlreadyFrozenToday_ReturnsFailure() - { - var user = CreateUserWithStreak(); - - _userRepo.FindOneTrackedAsync( - Arg.Any>>(), - Arg.Any, IQueryable>?>(), - Arg.Any()) - .Returns(user); - _userStreakService.RecalculateAsync(UserId, Arg.Any()) - .Returns(new UserStreakState(5, 5, Today.AddDays(-1))); - - // User has no habits (no need to check logs) - _habitRepo.FindAsync( - Arg.Any>>(), - Arg.Any()) - .Returns(new List().AsReadOnly()); - - // Already has a freeze today - _streakFreezeRepo.FindAsync( - Arg.Any>>(), - Arg.Any()) - .Returns( - new List { StreakFreeze.Create(UserId, Today) }.AsReadOnly(), - new List().AsReadOnly()); - - var command = new ActivateStreakFreezeCommand(UserId); - - var result = await _handler.Handle(command, CancellationToken.None); - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("already used"); - } - - [Fact] - public async Task Handle_MaxFreezesReached_ReturnsFailure() - { - var user = CreateUserWithStreak(); - - _userRepo.FindOneTrackedAsync( - Arg.Any>>(), - Arg.Any, IQueryable>?>(), - Arg.Any()) - .Returns(user); - _userStreakService.RecalculateAsync(UserId, Arg.Any()) - .Returns(new UserStreakState(5, 5, Today.AddDays(-1))); - - _habitRepo.FindAsync( - Arg.Any>>(), - Arg.Any()) - .Returns(new List().AsReadOnly()); - - // No freeze today but 3 recent freezes (at max) - var recentFreezes = new List - { - StreakFreeze.Create(UserId, Today.AddDays(-1)), - StreakFreeze.Create(UserId, Today.AddDays(-5)), - StreakFreeze.Create(UserId, Today.AddDays(-10)) - }; - - // First call: check existing freeze today (empty) - // Second call: check rolling window (3 freezes) - _streakFreezeRepo.FindAsync( - Arg.Any>>(), - Arg.Any()) - .Returns( - new List().AsReadOnly(), - recentFreezes.AsReadOnly()); - - var command = new ActivateStreakFreezeCommand(UserId); - - var result = await _handler.Handle(command, CancellationToken.None); - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("streak freezes this month"); - } - - [Fact] - public async Task Handle_ValidFreeze_ReturnsSuccess() - { - var user = CreateUserWithStreak(); - - _userRepo.FindOneTrackedAsync( - Arg.Any>>(), - Arg.Any, IQueryable>?>(), - Arg.Any()) - .Returns(user); - _userStreakService.RecalculateAsync(UserId, Arg.Any()) - .Returns( - new UserStreakState(5, 5, Today.AddDays(-1)), - new UserStreakState(5, 5, Today)); - - _habitRepo.FindAsync( - Arg.Any>>(), - Arg.Any()) - .Returns(new List().AsReadOnly()); - - // No existing freeze today, no recent freezes - _streakFreezeRepo.FindAsync( - Arg.Any>>(), - Arg.Any()) - .Returns( - new List().AsReadOnly(), - new List().AsReadOnly()); - - var command = new ActivateStreakFreezeCommand(UserId); - - var result = await _handler.Handle(command, CancellationToken.None); - - result.IsSuccess.Should().BeTrue(); - result.Value.FrozenDate.Should().Be(Today); - result.Value.FreezesRemainingThisMonth.Should().Be(2); - - await _streakFreezeRepo.Received(1).AddAsync(Arg.Any(), Arg.Any()); - await _unitOfWork.Received(2).SaveChangesAsync(Arg.Any()); - } -} diff --git a/tests/Orbit.Application.Tests/Validators/ActivateStreakFreezeCommandValidatorTests.cs b/tests/Orbit.Application.Tests/Validators/ActivateStreakFreezeCommandValidatorTests.cs deleted file mode 100644 index 90819a3b..00000000 --- a/tests/Orbit.Application.Tests/Validators/ActivateStreakFreezeCommandValidatorTests.cs +++ /dev/null @@ -1,27 +0,0 @@ -using FluentValidation.TestHelper; -using Orbit.Application.Gamification.Commands; -using Orbit.Application.Gamification.Validators; - -namespace Orbit.Application.Tests.Validators; - -public class ActivateStreakFreezeCommandValidatorTests -{ - private readonly ActivateStreakFreezeCommandValidator _validator = new(); - - private static ActivateStreakFreezeCommand ValidCommand() => new( - UserId: Guid.NewGuid()); - - [Fact] - public void Validate_ValidCommand_NoErrors() - { - var result = _validator.TestValidate(ValidCommand()); - result.ShouldNotHaveAnyValidationErrors(); - } - - [Fact] - public void Validate_EmptyUserId_HasError() - { - var result = _validator.TestValidate(ValidCommand() with { UserId = Guid.Empty }); - result.ShouldHaveValidationErrorFor(x => x.UserId); - } -} diff --git a/tests/Orbit.Infrastructure.Tests/Controllers/GamificationControllerTests.cs b/tests/Orbit.Infrastructure.Tests/Controllers/GamificationControllerTests.cs index 2f5f1545..fa877ab5 100644 --- a/tests/Orbit.Infrastructure.Tests/Controllers/GamificationControllerTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Controllers/GamificationControllerTests.cs @@ -5,7 +5,6 @@ using Microsoft.AspNetCore.Mvc; using NSubstitute; using Orbit.Api.Controllers; -using Orbit.Application.Gamification.Commands; using Orbit.Application.Gamification.Queries; using Orbit.Domain.Common; @@ -102,28 +101,4 @@ public async Task GetStreakInfo_Failure_ReturnsBadRequest() result.Should().BeOfType(); } - - // --- ActivateStreakFreeze --- - - [Fact] - public async Task ActivateStreakFreeze_Success_ReturnsOk() - { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success(default(StreakFreezeResponse)!)); - - var result = await _controller.ActivateStreakFreeze(CancellationToken.None); - - result.Should().BeOfType(); - } - - [Fact] - public async Task ActivateStreakFreeze_Failure_ReturnsBadRequest() - { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Failure("No freeze available")); - - var result = await _controller.ActivateStreakFreeze(CancellationToken.None); - - result.Should().BeOfType(); - } } diff --git a/tests/Orbit.Infrastructure.Tests/Mcp/GamificationToolsTests.cs b/tests/Orbit.Infrastructure.Tests/Mcp/GamificationToolsTests.cs index 265a6199..0ddc59bb 100644 --- a/tests/Orbit.Infrastructure.Tests/Mcp/GamificationToolsTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Mcp/GamificationToolsTests.cs @@ -3,7 +3,6 @@ using MediatR; using NSubstitute; using Orbit.Api.Mcp.Tools; -using Orbit.Application.Gamification.Commands; using Orbit.Application.Gamification.Queries; using Orbit.Domain.Common; @@ -125,29 +124,4 @@ public async Task GetStreakInfo_Failure_ReturnsError() result.Should().StartWith("Error: "); } - - [Fact] - public async Task ActivateStreakFreeze_Success_ReturnsActivatedMessage() - { - var response = new StreakFreezeResponse(1, new DateOnly(2026, 4, 3), 15, 0); - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Success(response)); - - var result = await _tools.ActivateStreakFreeze(_user); - - result.Should().Contain("Streak freeze activated"); - result.Should().Contain("streak preserved: 15 days"); - result.Should().Contain("Freezes remaining this month: 1"); - } - - [Fact] - public async Task ActivateStreakFreeze_Failure_ReturnsError() - { - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(Result.Failure("No freezes remaining")); - - var result = await _tools.ActivateStreakFreeze(_user); - - result.Should().StartWith("Error: "); - } } From 7932272d6539e856aef29a61554c62da7f9fa66f Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Thu, 4 Jun 2026 16:40:56 -0300 Subject: [PATCH 3/3] fix(api): exclude soft-deleted habits from auto-freeze completion check (#108) Aligns StreakFreezeAutoActivationService.LoadRecentCompletionsAsync with UserStreakService.LoadStreakDataAsync by filtering out soft-deleted habits when computing recent completions, so a deleted habit's log can no longer count as activity for a date and suppress the auto-freeze. Adds DB-backed tests locking the LoadRecentCompletionsAsync contract for soft-deleted and live habit logs. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../StreakFreezeAutoActivationService.cs | 2 +- .../StreakFreezeAutoActivationServiceTests.cs | 75 +++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/src/Orbit.Infrastructure/Services/StreakFreezeAutoActivationService.cs b/src/Orbit.Infrastructure/Services/StreakFreezeAutoActivationService.cs index d0d53320..6d50bb94 100644 --- a/src/Orbit.Infrastructure/Services/StreakFreezeAutoActivationService.cs +++ b/src/Orbit.Infrastructure/Services/StreakFreezeAutoActivationService.cs @@ -105,7 +105,7 @@ private static async Task>> LoadRecentComplet OrbitDbContext dbContext, List userIds, DateOnly since, CancellationToken ct) { var habitOwners = await dbContext.Habits - .Where(h => userIds.Contains(h.UserId) && !h.IsBadHabit) + .Where(h => userIds.Contains(h.UserId) && !h.IsDeleted && !h.IsBadHabit) .Select(h => new { h.Id, h.UserId }) .ToListAsync(ct); diff --git a/tests/Orbit.Infrastructure.Tests/Services/StreakFreezeAutoActivationServiceTests.cs b/tests/Orbit.Infrastructure.Tests/Services/StreakFreezeAutoActivationServiceTests.cs index 241ea0dd..6356f456 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/StreakFreezeAutoActivationServiceTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/StreakFreezeAutoActivationServiceTests.cs @@ -1,8 +1,11 @@ using System.Reflection; using FluentAssertions; +using Microsoft.EntityFrameworkCore; using NSubstitute; using Orbit.Application.Common; using Orbit.Domain.Entities; +using Orbit.Domain.Enums; +using Orbit.Infrastructure.Persistence; using Orbit.Infrastructure.Services; namespace Orbit.Infrastructure.Tests.Services; @@ -176,6 +179,56 @@ public void Eligibility_NoInventory_Excluded() IsEligible(new EligibilityCase { StreakFreezesAccumulated = 0 }).Should().BeFalse(); } + // --- Completion loading (LoadRecentCompletionsAsync excludes soft-deleted habits) --- + + [Fact] + public async Task LoadRecentCompletions_SoftDeletedHabitLog_DoesNotMarkDateActive() + { + // A soft-deleted habit's completion log must not count as activity: otherwise it would + // falsely cover the missed date and suppress the auto-freeze even though no live habit + // was completed. Mirrors UserStreakService, which also excludes IsDeleted habits. + var userId = Guid.NewGuid(); + var missedDate = new DateOnly(2026, 6, 3); + var since = missedDate.AddDays(-1); + + await using var context = CreateInMemoryContext(); + + var activeHabit = CreateHabit(userId); + var deletedHabit = CreateHabit(userId); + deletedHabit.SoftDelete(); + context.Habits.AddRange(activeHabit, deletedHabit); + + deletedHabit.Log(missedDate); + context.HabitLogs.AddRange(deletedHabit.Logs); + await context.SaveChangesAsync(); + + var completionsByUser = await InvokeLoadRecentCompletions(context, [userId], since); + + var completionDates = completionsByUser.GetValueOrDefault(userId) ?? []; + completionDates.Should().NotContain(missedDate); + } + + [Fact] + public async Task LoadRecentCompletions_LiveHabitLog_MarksDateActive() + { + var userId = Guid.NewGuid(); + var completedDate = new DateOnly(2026, 6, 3); + var since = completedDate.AddDays(-1); + + await using var context = CreateInMemoryContext(); + + var activeHabit = CreateHabit(userId); + context.Habits.Add(activeHabit); + + activeHabit.Log(completedDate); + context.HabitLogs.AddRange(activeHabit.Logs); + await context.SaveChangesAsync(); + + var completionsByUser = await InvokeLoadRecentCompletions(context, [userId], since); + + completionsByUser.GetValueOrDefault(userId).Should().Contain(completedDate); + } + // --- Helpers --- private static (string Title, string Body) InvokeBuildNotification(int currentStreak, string lang) @@ -185,6 +238,28 @@ private static (string Title, string Body) InvokeBuildNotification(int currentSt return ((string, string))method.Invoke(null, [currentStreak, lang])!; } + private static OrbitDbContext CreateInMemoryContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new OrbitDbContext(options); + } + + private static Habit CreateHabit(Guid userId) => + Habit.Create(new HabitCreateParams( + userId, "Habit", FrequencyUnit.Day, 1, DueDate: new DateOnly(2026, 6, 3))).Value; + + private static async Task>> InvokeLoadRecentCompletions( + OrbitDbContext context, List userIds, DateOnly since) + { + var method = typeof(StreakFreezeAutoActivationService) + .GetMethod("LoadRecentCompletionsAsync", PrivateStatic)!; + var task = (Task>>)method.Invoke( + null, [context, userIds, since, CancellationToken.None])!; + return await task; + } + private static int GetConfiguredIntervalMinutesDefault() { // Construct the service with an empty configuration so the field initializer resolves