From 5cf2ad9418a8e7fc6c1ca2fe40690a33034acfca Mon Sep 17 00:00:00 2001 From: thomasluizon Date: Sat, 8 Aug 2026 01:20:04 -0300 Subject: [PATCH 1/3] chore: start ORB-188 From 92120036d6f8e5215580febd48af0959f825c495 Mon Sep 17 00:00:00 2001 From: thomasluizon Date: Sat, 8 Aug 2026 05:09:59 -0300 Subject: [PATCH 2/3] feat: collapse the Yearly-Pro entitlement tier into Pro (ORB-188) Salvaged by the orchestrator after the implementing worker's process tree was killed mid-edit at 2026-08-08T04:29:33Z with this work uncommitted. No implementation code was written outside the worker's own edits; only the 22 paths it had already touched were staged, by name. Gate before staging: dotnet build 0 errors, dotnet test Orbit.slnx 5,722 passed 0 failed. --- .../Content/FeatureExplanations/paygate.md | 5 +- .../Common/AnalyticsCapture.cs | 2 +- .../Common/PayGateService.cs | 4 +- src/Orbit.Domain/Entities/User.cs | 3 - .../Interfaces/IProductAnalytics.cs | 2 +- ...42801_CollapseYearlyProIntoPro.Designer.cs | 2606 +++++++++++++++++ ...20260808042801_CollapseYearlyProIntoPro.cs | 32 + .../Migrations/OrbitDbContextModelSnapshot.cs | 2 +- .../Persistence/OrbitDbContext.cs | 2 +- .../AgentCatalogService.Capabilities.cs | 4 +- .../Services/AgentPolicyEvaluator.cs | 1 - .../Services/FeatureFlagService.cs | 1 - .../Services/NoOpProductAnalytics.cs | 2 +- .../Services/PostHogProductAnalytics.cs | 5 +- .../Auth/GoogleAuthCommandHandlerTests.cs | 4 +- .../Auth/VerifyCodeCommandHandlerTests.cs | 6 +- .../HandleWebhookCommandHandlerTests.cs | 18 +- .../Common/PayGateServiceTests.cs | 49 +- .../ProductAnalyticsRegistrationTests.cs | 2 +- .../Services/AgentPolicyEvaluatorTests.cs | 23 + .../FeatureFlagAndAgentSupportTests.cs | 17 + .../Services/PostHogProductAnalyticsTests.cs | 10 +- 22 files changed, 2754 insertions(+), 46 deletions(-) create mode 100644 src/Orbit.Infrastructure/Migrations/20260808042801_CollapseYearlyProIntoPro.Designer.cs create mode 100644 src/Orbit.Infrastructure/Migrations/20260808042801_CollapseYearlyProIntoPro.cs diff --git a/src/Orbit.Application/Chat/Content/FeatureExplanations/paygate.md b/src/Orbit.Application/Chat/Content/FeatureExplanations/paygate.md index ef28875e..5092d2a0 100644 --- a/src/Orbit.Application/Chat/Content/FeatureExplanations/paygate.md +++ b/src/Orbit.Application/Chat/Content/FeatureExplanations/paygate.md @@ -34,7 +34,4 @@ Upgrading to Pro unlocks: - Premium color schemes - Streak freezes - Gamification: XP, levels, and achievements - -## The retrospective is yearly-only - -The **retrospective** is the one feature that needs the **yearly** Pro plan specifically. A monthly Pro subscription does not include it; the yearly plan does. +- Retrospectives diff --git a/src/Orbit.Application/Common/AnalyticsCapture.cs b/src/Orbit.Application/Common/AnalyticsCapture.cs index 7051e70b..11a25a94 100644 --- a/src/Orbit.Application/Common/AnalyticsCapture.cs +++ b/src/Orbit.Application/Common/AnalyticsCapture.cs @@ -16,7 +16,7 @@ public static void SafeCaptureUserEvent( { try { - analytics.CaptureUserEvent(user.Id, eventName, user.Plan.ToString(), user.IsYearlyPro); + analytics.CaptureUserEvent(user.Id, eventName, user.Plan.ToString()); } catch (Exception ex) { diff --git a/src/Orbit.Application/Common/PayGateService.cs b/src/Orbit.Application/Common/PayGateService.cs index 9f62cf56..5cd499dc 100644 --- a/src/Orbit.Application/Common/PayGateService.cs +++ b/src/Orbit.Application/Common/PayGateService.cs @@ -139,8 +139,8 @@ public async Task CanUseRetrospective(Guid userId, CancellationToken ct return Result.Failure(ErrorMessages.UserNotFound); var proOnly = await appConfig.GetAsync(AppConfigKeys.RetrospectiveProOnly, true, ct); - if (proOnly && !user.IsYearlyPro) - return Result.PayGateFailure("Retrospectives are available on the yearly Pro plan. Upgrade to unlock!"); + if (proOnly && !user.HasProAccess) + return Result.PayGateFailure("Retrospectives are a Pro feature. Upgrade to unlock!"); return Result.Success(); } diff --git a/src/Orbit.Domain/Entities/User.cs b/src/Orbit.Domain/Entities/User.cs index 3d764b93..8deb6fec 100644 --- a/src/Orbit.Domain/Entities/User.cs +++ b/src/Orbit.Domain/Entities/User.cs @@ -88,9 +88,6 @@ public partial class User : Entity [NotMapped] public bool HasProAccess => IsPro || IsTrialActive; - [NotMapped] - public bool IsYearlyPro => IsPro && (IsLifetimePro || SubscriptionInterval == Enums.SubscriptionInterval.Yearly); - private User() { } public static Result Create(string name, string email) diff --git a/src/Orbit.Domain/Interfaces/IProductAnalytics.cs b/src/Orbit.Domain/Interfaces/IProductAnalytics.cs index bc52698a..f6e6e44d 100644 --- a/src/Orbit.Domain/Interfaces/IProductAnalytics.cs +++ b/src/Orbit.Domain/Interfaces/IProductAnalytics.cs @@ -7,5 +7,5 @@ namespace Orbit.Domain.Interfaces; /// public interface IProductAnalytics { - void CaptureUserEvent(Guid userId, string eventName, string plan, bool isYearlyPro); + void CaptureUserEvent(Guid userId, string eventName, string plan); } diff --git a/src/Orbit.Infrastructure/Migrations/20260808042801_CollapseYearlyProIntoPro.Designer.cs b/src/Orbit.Infrastructure/Migrations/20260808042801_CollapseYearlyProIntoPro.Designer.cs new file mode 100644 index 00000000..2f6d53da --- /dev/null +++ b/src/Orbit.Infrastructure/Migrations/20260808042801_CollapseYearlyProIntoPro.Designer.cs @@ -0,0 +1,2606 @@ +// +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("20260808042801_CollapseYearlyProIntoPro")] + partial class CollapseYearlyProIntoPro + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("HabitGoals", b => + { + b.Property("GoalId") + .HasColumnType("uuid"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.HasKey("GoalId", "HabitId"); + + b.HasIndex("HabitId"); + + b.ToTable("HabitGoals"); + }); + + modelBuilder.Entity("HabitTags", b => + { + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("TagId") + .HasColumnType("uuid"); + + b.HasKey("HabitId", "TagId"); + + b.HasIndex("TagId"); + + b.ToTable("HabitTags"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AccountabilityCheckIn", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("Note") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PairId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PairId", "CreatedAtUtc"); + + b.HasIndex("PairId", "UserId", "Date") + .IsUnique(); + + b.ToTable("AccountabilityCheckIns"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AccountabilityPair", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AddresseeId") + .HasColumnType("uuid"); + + b.Property("Cadence") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EndedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("AddresseeId"); + + b.HasIndex("RequesterId"); + + b.ToTable("AccountabilityPairs"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AccountabilityPairHabit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("PairId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("HabitId"); + + b.HasIndex("PairId", "UserId", "HabitId") + .IsUnique(); + + b.ToTable("AccountabilityPairHabits"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AgentAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthMethod") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CapabilityId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CorrelationId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Error") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("OutcomeStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("PolicyDecision") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RedactedArguments") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RiskClass") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ShadowPolicyDecision") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ShadowReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("SourceName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Summary") + .HasColumnType("text"); + + b.Property("Surface") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TargetId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TargetName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CapabilityId", "CreatedAtUtc"); + + b.HasIndex("UserId", "CreatedAtUtc"); + + b.ToTable("AgentAuditLogs"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AgentStepUpChallengeState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttemptCount") + .HasColumnType("integer"); + + b.Property("CodeHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PendingOperationId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("VerifiedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PendingOperationId", "CreatedAtUtc"); + + b.ToTable("AgentStepUpChallenges"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AiFactExtractionBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BatchId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("InputFileId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("OutputFileId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("UserId"); + + b.ToTable("AiFactExtractionBatches"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AiUsageDaily", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CachedTokens") + .HasColumnType("bigint"); + + b.Property("Calls") + .HasColumnType("bigint"); + + b.Property("CompletionTokens") + .HasColumnType("bigint"); + + b.Property("CostUsd") + .HasColumnType("numeric"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PromptTokens") + .HasColumnType("bigint"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TotalTokens") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("Date", "Model", "Purpose") + .IsUnique(); + + b.ToTable("AiUsageDaily"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsReadOnly") + .HasColumnType("boolean"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("KeyHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("KeyPrefix") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("character varying(12)"); + + b.Property("LastUsedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Scopes") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("KeyPrefix"); + + b.HasIndex("UserId"); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AppConfig", b => + { + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Key"); + + b.ToTable("AppConfigs"); + + b.HasData( + new + { + Key = "MaxUserFacts", + Description = "Maximum number of facts the AI can remember per user", + Value = "50" + }, + new + { + Key = "MaxHabitDepth", + Description = "Maximum nesting depth for sub-habits", + Value = "5" + }, + new + { + Key = "MaxTagsPerHabit", + Description = "Maximum number of tags per habit", + Value = "5" + }, + new + { + Key = "ReferralRewardDays", + Description = "Days of Pro added per successful referral", + Value = "10" + }, + new + { + Key = "MaxReferrals", + Description = "Maximum successful referrals per user", + Value = "10" + }, + new + { + Key = "MinSupportedVersion", + Description = "Minimum supported client app version; clients below this receive HTTP 426", + Value = "0.0.0" + }); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AppFeatureFlag", b => + { + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("PlanRequirement") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Key"); + + b.ToTable("AppFeatureFlags"); + + b.HasData( + new + { + Key = "offline_mode", + Description = "Enable offline mode with background sync", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "ai_chat", + Description = "AI chat assistant", + Enabled = true, + PlanRequirement = "Free", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "ai_summary", + Description = "AI daily summary", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "ai_retrospective", + Description = "AI retrospective analysis", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "sub_habits", + Description = "Sub-habit nesting", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "goal_tracking", + Description = "Goal tracking with progress", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "push_notifications", + Description = "Push notification reminders", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "scheduled_reminders", + Description = "Custom scheduled reminders", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "slip_alerts", + Description = "Slip detection alerts", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "checklist_templates", + Description = "Reusable checklist templates", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "habit_duplication", + Description = "Duplicate habits", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "bulk_operations", + Description = "Bulk create/delete/log habits", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "calendar_integration", + Description = "Google Calendar integration", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "api_keys", + Description = "Personal API keys", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.BlockedUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BlockedId") + .HasColumnType("uuid"); + + b.Property("BlockerId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("BlockedId"); + + b.HasIndex("BlockerId", "BlockedId") + .IsUnique(); + + b.ToTable("BlockedUsers"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Challenge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatorId") + .HasColumnType("uuid"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("JoinCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("PeriodEndUtc") + .HasColumnType("date"); + + b.Property("PeriodStartUtc") + .HasColumnType("date"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TargetCount") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatorId"); + + b.HasIndex("JoinCode") + .IsUnique(); + + b.ToTable("Challenges"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ChallengeParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChallengeId") + .HasColumnType("uuid"); + + b.Property("JoinedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LeftAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("ChallengeId", "UserId") + .IsUnique() + .HasFilter("\"LeftAtUtc\" IS NULL"); + + b.ToTable("ChallengeParticipants"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ChallengeParticipantHabit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChallengeParticipantId") + .HasColumnType("uuid"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("HabitId"); + + b.HasIndex("ChallengeParticipantId", "HabitId") + .IsUnique(); + + b.ToTable("ChallengeParticipantHabits"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ChecklistTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Items") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "IsDeleted"); + + b.HasIndex("UserId", "UpdatedAtUtc"); + + b.ToTable("ChecklistTemplates"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Cheer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("Note") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RecipientId") + .HasColumnType("uuid"); + + b.Property("SenderId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("HabitId"); + + b.HasIndex("RecipientId"); + + b.HasIndex("SenderId", "CreatedAtUtc"); + + b.ToTable("Cheers"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ContentBlock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Key", "Locale") + .IsUnique(); + + b.ToTable("ContentBlocks"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.DistributedRateLimitBucket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Count") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PartitionKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PolicyName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WindowEndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WindowStartUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PolicyName", "PartitionKey", "WindowStartUtc") + .IsUnique(); + + b.ToTable("DistributedRateLimitBuckets"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.FriendFeedEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AchievementId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ActorUserId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Value") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId", "AchievementId") + .IsUnique() + .HasFilter("\"AchievementId\" IS NOT NULL"); + + b.HasIndex("ActorUserId", "CreatedAtUtc", "Id") + .IsDescending(false, true, true); + + b.HasIndex("ActorUserId", "Type", "Value") + .IsUnique() + .HasFilter("\"AchievementId\" IS NULL"); + + b.ToTable("FriendFeedEvents"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Friendship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddresseeId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("RespondedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("AddresseeId"); + + b.HasIndex("RequesterId"); + + b.ToTable("Friendships"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Goal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentValue") + .HasColumnType("numeric"); + + b.Property("Deadline") + .HasColumnType("date"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("StreakSyncedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TargetValue") + .HasColumnType("numeric"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "IsDeleted"); + + b.HasIndex("UserId", "UpdatedAtUtc"); + + b.ToTable("Goals"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.GoalProgressLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GoalId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("PreviousValue") + .HasColumnType("numeric"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("GoalId"); + + b.HasIndex("GoalId", "IsDeleted"); + + b.HasIndex("GoalId", "UpdatedAtUtc"); + + b.ToTable("GoalProgressLogs"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.GoogleCalendarSyncSuggestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DiscoveredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DismissedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GoogleEventId") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ImportedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ImportedHabitId") + .HasColumnType("uuid"); + + b.Property("RawEventJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartDateUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "GoogleEventId") + .IsUnique(); + + b.HasIndex("UserId", "DismissedAtUtc", "ImportedAtUtc"); + + b.ToTable("GoogleCalendarSyncSuggestions"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Habit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChecklistItems") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Days") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DueDate") + .HasColumnType("date"); + + b.Property("DueEndTime") + .HasColumnType("time without time zone"); + + b.Property("DueTime") + .HasColumnType("time without time zone"); + + b.Property("Emoji") + .HasColumnType("text"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("FrequencyQuantity") + .HasColumnType("integer"); + + b.Property("FrequencyUnit") + .HasColumnType("integer"); + + b.Property("GoogleEventId") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("IsBadHabit") + .HasColumnType("boolean"); + + b.Property("IsCompleted") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsFlexible") + .HasColumnType("boolean"); + + b.Property("IsGeneral") + .HasColumnType("boolean"); + + b.Property("OriginalDayOfMonth") + .HasColumnType("integer"); + + b.Property("ParentHabitId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ReminderEnabled") + .HasColumnType("boolean"); + + b.Property("ReminderTimes") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[15]'::jsonb"); + + b.Property("ScheduledReminders") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("ScheduledStartDate") + .HasColumnType("date"); + + b.Property("SlipAlertEnabled") + .HasColumnType("boolean"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ParentHabitId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "GoogleEventId") + .IsUnique() + .HasFilter("\"GoogleEventId\" IS NOT NULL AND \"IsDeleted\" = FALSE"); + + b.HasIndex("UserId", "IsDeleted"); + + b.HasIndex("UserId", "UpdatedAtUtc"); + + b.ToTable("Habits"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.HabitLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("HabitId", "UpdatedAtUtc"); + + b.HasIndex(new[] { "HabitId", "Date" }, "IX_HabitLogs_HabitId_Date"); + + b.HasIndex(new[] { "HabitId", "Date" }, "IX_HabitLogs_HabitId_Date_Completed") + .IsUnique() + .HasFilter("\"Value\" > 0 AND NOT \"IsDeleted\""); + + b.ToTable("HabitLogs"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsRead") + .HasColumnType("boolean"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Url") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Url") + .HasFilter("\"Url\" IS NOT NULL"); + + b.HasIndex("UserId", "CreatedAtUtc") + .IsDescending(false, true); + + b.HasIndex("UserId", "IsDeleted"); + + b.HasIndex("UserId", "IsRead"); + + b.HasIndex("UserId", "UpdatedAtUtc"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.PendingAgentOperationState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ArgumentsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CapabilityId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfirmationRequirement") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ConfirmationTokenHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ConfirmedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OperationFingerprint") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OperationId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RiskClass") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("StepUpSatisfiedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Surface") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CapabilityId"); + + b.HasIndex("UserId", "OperationFingerprint"); + + b.ToTable("PendingAgentOperations"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.PendingClarification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MissingArgumentKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PartialArgumentsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("QuickActionsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ResolvedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ToolName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("UserId", "CreatedAtUtc"); + + b.ToTable("PendingClarifications"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ProcessedPlayNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MessageId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ProcessedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("MessageId") + .IsUnique(); + + b.ToTable("ProcessedPlayNotifications"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ProcessedRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RequestType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ResponseBody") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAtUtc"); + + b.HasIndex("UserId", "IdempotencyKey", "RequestType") + .IsUnique(); + + b.ToTable("ProcessedRequests"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ProcessedStripeEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EventId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ProcessedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("EventId") + .IsUnique(); + + b.ToTable("ProcessedStripeEvents"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.PushSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Auth") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Endpoint") + .IsRequired() + .HasColumnType("text"); + + b.Property("P256dh") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Endpoint") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("PushSubscriptions"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Referral", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferredUserId") + .HasColumnType("uuid"); + + b.Property("ReferrerId") + .HasColumnType("uuid"); + + b.Property("RewardGrantedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("ReferredUserId") + .IsUnique(); + + b.HasIndex("ReferrerId"); + + b.ToTable("Referrals"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Report", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CheerId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Details") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ReportedUserId") + .HasColumnType("uuid"); + + b.Property("ReporterId") + .HasColumnType("uuid"); + + b.Property("ReviewedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("CheerId"); + + b.HasIndex("ReportedUserId"); + + b.HasIndex("ReporterId"); + + b.HasIndex("Status"); + + b.ToTable("Reports"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.SentProactiveCheckin", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("SentAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Date") + .IsUnique(); + + b.ToTable("SentProactiveCheckins"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.SentReminder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("MinutesBefore") + .HasColumnType("integer"); + + b.Property("ReminderTimeUtc") + .HasColumnType("time without time zone"); + + b.Property("SentAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("When") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("HabitId", "Date", "MinutesBefore", "ReminderTimeUtc", "When") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("HabitId", "Date", "MinutesBefore", "ReminderTimeUtc", "When"), false); + + b.ToTable("SentReminders"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.SentSlipAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("SentAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WeekStart") + .HasColumnType("date"); + + b.HasKey("Id"); + + b.HasIndex("HabitId", "WeekStart") + .IsUnique(); + + b.ToTable("SentSlipAlerts"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.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() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "IsDeleted"); + + b.HasIndex("UserId", "Name") + .IsUnique() + .HasFilter("\"IsDeleted\" = FALSE"); + + b.HasIndex("UserId", "UpdatedAtUtc"); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AdRewardBonusMessages") + .HasColumnType("integer"); + + b.Property("AdRewardsClaimedToday") + .HasColumnType("integer"); + + b.Property("AiMemoryEnabled") + .HasColumnType("boolean"); + + b.Property("AiMessagesResetAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AiMessagesUsedThisMonth") + .HasColumnType("integer"); + + b.Property("AiSummaryEnabled") + .HasColumnType("boolean"); + + b.Property("ColorScheme") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentStreak") + .HasColumnType("integer"); + + b.Property("DeactivatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("GoogleAccessToken") + .HasColumnType("text"); + + b.Property("GoogleCalendarAutoSyncEnabled") + .HasColumnType("boolean"); + + b.Property("GoogleCalendarAutoSyncStatus") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("GoogleCalendarLastSyncError") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("GoogleCalendarLastSyncedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GoogleCalendarSelectedIds") + .HasColumnType("text"); + + b.Property("GoogleCalendarSyncReconciledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GoogleRefreshToken") + .HasColumnType("text"); + + b.Property("Handle") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("HasCompletedOnboarding") + .HasColumnType("boolean"); + + b.Property("HasCompletedOnboardingChecklist") + .HasColumnType("boolean"); + + b.Property("HasCompletedTour") + .HasColumnType("boolean"); + + b.Property("HasCreatedFirstHabit") + .HasColumnType("boolean"); + + b.Property("HasImportedCalendar") + .HasColumnType("boolean"); + + b.Property("HasLoggedFirstHabit") + .HasColumnType("boolean"); + + b.Property("HasSeenImportPrompt") + .HasColumnType("boolean"); + + b.Property("HasTriedAstra") + .HasColumnType("boolean"); + + b.Property("IsAdmin") + .HasColumnType("boolean"); + + b.Property("IsDeactivated") + .HasColumnType("boolean"); + + b.Property("IsLifetimePro") + .HasColumnType("boolean"); + + b.Property("Language") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("LastActiveDate") + .HasColumnType("date"); + + b.Property("LastAdRewardAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastAdRewardLocalDate") + .HasColumnType("date"); + + b.Property("LastFreezeAwardStreak") + .HasColumnType("integer"); + + b.Property("Level") + .HasColumnType("integer"); + + b.Property("LongestStreak") + .HasColumnType("integer"); + + b.Property("MarketingConsentUpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MarketingEmailConsent") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Plan") + .HasColumnType("integer"); + + b.Property("PlanExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayPurchaseToken") + .HasColumnType("text"); + + b.Property("ProactiveAstraEnabled") + .HasColumnType("boolean"); + + b.Property("PublicProfileShowAchievements") + .HasColumnType("boolean"); + + b.Property("PublicProfileShowLevel") + .HasColumnType("boolean"); + + b.Property("PublicProfileShowStreak") + .HasColumnType("boolean"); + + b.Property("PublicProfileShowTopHabits") + .HasColumnType("boolean"); + + b.Property("PublicProfileSlug") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ReferralCode") + .HasColumnType("text"); + + b.Property("ReferralCouponId") + .HasColumnType("text"); + + b.Property("ReferredByUserId") + .HasColumnType("uuid"); + + b.Property("ScheduledDeletionAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SocialOptIn") + .HasColumnType("boolean"); + + b.Property("StreakFreezesAccumulated") + .HasColumnType("integer"); + + b.Property("StripeCustomerId") + .HasColumnType("text"); + + b.Property("StripeSubscriptionId") + .HasColumnType("text"); + + b.Property("SubscriptionInterval") + .HasColumnType("integer"); + + b.Property("SubscriptionSource") + .HasColumnType("integer"); + + b.Property("ThemePreference") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("TimeZone") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TotalXp") + .HasColumnType("integer"); + + b.Property("TrialEndsAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WeekStartDay") + .HasColumnType("integer"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("PlayPurchaseToken") + .IsUnique() + .HasFilter("\"PlayPurchaseToken\" IS NOT NULL"); + + b.HasIndex("PublicProfileSlug") + .IsUnique() + .HasFilter("\"PublicProfileSlug\" IS NOT NULL"); + + b.HasIndex("ReferralCode") + .IsUnique() + .HasFilter("\"ReferralCode\" IS NOT NULL"); + + b.HasIndex("GoogleCalendarAutoSyncEnabled", "GoogleCalendarLastSyncedAt") + .HasFilter("\"GoogleCalendarAutoSyncEnabled\" = TRUE"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.UserAchievement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AchievementId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EarnedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "AchievementId") + .IsUnique(); + + b.ToTable("UserAchievements"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.UserFact", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Category") + .HasColumnType("text"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExtractedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FactText") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "IsDeleted"); + + b.ToTable("UserFacts"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUsedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("UserSessions"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.XpAwardLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("integer"); + + b.Property("AwardedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SourceId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "AwardedAtUtc"); + + b.ToTable("XpAwardLogs"); + }); + + modelBuilder.Entity("HabitGoals", b => + { + b.HasOne("Orbit.Domain.Entities.Goal", null) + .WithMany() + .HasForeignKey("GoalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany() + .HasForeignKey("HabitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("HabitTags", b => + { + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany() + .HasForeignKey("HabitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AccountabilityCheckIn", b => + { + b.HasOne("Orbit.Domain.Entities.AccountabilityPair", null) + .WithMany() + .HasForeignKey("PairId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AccountabilityPair", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("AddresseeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AccountabilityPairHabit", b => + { + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany() + .HasForeignKey("HabitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.AccountabilityPair", null) + .WithMany() + .HasForeignKey("PairId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AiFactExtractionBatch", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ApiKey", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.BlockedUser", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("BlockedId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("BlockerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Challenge", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("CreatorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ChallengeParticipant", b => + { + b.HasOne("Orbit.Domain.Entities.Challenge", null) + .WithMany("Participants") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ChallengeParticipantHabit", b => + { + b.HasOne("Orbit.Domain.Entities.ChallengeParticipant", null) + .WithMany("LinkedHabits") + .HasForeignKey("ChallengeParticipantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany() + .HasForeignKey("HabitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ChecklistTemplate", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Cheer", b => + { + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany() + .HasForeignKey("HabitId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("RecipientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("SenderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.FriendFeedEvent", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("ActorUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Friendship", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("AddresseeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.GoalProgressLog", b => + { + b.HasOne("Orbit.Domain.Entities.Goal", null) + .WithMany("ProgressLogs") + .HasForeignKey("GoalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.GoogleCalendarSyncSuggestion", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Habit", b => + { + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany("Children") + .HasForeignKey("ParentHabitId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.HabitLog", b => + { + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany("Logs") + .HasForeignKey("HabitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.PendingClarification", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ProcessedRequest", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.PushSubscription", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Report", b => + { + b.HasOne("Orbit.Domain.Entities.Cheer", null) + .WithMany() + .HasForeignKey("CheerId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("ReportedUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("ReporterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.SentProactiveCheckin", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.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.Tag", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.UserSession", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.XpAwardLog", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Challenge", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ChallengeParticipant", b => + { + b.Navigation("LinkedHabits"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Goal", b => + { + b.Navigation("ProgressLogs"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Habit", b => + { + b.Navigation("Children"); + + b.Navigation("Logs"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Orbit.Infrastructure/Migrations/20260808042801_CollapseYearlyProIntoPro.cs b/src/Orbit.Infrastructure/Migrations/20260808042801_CollapseYearlyProIntoPro.cs new file mode 100644 index 00000000..adc01d77 --- /dev/null +++ b/src/Orbit.Infrastructure/Migrations/20260808042801_CollapseYearlyProIntoPro.cs @@ -0,0 +1,32 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Orbit.Infrastructure.Migrations +{ + /// + public partial class CollapseYearlyProIntoPro : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.UpdateData( + table: "AppFeatureFlags", + keyColumn: "Key", + keyValue: "ai_retrospective", + column: "PlanRequirement", + value: "Pro"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.UpdateData( + table: "AppFeatureFlags", + keyColumn: "Key", + keyValue: "ai_retrospective", + column: "PlanRequirement", + value: string.Concat("Yearly", "Pro")); + } + } +} diff --git a/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs b/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs index b6a46fb3..a81e90d1 100644 --- a/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs +++ b/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs @@ -530,7 +530,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) Key = "ai_retrospective", Description = "AI retrospective analysis", Enabled = true, - PlanRequirement = "YearlyPro", + PlanRequirement = "Pro", UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) }, new diff --git a/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs b/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs index 75c65069..3890c351 100644 --- a/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs +++ b/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs @@ -496,7 +496,7 @@ private static void ConfigureAppFeatureFlagEntity(ModelBuilder modelBuilder) new { Key = "offline_mode", Enabled = true, PlanRequirement = (string?)null, Description = (string?)"Enable offline mode with background sync", UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc) }, new { Key = "ai_chat", Enabled = true, PlanRequirement = (string?)"Free", Description = (string?)"AI chat assistant", UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc) }, new { Key = "ai_summary", Enabled = true, PlanRequirement = (string?)"Pro", Description = (string?)"AI daily summary", UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc) }, - new { Key = "ai_retrospective", Enabled = true, PlanRequirement = (string?)"YearlyPro", Description = (string?)"AI retrospective analysis", UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc) }, + new { Key = "ai_retrospective", Enabled = true, PlanRequirement = (string?)"Pro", Description = (string?)"AI retrospective analysis", UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc) }, new { Key = "sub_habits", Enabled = true, PlanRequirement = (string?)"Pro", Description = (string?)"Sub-habit nesting", UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc) }, new { Key = "goal_tracking", Enabled = true, PlanRequirement = (string?)"Pro", Description = (string?)"Goal tracking with progress", UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc) }, new { Key = "push_notifications", Enabled = true, PlanRequirement = (string?)null, Description = (string?)"Push notification reminders", UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc) }, diff --git a/src/Orbit.Infrastructure/Services/AgentCatalogService.Capabilities.cs b/src/Orbit.Infrastructure/Services/AgentCatalogService.Capabilities.cs index 4ca0a6b6..21c7267c 100644 --- a/src/Orbit.Infrastructure/Services/AgentCatalogService.Capabilities.cs +++ b/src/Orbit.Infrastructure/Services/AgentCatalogService.Capabilities.cs @@ -290,14 +290,14 @@ private static AgentCapability[] HabitBulkAndInsightCapabilities() CreateCapability( AgentCapabilityIds.RetrospectiveRead, "Read Retrospective", - "Reads the yearly premium retrospective.", + "Reads the Pro retrospective.", "habits", AgentScopes.ReadHabits, AgentRiskClass.Low, isMutation: false, isPhaseOneReadOnly: false, AgentConfirmationRequirement.None, - planRequirement: "YearlyPro", + planRequirement: "Pro", featureFlagKeys: ["ai_retrospective"], chatTools: ["get_retrospective"], mcpTools: ["get_retrospective"], diff --git a/src/Orbit.Infrastructure/Services/AgentPolicyEvaluator.cs b/src/Orbit.Infrastructure/Services/AgentPolicyEvaluator.cs index 31f79f32..d9737485 100644 --- a/src/Orbit.Infrastructure/Services/AgentPolicyEvaluator.cs +++ b/src/Orbit.Infrastructure/Services/AgentPolicyEvaluator.cs @@ -184,7 +184,6 @@ private static bool UserMeetsPlanRequirement(User user, string? planRequirement) return planRequirement.Trim().ToLowerInvariant() switch { "pro" => user.HasProAccess, - "yearlypro" or "yearly_pro" or "yearly-pro" => user.IsYearlyPro, "free" => true, _ => false }; diff --git a/src/Orbit.Infrastructure/Services/FeatureFlagService.cs b/src/Orbit.Infrastructure/Services/FeatureFlagService.cs index 6fef55e3..549549fa 100644 --- a/src/Orbit.Infrastructure/Services/FeatureFlagService.cs +++ b/src/Orbit.Infrastructure/Services/FeatureFlagService.cs @@ -53,7 +53,6 @@ private static bool UserHasFeaturePlan(User user, string planRequirement) return planRequirement.Trim().ToLowerInvariant() switch { "pro" => user.HasProAccess, - "yearlypro" or "yearly_pro" or "yearly-pro" => user.IsYearlyPro, "free" => true, _ => false }; diff --git a/src/Orbit.Infrastructure/Services/NoOpProductAnalytics.cs b/src/Orbit.Infrastructure/Services/NoOpProductAnalytics.cs index 59761182..e423dfe2 100644 --- a/src/Orbit.Infrastructure/Services/NoOpProductAnalytics.cs +++ b/src/Orbit.Infrastructure/Services/NoOpProductAnalytics.cs @@ -8,7 +8,7 @@ namespace Orbit.Infrastructure.Services; /// public sealed class NoOpProductAnalytics : IProductAnalytics { - public void CaptureUserEvent(Guid userId, string eventName, string plan, bool isYearlyPro) + public void CaptureUserEvent(Guid userId, string eventName, string plan) { } } diff --git a/src/Orbit.Infrastructure/Services/PostHogProductAnalytics.cs b/src/Orbit.Infrastructure/Services/PostHogProductAnalytics.cs index 2e010899..f195cc8b 100644 --- a/src/Orbit.Infrastructure/Services/PostHogProductAnalytics.cs +++ b/src/Orbit.Infrastructure/Services/PostHogProductAnalytics.cs @@ -12,7 +12,7 @@ public sealed partial class PostHogProductAnalytics( IPostHogClient postHogClient, ILogger logger) : IProductAnalytics { - public void CaptureUserEvent(Guid userId, string eventName, string plan, bool isYearlyPro) + public void CaptureUserEvent(Guid userId, string eventName, string plan) { var distinctId = userId.ToString(); @@ -20,8 +20,7 @@ public void CaptureUserEvent(Guid userId, string eventName, string plan, bool is { ["$set"] = new Dictionary { - ["plan"] = plan, - ["isYearlyPro"] = isYearlyPro + ["plan"] = plan } }); diff --git a/tests/Orbit.Application.Tests/Commands/Auth/GoogleAuthCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Auth/GoogleAuthCommandHandlerTests.cs index 356a8f89..4cf77840 100644 --- a/tests/Orbit.Application.Tests/Commands/Auth/GoogleAuthCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Auth/GoogleAuthCommandHandlerTests.cs @@ -180,7 +180,7 @@ public async Task Handle_NewUserProvisioned_CapturesSignupCompleted() await _handler.Handle(new GoogleAuthCommand("valid-token"), CancellationToken.None); created.Should().NotBeNull(); - _analytics.Received(1).CaptureUserEvent(created!.Id, "signup_completed", "Free", false); + _analytics.Received(1).CaptureUserEvent(created!.Id, "signup_completed", "Free"); } [Fact] @@ -192,7 +192,7 @@ public async Task Handle_ReturningUser_DoesNotCaptureSignupCompleted() await _handler.Handle(new GoogleAuthCommand("valid-token"), CancellationToken.None); _analytics.DidNotReceive().CaptureUserEvent( - Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + Arg.Any(), Arg.Any(), Arg.Any()); } private void SetupGoogleTokenResponse(string email, string name) diff --git a/tests/Orbit.Application.Tests/Commands/Auth/VerifyCodeCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Auth/VerifyCodeCommandHandlerTests.cs index 7b389e8e..e21d9f8c 100644 --- a/tests/Orbit.Application.Tests/Commands/Auth/VerifyCodeCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Auth/VerifyCodeCommandHandlerTests.cs @@ -178,7 +178,7 @@ public async Task Handle_NewUser_CapturesSignupCompleted() await _handler.Handle(new VerifyCodeCommand(TestEmail, "123456"), CancellationToken.None); created.Should().NotBeNull(); - _analytics.Received(1).CaptureUserEvent(created!.Id, "signup_completed", "Free", false); + _analytics.Received(1).CaptureUserEvent(created!.Id, "signup_completed", "Free"); } [Fact] @@ -190,7 +190,7 @@ public async Task Handle_ExistingUser_DoesNotCaptureSignupCompleted() await _handler.Handle(new VerifyCodeCommand(TestEmail, "123456"), CancellationToken.None); _analytics.DidNotReceive().CaptureUserEvent( - Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] @@ -198,7 +198,7 @@ public async Task Handle_CaptureThrows_SignupStillSucceeds() { SetupCacheWithCode("123456"); _analytics - .When(a => a.CaptureUserEvent(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())) + .When(a => a.CaptureUserEvent(Arg.Any(), Arg.Any(), Arg.Any())) .Do(_ => throw new InvalidOperationException("PostHog unreachable")); var result = await _handler.Handle(new VerifyCodeCommand(TestEmail, "123456"), CancellationToken.None); diff --git a/tests/Orbit.Application.Tests/Commands/Subscriptions/HandleWebhookCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Subscriptions/HandleWebhookCommandHandlerTests.cs index 9a3e1934..d2b9528c 100644 --- a/tests/Orbit.Application.Tests/Commands/Subscriptions/HandleWebhookCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Subscriptions/HandleWebhookCommandHandlerTests.cs @@ -727,11 +727,11 @@ public async Task Handle_CheckoutSessionCompleted_CapturesSubscriptionStarted() await _handler.Handle(new HandleWebhookCommand(json, signature), CancellationToken.None); - _analytics.Received(1).CaptureUserEvent(user.Id, "subscription_started", "Pro", false); + _analytics.Received(1).CaptureUserEvent(user.Id, "subscription_started", "Pro"); } [Fact] - public async Task Handle_CheckoutSessionCompleted_YearlyPlan_CapturesIsYearlyProTrue() + public async Task Handle_CheckoutSessionCompleted_AnnualPlan_CapturesSubscriptionStarted() { var user = User.Create("Thomas", "test@example.com").Value; _userRepo.FindOneTrackedIgnoringFiltersAsync( @@ -748,7 +748,7 @@ public async Task Handle_CheckoutSessionCompleted_YearlyPlan_CapturesIsYearlyPro await _handler.Handle(new HandleWebhookCommand(json, signature), CancellationToken.None); - _analytics.Received(1).CaptureUserEvent(user.Id, "subscription_started", "Pro", true); + _analytics.Received(1).CaptureUserEvent(user.Id, "subscription_started", "Pro"); } [Fact] @@ -770,7 +770,7 @@ public async Task Handle_InvoicePaid_CapturesSubscriptionRenewed() await _handler.Handle(new HandleWebhookCommand(json, signature), CancellationToken.None); - _analytics.Received(1).CaptureUserEvent(user.Id, "subscription_renewed", "Pro", false); + _analytics.Received(1).CaptureUserEvent(user.Id, "subscription_renewed", "Pro"); } [Fact] @@ -789,7 +789,7 @@ public async Task Handle_SubscriptionDeleted_CapturesSubscriptionCanceled() await _handler.Handle(new HandleWebhookCommand(json, signature), CancellationToken.None); - _analytics.Received(1).CaptureUserEvent(user.Id, "subscription_canceled", "Free", false); + _analytics.Received(1).CaptureUserEvent(user.Id, "subscription_canceled", "Free"); } [Fact] @@ -808,7 +808,7 @@ public async Task Handle_SubscriptionUpdated_CapturesSubscriptionUpdated() await _handler.Handle(new HandleWebhookCommand(json, signature), CancellationToken.None); - _analytics.Received(1).CaptureUserEvent(user.Id, "subscription_updated", "Pro", false); + _analytics.Received(1).CaptureUserEvent(user.Id, "subscription_updated", "Pro"); } [Fact] @@ -825,7 +825,7 @@ public async Task Handle_CaptureThrows_WebhookStillSucceedsSoStripeDoesNotRetry( .Returns(CreateMockSubscription("sub_test")); _analytics - .When(a => a.CaptureUserEvent(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())) + .When(a => a.CaptureUserEvent(Arg.Any(), Arg.Any(), Arg.Any())) .Do(_ => throw new InvalidOperationException("PostHog unreachable")); var (json, signature) = BuildSignedEvent("checkout.session.completed", BuildCheckoutSessionJson( @@ -860,7 +860,7 @@ public async Task Handle_ReplayedEventDedupedByProcessedStripeEvent_DoesNotCaptu result.IsSuccess.Should().BeTrue(); _analytics.DidNotReceive().CaptureUserEvent( - Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] @@ -890,7 +890,7 @@ public async Task Handle_NoOpAnalyticsBound_CapturesNothingAndLeavesHandlerResul user.IsPro.Should().BeTrue(); await _unitOfWork.Received().SaveChangesAsync(Arg.Any()); _analytics.DidNotReceive().CaptureUserEvent( - Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + Arg.Any(), Arg.Any(), Arg.Any()); } private static Subscription CreateMockSubscription(string subscriptionId) diff --git a/tests/Orbit.Application.Tests/Common/PayGateServiceTests.cs b/tests/Orbit.Application.Tests/Common/PayGateServiceTests.cs index cd59bbd3..3d78cd86 100644 --- a/tests/Orbit.Application.Tests/Common/PayGateServiceTests.cs +++ b/tests/Orbit.Application.Tests/Common/PayGateServiceTests.cs @@ -41,7 +41,7 @@ private static User CreateFreeUser() private static User CreateProUser() { var user = CreateFreeUser(); - user.SetStripeSubscription("sub_123", DateTime.UtcNow.AddYears(1)); + user.SetStripeSubscription("sub_123", DateTime.UtcNow.AddYears(1), SubscriptionInterval.Monthly); return user; } @@ -408,7 +408,7 @@ public async Task GetAiMessageLimit_UserNotFound_ReturnsDefault20() } [Fact] - public async Task CanUseRetrospective_YearlyProUser_Success() + public async Task CanUseRetrospective_AnnualSubscription_Success() { var user = CreateFreeUser(); user.SetStripeSubscription("sub_yearly", DateTime.UtcNow.AddYears(1), SubscriptionInterval.Yearly); @@ -420,20 +420,58 @@ public async Task CanUseRetrospective_YearlyProUser_Success() } [Fact] - public async Task CanUseRetrospective_MonthlyProUser_PayGateFailure() + public async Task CanUseRetrospective_MonthlySubscription_Success() { - var user = CreateProUser(); _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); + var user = CreateProUser(); + _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); + + var result = await _sut.CanUseRetrospective(UserId); + + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task CanUseRetrospective_LifetimePro_Success() + { + var user = CreateFreeUser(); + user.GrantLifetimePro(); + _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); + + var result = await _sut.CanUseRetrospective(UserId); + + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task CanUseRetrospective_ActiveTrial_Success() + { + var user = CreateFreeUser(); + _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); + + var result = await _sut.CanUseRetrospective(UserId); + + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task CanUseRetrospective_FreeUser_PayGateFailure() + { + var user = CreateFreeUser(); + user.StartTrial(DateTime.UtcNow.AddDays(-1)); + _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); var result = await _sut.CanUseRetrospective(UserId); result.IsFailure.Should().BeTrue(); result.ErrorCode.Should().Be("PAY_GATE"); + result.Error.Should().Be("Retrospectives are a Pro feature. Upgrade to unlock!"); } [Fact] - public async Task CanUseRetrospective_FreeUser_PayGateFailure() + public async Task CanUseRetrospective_ExpiredSubscription_PayGateFailure() { var user = CreateFreeUser(); + user.SetStripeSubscription("sub_expired", DateTime.UtcNow.AddDays(-1), SubscriptionInterval.Monthly); user.StartTrial(DateTime.UtcNow.AddDays(-1)); _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); @@ -441,6 +479,7 @@ public async Task CanUseRetrospective_FreeUser_PayGateFailure() result.IsFailure.Should().BeTrue(); result.ErrorCode.Should().Be("PAY_GATE"); + result.Error.Should().Be("Retrospectives are a Pro feature. Upgrade to unlock!"); } [Fact] diff --git a/tests/Orbit.Infrastructure.Tests/Extensions/ProductAnalyticsRegistrationTests.cs b/tests/Orbit.Infrastructure.Tests/Extensions/ProductAnalyticsRegistrationTests.cs index c7372cd4..9ad6fd57 100644 --- a/tests/Orbit.Infrastructure.Tests/Extensions/ProductAnalyticsRegistrationTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Extensions/ProductAnalyticsRegistrationTests.cs @@ -45,7 +45,7 @@ public void AddOrbitProductAnalytics_ApiKeyPresent_BindsPostHogBackedImplementat public void NoOpProductAnalytics_Capture_DoesNothingAndDoesNotThrow() { var act = () => new NoOpProductAnalytics() - .CaptureUserEvent(Guid.NewGuid(), "subscription_started", "Pro", true); + .CaptureUserEvent(Guid.NewGuid(), "subscription_started", "Pro"); act.Should().NotThrow(); } diff --git a/tests/Orbit.Infrastructure.Tests/Services/AgentPolicyEvaluatorTests.cs b/tests/Orbit.Infrastructure.Tests/Services/AgentPolicyEvaluatorTests.cs index 32be6838..a6902edd 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/AgentPolicyEvaluatorTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/AgentPolicyEvaluatorTests.cs @@ -57,6 +57,29 @@ public void Evaluate_MissingApiKeyScope_DeniesOperation() decision.Reason.Should().Be($"missing_scope:{AgentScopes.ReadHabits}"); } + [Fact] + public void Evaluate_UnknownFeaturePlanRequirement_FailsClosed() + { + var user = _dbContext.Users.Single(item => item.Id == _userId); + user.SetStripeSubscription("sub_123", DateTime.UtcNow.AddDays(30), Orbit.Domain.Enums.SubscriptionInterval.Monthly); + var unknownRequirement = string.Concat("Yearly", "Pro"); + _dbContext.AppFeatureFlags.Add(AppFeatureFlag.Create( + "ai_retrospective", true, unknownRequirement, "AI retrospective analysis")); + _dbContext.SaveChanges(); + + var decision = _policyEvaluator.Evaluate(new AgentPolicyEvaluationContext( + AgentCapabilityIds.RetrospectiveRead, + _userId, + AgentExecutionSurface.Chat, + AgentAuthMethod.Jwt, + [], + "get_retrospective", + "Get retrospective")); + + decision.Status.Should().Be(AgentPolicyDecisionStatus.Denied); + decision.Reason.Should().Be($"feature_plan_required:ai_retrospective:{unknownRequirement}"); + } + [Fact] public void Evaluate_DestructiveOperationWithoutConfirmation_CreatesPendingOperation() { diff --git a/tests/Orbit.Infrastructure.Tests/Services/FeatureFlagAndAgentSupportTests.cs b/tests/Orbit.Infrastructure.Tests/Services/FeatureFlagAndAgentSupportTests.cs index 5ab015d0..f3ce2639 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/FeatureFlagAndAgentSupportTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/FeatureFlagAndAgentSupportTests.cs @@ -69,6 +69,23 @@ public async Task FeatureFlagService_ReturnsEmptyWhenUserIsMissing() result.Should().BeEmpty(); } + [Fact] + public async Task FeatureFlagService_UnknownPlanRequirement_FailsClosed() + { + var user = User.Create("Thomas", "thomas@example.com").Value; + user.SetStripeSubscription("sub_123", DateTime.UtcNow.AddDays(30), SubscriptionInterval.Monthly); + var unknownRequirement = string.Concat("Yearly", "Pro"); + _dbContext.Users.Add(user); + _dbContext.AppFeatureFlags.Add(AppFeatureFlag.Create("stale_plan", true, unknownRequirement, "Stale plan")); + await _dbContext.SaveChangesAsync(); + + var service = new FeatureFlagService(_dbContext, new MemoryCache(new MemoryCacheOptions())); + + var result = await service.GetEnabledKeysForUserAsync(user.Id); + + result.Should().NotContain("stale_plan"); + } + [Fact] public async Task AgentTargetOwnershipService_ReturnsNullWhenNoTargetsAreProvided() { diff --git a/tests/Orbit.Infrastructure.Tests/Services/PostHogProductAnalyticsTests.cs b/tests/Orbit.Infrastructure.Tests/Services/PostHogProductAnalyticsTests.cs index 4730f147..29b6d82a 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/PostHogProductAnalyticsTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/PostHogProductAnalyticsTests.cs @@ -7,8 +7,8 @@ namespace Orbit.Infrastructure.Tests.Services; /// -/// Pins the PostHog wire shape: the Orbit user id is the distinct id, and plan/isYearlyPro -/// travel under $set so they land as person properties rather than plain event properties. +/// Pins the PostHog wire shape: the Orbit user id is the distinct id, and plan travels under +/// $set so it lands as a person property rather than a plain event property. /// public class PostHogProductAnalyticsTests { @@ -24,7 +24,7 @@ public void CaptureUserEvent_SendsUserIdAsDistinctIdWithPersonProperties() { var userId = Guid.NewGuid(); - _analytics.CaptureUserEvent(userId, "subscription_started", "Pro", true); + _analytics.CaptureUserEvent(userId, "subscription_started", "Pro"); var arguments = _postHogClient.ReceivedCalls() .Single(call => call.GetMethodInfo().Name == nameof(IPostHogClient.Capture)) @@ -36,13 +36,13 @@ public void CaptureUserEvent_SendsUserIdAsDistinctIdWithPersonProperties() var properties = arguments[2].Should().BeAssignableTo>().Subject; var personProperties = properties["$set"].Should().BeAssignableTo>().Subject; personProperties["plan"].Should().Be("Pro"); - personProperties["isYearlyPro"].Should().Be(true); + personProperties.Should().HaveCount(1); } [Fact] public void CaptureUserEvent_DoesNotStampAnExplicitTimestamp() { - _analytics.CaptureUserEvent(Guid.NewGuid(), "signup_completed", "Free", false); + _analytics.CaptureUserEvent(Guid.NewGuid(), "signup_completed", "Free"); var arguments = _postHogClient.ReceivedCalls() .Single(call => call.GetMethodInfo().Name == nameof(IPostHogClient.Capture)) From 7077fb53ed96dbda8b5af7bd7cf1a7865ce72760 Mon Sep 17 00:00:00 2001 From: thomasluizon Date: Sat, 8 Aug 2026 06:03:29 -0300 Subject: [PATCH 3/3] fix: clear retired PostHog tier property (ORB-188) --- src/Orbit.Infrastructure/Services/PostHogProductAnalytics.cs | 3 ++- .../Services/PostHogProductAnalyticsTests.cs | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Orbit.Infrastructure/Services/PostHogProductAnalytics.cs b/src/Orbit.Infrastructure/Services/PostHogProductAnalytics.cs index f195cc8b..c67680f1 100644 --- a/src/Orbit.Infrastructure/Services/PostHogProductAnalytics.cs +++ b/src/Orbit.Infrastructure/Services/PostHogProductAnalytics.cs @@ -21,7 +21,8 @@ public void CaptureUserEvent(Guid userId, string eventName, string plan) ["$set"] = new Dictionary { ["plan"] = plan - } + }, + ["$unset"] = new[] { "isYearlyPro" } }); LogCaptureEnqueued(logger, eventName, distinctId, enqueued); diff --git a/tests/Orbit.Infrastructure.Tests/Services/PostHogProductAnalyticsTests.cs b/tests/Orbit.Infrastructure.Tests/Services/PostHogProductAnalyticsTests.cs index 29b6d82a..b380a2f1 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/PostHogProductAnalyticsTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/PostHogProductAnalyticsTests.cs @@ -7,8 +7,8 @@ namespace Orbit.Infrastructure.Tests.Services; /// -/// Pins the PostHog wire shape: the Orbit user id is the distinct id, and plan travels under -/// $set so it lands as a person property rather than a plain event property. +/// Pins the PostHog wire shape: the Orbit user id is the distinct id, plan travels under +/// $set, and the retired yearly entitlement property travels under $unset. /// public class PostHogProductAnalyticsTests { @@ -37,6 +37,7 @@ public void CaptureUserEvent_SendsUserIdAsDistinctIdWithPersonProperties() var personProperties = properties["$set"].Should().BeAssignableTo>().Subject; personProperties["plan"].Should().Be("Pro"); personProperties.Should().HaveCount(1); + properties["$unset"].Should().BeEquivalentTo(new[] { "isYearlyPro" }); } [Fact]