diff --git a/src/Orbit.Api/Controllers/ProfileController.cs b/src/Orbit.Api/Controllers/ProfileController.cs index 16c3049d..f5b1e1f4 100644 --- a/src/Orbit.Api/Controllers/ProfileController.cs +++ b/src/Orbit.Api/Controllers/ProfileController.cs @@ -36,6 +36,13 @@ public record UpdatePublicProfileRequest( bool ShowTopHabits, bool Regenerate); + public record ApplyOnboardingRequest( + IReadOnlyList? Habits, + ApplyLogInput? FirstLog, + ApplyGoalInput? Goal, + int? WeekStartDay, + string? ColorScheme); + private static readonly JsonSerializerOptions ExportJsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true @@ -216,6 +223,36 @@ public async Task CompleteOnboarding(CancellationToken cancellati return result.ToPayGateAwareResult(() => NoContent()); } + [HttpPost("onboarding/apply")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task ApplyOnboarding( + [FromBody] ApplyOnboardingRequest request, + CancellationToken cancellationToken) + { + var command = new ApplyOnboardingCommand( + HttpContext.GetUserId(), + request.Habits ?? [], + request.FirstLog, + request.Goal, + request.WeekStartDay, + request.ColorScheme); + var result = await mediator.Send(command, cancellationToken); + return result.ToPayGateAwareResult(value => Ok(value)); + } + + [HttpPut("import-prompt/dismiss")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task DismissImportPrompt(CancellationToken cancellationToken) + { + var command = new DismissImportPromptCommand(HttpContext.GetUserId()); + var result = await mediator.Send(command, cancellationToken); + return result.ToPayGateAwareResult(() => NoContent()); + } + [HttpPut("tour")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status400BadRequest)] diff --git a/src/Orbit.Application/Profile/Commands/ApplyOnboardingCommand.cs b/src/Orbit.Application/Profile/Commands/ApplyOnboardingCommand.cs new file mode 100644 index 00000000..1bd355e1 --- /dev/null +++ b/src/Orbit.Application/Profile/Commands/ApplyOnboardingCommand.cs @@ -0,0 +1,252 @@ +using MediatR; +using Microsoft.Extensions.Caching.Memory; +using Orbit.Application.Behaviors; +using Orbit.Application.Common; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Enums; +using Orbit.Domain.Interfaces; +using Orbit.Domain.ValueObjects; + +namespace Orbit.Application.Profile.Commands; + +public record ApplyHabitInput( + string Title, + string? Description, + string? Emoji, + FrequencyUnit? FrequencyUnit, + int? FrequencyQuantity, + IReadOnlyList? Days = null, + bool IsBadHabit = false, + bool IsGeneral = false, + bool IsFlexible = false, + DateOnly? DueDate = null, + TimeOnly? DueTime = null, + bool ReminderEnabled = false, + IReadOnlyList? ReminderTimes = null, + IReadOnlyList? ChecklistItems = null); + +public record ApplyLogInput(int HabitIndex, DateOnly Date); + +public record ApplyGoalInput( + string Title, + string? Description, + decimal TargetValue, + string Unit, + DateOnly? Deadline = null, + GoalType Type = GoalType.Standard); + +public record ApplyOnboardingResponse( + bool Applied, + int CreatedHabitCount, + bool CreatedGoal, + bool LoggedFirstHabit); + +public record ApplyOnboardingCommand( + Guid UserId, + IReadOnlyList Habits, + ApplyLogInput? FirstLog, + ApplyGoalInput? Goal, + int? WeekStartDay, + string? ColorScheme) : IRequest>, IConcurrencyRetryable; + +/// +/// Applies the buffer of answers a user built during pre-auth onboarding in a single transaction: +/// creates the habits (trimmed to the free-plan allowance), an optional first log, an optional +/// Pro-gated goal, week-start/color preferences, and flips HasCompletedOnboarding. Idempotent +/// by construction — an already-onboarded user is a no-op (Applied:false) — so the client can +/// flush unconditionally after any successful auth and retry safely under the concurrency pipeline. +/// +public class ApplyOnboardingCommandHandler( + IGenericRepository userRepository, + IGenericRepository habitRepository, + IGenericRepository goalRepository, + IPayGateService payGate, + IUserDateService userDateService, + IAppConfigService appConfig, + IUnitOfWork unitOfWork, + IMemoryCache cache) : IRequestHandler> +{ + public async Task> Handle( + ApplyOnboardingCommand request, CancellationToken cancellationToken) + { + Result? failure = null; + ApplyOnboardingResponse? response = null; + + await unitOfWork.ExecuteInTransactionAsync(async ct => + { + await unitOfWork.AcquireAdvisoryLockAsync($"onboarding-apply:{request.UserId}", ct); + + var user = await userRepository.FindOneTrackedAsync( + u => u.Id == request.UserId, cancellationToken: ct); + + if (user is null) + { + failure = Result.Failure(ErrorMessages.UserNotFound); + return; + } + + if (user.HasCompletedOnboarding) + { + response = new ApplyOnboardingResponse(false, 0, false, false); + return; + } + + var today = await userDateService.GetUserTodayAsync(request.UserId, ct); + + var habitsToCreate = await TrimToAllowanceAsync(user, request.Habits, ct); + + var createResult = await CreateHabitsAsync(request.UserId, habitsToCreate, today, ct); + if (createResult.IsFailure) + { + failure = createResult.PropagateError(); + return; + } + + var createdHabits = createResult.Value; + + var loggedFirstHabit = false; + if (request.FirstLog is { } firstLog + && firstLog.HabitIndex >= 0 + && firstLog.HabitIndex < createdHabits.Count) + { + var logResult = createdHabits[firstLog.HabitIndex].Log(firstLog.Date); + if (logResult.IsFailure) + { + failure = logResult.PropagateError(); + return; + } + loggedFirstHabit = true; + } + + var goalResult = await CreateGoalIfAllowedAsync(request.UserId, request.Goal, today, ct); + if (goalResult.IsFailure) + { + failure = goalResult.PropagateError(); + return; + } + var createdGoal = goalResult.Value; + + var prefsResult = ApplyPreferences(user, request.WeekStartDay, request.ColorScheme); + if (prefsResult.IsFailure) + { + failure = prefsResult.PropagateError(); + return; + } + + user.CompleteOnboarding(); + + await unitOfWork.SaveChangesAsync(ct); + + response = new ApplyOnboardingResponse(true, createdHabits.Count, createdGoal, loggedFirstHabit); + }, cancellationToken); + + if (failure is not null) + return failure; + + if (response!.Applied) + CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId); + + return Result.Success(response); + } + + private async Task> TrimToAllowanceAsync( + User user, IReadOnlyList habits, CancellationToken cancellationToken) + { + if (user.HasProAccess) + return habits; + + var maxHabits = await appConfig.GetAsync( + AppConfigKeys.FreeMaxHabits, AppConstants.DefaultFreeMaxHabits, cancellationToken); + var existingRoots = await habitRepository.CountAsync( + h => h.UserId == user.Id && h.ParentHabitId == null, cancellationToken); + var allowance = Math.Max(0, maxHabits - existingRoots); + + return allowance >= habits.Count ? habits : habits.Take(allowance).ToList(); + } + + private async Task>> CreateHabitsAsync( + Guid userId, IReadOnlyList habits, DateOnly today, CancellationToken cancellationToken) + { + var createdHabits = new List(); + var position = 0; + + foreach (var item in habits) + { + var habitResult = Habit.Create(new HabitCreateParams( + userId, + item.Title, + item.FrequencyUnit, + item.FrequencyQuantity, + item.Description, + Emoji: item.Emoji, + Days: item.Days, + IsBadHabit: item.IsBadHabit, + DueDate: item.DueDate ?? today, + DueTime: item.DueTime, + ReminderEnabled: item.ReminderEnabled, + ReminderTimes: item.ReminderTimes, + ChecklistItems: item.ChecklistItems, + IsGeneral: item.IsGeneral, + IsFlexible: item.IsFlexible, + Position: position++)); + + if (habitResult.IsFailure) + return habitResult.PropagateError>(); + + await habitRepository.AddAsync(habitResult.Value, cancellationToken); + createdHabits.Add(habitResult.Value); + } + + return Result.Success(createdHabits); + } + + private async Task> CreateGoalIfAllowedAsync( + Guid userId, ApplyGoalInput? goalInput, DateOnly today, CancellationToken cancellationToken) + { + if (goalInput is null) + return Result.Success(false); + + var goalGate = await payGate.CanAccessGoals(userId, cancellationToken); + if (goalGate.IsFailure) + return Result.Success(false); + + if (goalInput.Deadline is { } deadline && deadline < today) + return Result.Failure(ErrorMessages.DeadlineInPast); + + var goalResult = Goal.Create(new Goal.CreateGoalParams( + userId, + goalInput.Title, + goalInput.TargetValue, + goalInput.Unit, + goalInput.Description, + goalInput.Deadline, + 0, + goalInput.Type)); + + if (goalResult.IsFailure) + return goalResult.PropagateError(); + + await goalRepository.AddAsync(goalResult.Value, cancellationToken); + return Result.Success(true); + } + + private static Result ApplyPreferences(User user, int? weekStartDay, string? colorScheme) + { + if (weekStartDay is { } day) + { + var weekStartResult = user.SetWeekStartDay(day); + if (weekStartResult.IsFailure) + return weekStartResult; + } + + if (colorScheme is not null) + { + var colorResult = user.SetColorScheme(colorScheme); + if (colorResult.IsFailure) + return colorResult; + } + + return Result.Success(); + } +} diff --git a/src/Orbit.Application/Profile/Commands/DismissImportPromptCommand.cs b/src/Orbit.Application/Profile/Commands/DismissImportPromptCommand.cs new file mode 100644 index 00000000..fdf2bb45 --- /dev/null +++ b/src/Orbit.Application/Profile/Commands/DismissImportPromptCommand.cs @@ -0,0 +1,36 @@ +using MediatR; +using Orbit.Application.Behaviors; +using Orbit.Application.Common; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Profile.Commands; + +public record DismissImportPromptCommand(Guid UserId) : IRequest, IConcurrencyRetryable; + +/// +/// Marks the one-time "import from another app?" prompt as seen for the user. Not pay-gated: the +/// prompt is shown to every account exactly once, so every account must be able to dismiss it +/// permanently regardless of plan. +/// +public class DismissImportPromptCommandHandler( + IGenericRepository userRepository, + IUnitOfWork unitOfWork) : IRequestHandler +{ + public async Task Handle(DismissImportPromptCommand request, CancellationToken cancellationToken) + { + var user = await userRepository.FindOneTrackedAsync( + u => u.Id == request.UserId, + cancellationToken: cancellationToken); + + if (user is null) + return Result.Failure(ErrorMessages.UserNotFound); + + user.MarkImportPromptSeen(); + + await unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/src/Orbit.Application/Profile/Queries/GetProfileQuery.cs b/src/Orbit.Application/Profile/Queries/GetProfileQuery.cs index c57aa9a1..a80b04b7 100644 --- a/src/Orbit.Application/Profile/Queries/GetProfileQuery.cs +++ b/src/Orbit.Application/Profile/Queries/GetProfileQuery.cs @@ -31,6 +31,7 @@ public record ProfileResponse( int AiMessagesUsed, int AiMessagesLimit, bool HasImportedCalendar, + bool HasSeenImportPrompt, bool HasGoogleConnection, string? SubscriptionInterval, string? SubscriptionSource, @@ -117,6 +118,7 @@ public async Task> Handle(GetProfileQuery request, Cance user.AiMessagesUsedThisMonth, aiMessageLimit, user.HasImportedCalendar, + user.HasSeenImportPrompt, user.GoogleAccessToken is not null, user.SubscriptionInterval?.ToString().ToLowerInvariant(), user.SubscriptionSource.ToApiValue(), diff --git a/src/Orbit.Application/Profile/Validators/ApplyOnboardingCommandValidator.cs b/src/Orbit.Application/Profile/Validators/ApplyOnboardingCommandValidator.cs new file mode 100644 index 00000000..7005a780 --- /dev/null +++ b/src/Orbit.Application/Profile/Validators/ApplyOnboardingCommandValidator.cs @@ -0,0 +1,60 @@ +using FluentValidation; +using Orbit.Application.Common; +using Orbit.Application.Habits.Validators; +using Orbit.Application.Profile.Commands; + +namespace Orbit.Application.Profile.Validators; + +public class ApplyHabitInputValidator : AbstractValidator +{ + public ApplyHabitInputValidator() + { + SharedHabitRules.AddTitleRules(RuleFor(x => x.Title)); + SharedHabitRules.AddDescriptionRules(RuleFor(x => x.Description)); + SharedHabitRules.AddEmojiRules(RuleFor(x => x.Emoji)); + SharedHabitRules.AddChecklistItemRules(RuleFor(x => x.ChecklistItems)); + + RuleFor(x => x.FrequencyQuantity) + .GreaterThan(0) + .When(x => x.FrequencyQuantity is not null); + + RuleFor(x => x.FrequencyQuantity) + .NotNull() + .WithMessage("Frequency quantity is required when frequency unit is set") + .When(x => x.FrequencyUnit is not null); + + SharedHabitRules.AddReminderTimesRules(RuleFor(x => x.ReminderTimes)); + SharedHabitRules.AddDaysRules(this, x => x.Days, x => x.FrequencyQuantity, x => x.FrequencyUnit, x => x.IsFlexible); + SharedHabitRules.AddGeneralHabitRules(this, x => x.IsGeneral, x => x.FrequencyUnit, x => x.FrequencyQuantity, x => x.Days); + } +} + +public class ApplyGoalInputValidator : AbstractValidator +{ + public ApplyGoalInputValidator() + { + RuleFor(x => x.Title).NotEmpty().MaximumLength(200); + RuleFor(x => x.Description).MaximumLength(AppConstants.MaxGoalDescriptionLength); + RuleFor(x => x.TargetValue).GreaterThan(0); + RuleFor(x => x.Unit).NotEmpty().MaximumLength(50); + RuleFor(x => x.Type).IsInEnum(); + } +} + +public class ApplyOnboardingCommandValidator : AbstractValidator +{ + public ApplyOnboardingCommandValidator() + { + RuleFor(x => x.Habits).NotNull(); + RuleFor(x => x.Habits.Count).LessThanOrEqualTo(AppConstants.MaxBulkOperationSize) + .When(x => x.Habits is not null); + RuleForEach(x => x.Habits).SetValidator(new ApplyHabitInputValidator()); + + When(x => x.Goal is not null, () => + RuleFor(x => x.Goal!).SetValidator(new ApplyGoalInputValidator())); + + When(x => x.WeekStartDay is not null, () => + RuleFor(x => x.WeekStartDay!.Value).Must(day => day is 0 or 1) + .WithMessage("Week start day must be 0 (Sunday) or 1 (Monday).")); + } +} diff --git a/src/Orbit.Domain/Entities/User.cs b/src/Orbit.Domain/Entities/User.cs index 7283d22b..d04d9782 100644 --- a/src/Orbit.Domain/Entities/User.cs +++ b/src/Orbit.Domain/Entities/User.cs @@ -39,6 +39,7 @@ public partial class User : Entity public string? PlayPurchaseToken { get; private set; } public DateTime CreatedAtUtc { get; private set; } public bool HasImportedCalendar { get; private set; } = false; + public bool HasSeenImportPrompt { get; private set; } = false; public string? GoogleAccessToken { get; private set; } public string? GoogleRefreshToken { get; private set; } public bool GoogleCalendarAutoSyncEnabled { get; private set; } @@ -307,6 +308,8 @@ public void SetGoogleTokens(string accessToken, string? refreshToken) public void MarkCalendarImported() => HasImportedCalendar = true; + public void MarkImportPromptSeen() => HasSeenImportPrompt = true; + public Result EnableCalendarAutoSync() { if (!HasProAccess) diff --git a/src/Orbit.Infrastructure/Migrations/20260705031342_AddHasSeenImportPrompt.Designer.cs b/src/Orbit.Infrastructure/Migrations/20260705031342_AddHasSeenImportPrompt.Designer.cs new file mode 100644 index 00000000..5b9669c2 --- /dev/null +++ b/src/Orbit.Infrastructure/Migrations/20260705031342_AddHasSeenImportPrompt.Designer.cs @@ -0,0 +1,2497 @@ +// +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("20260705031342_AddHasSeenImportPrompt")] + partial class AddHasSeenImportPrompt + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("HabitGoals", b => + { + b.Property("GoalId") + .HasColumnType("uuid"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.HasKey("GoalId", "HabitId"); + + b.HasIndex("HabitId"); + + b.ToTable("HabitGoals"); + }); + + modelBuilder.Entity("HabitTags", b => + { + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("TagId") + .HasColumnType("uuid"); + + b.HasKey("HabitId", "TagId"); + + b.HasIndex("TagId"); + + b.ToTable("HabitTags"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.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 = "YearlyPro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "sub_habits", + Description = "Sub-habit nesting", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "goal_tracking", + Description = "Goal tracking with progress", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "push_notifications", + Description = "Push notification reminders", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "scheduled_reminders", + Description = "Custom scheduled reminders", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "slip_alerts", + Description = "Slip detection alerts", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "checklist_templates", + Description = "Reusable checklist templates", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "habit_duplication", + Description = "Duplicate habits", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "bulk_operations", + Description = "Bulk create/delete/log habits", + Enabled = true, + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "calendar_integration", + Description = "Google Calendar integration", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }, + new + { + Key = "api_keys", + Description = "Personal API keys", + Enabled = true, + PlanRequirement = "Pro", + UpdatedAtUtc = new DateTime(2026, 4, 1, 0, 0, 0, 0, DateTimeKind.Utc) + }); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.BlockedUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BlockedId") + .HasColumnType("uuid"); + + b.Property("BlockerId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("BlockedId"); + + b.HasIndex("BlockerId", "BlockedId") + .IsUnique(); + + b.ToTable("BlockedUsers"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.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.ToTable("ChecklistTemplates"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Cheer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("Note") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RecipientId") + .HasColumnType("uuid"); + + b.Property("SenderId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("HabitId"); + + b.HasIndex("RecipientId"); + + b.HasIndex("SenderId", "CreatedAtUtc"); + + b.ToTable("Cheers"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ContentBlock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Key", "Locale") + .IsUnique(); + + b.ToTable("ContentBlocks"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.DistributedRateLimitBucket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Count") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PartitionKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PolicyName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WindowEndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WindowStartUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PolicyName", "PartitionKey", "WindowStartUtc") + .IsUnique(); + + b.ToTable("DistributedRateLimitBuckets"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.FriendFeedEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AchievementId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ActorUserId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Value") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId", "AchievementId") + .IsUnique() + .HasFilter("\"AchievementId\" IS NOT NULL"); + + b.HasIndex("ActorUserId", "CreatedAtUtc", "Id") + .IsDescending(false, true, true); + + b.HasIndex("ActorUserId", "Type", "Value") + .IsUnique() + .HasFilter("\"AchievementId\" IS NULL"); + + b.ToTable("FriendFeedEvents"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Friendship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddresseeId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("RespondedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("AddresseeId"); + + b.HasIndex("RequesterId"); + + b.ToTable("Friendships"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Goal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentValue") + .HasColumnType("numeric"); + + b.Property("Deadline") + .HasColumnType("date"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("StreakSyncedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TargetValue") + .HasColumnType("numeric"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("Unit") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "IsDeleted"); + + b.ToTable("Goals"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.GoalProgressLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GoalId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("PreviousValue") + .HasColumnType("numeric"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("GoalId"); + + b.HasIndex("GoalId", "IsDeleted"); + + b.ToTable("GoalProgressLogs"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.GoogleCalendarSyncSuggestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DiscoveredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DismissedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GoogleEventId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ImportedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ImportedHabitId") + .HasColumnType("uuid"); + + b.Property("RawEventJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartDateUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "GoogleEventId") + .IsUnique(); + + b.HasIndex("UserId", "DismissedAtUtc", "ImportedAtUtc"); + + b.ToTable("GoogleCalendarSyncSuggestions"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Habit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChecklistItems") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Days") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DueDate") + .HasColumnType("date"); + + b.Property("DueEndTime") + .HasColumnType("time without time zone"); + + b.Property("DueTime") + .HasColumnType("time without time zone"); + + b.Property("Emoji") + .HasColumnType("text"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("FrequencyQuantity") + .HasColumnType("integer"); + + b.Property("FrequencyUnit") + .HasColumnType("integer"); + + b.Property("GoogleEventId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsBadHabit") + .HasColumnType("boolean"); + + b.Property("IsCompleted") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsFlexible") + .HasColumnType("boolean"); + + b.Property("IsGeneral") + .HasColumnType("boolean"); + + b.Property("OriginalDayOfMonth") + .HasColumnType("integer"); + + b.Property("ParentHabitId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ReminderEnabled") + .HasColumnType("boolean"); + + b.Property("ReminderTimes") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[15]'::jsonb"); + + b.Property("ScheduledReminders") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("SlipAlertEnabled") + .HasColumnType("boolean"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ParentHabitId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "GoogleEventId") + .IsUnique() + .HasFilter("\"GoogleEventId\" IS NOT NULL AND \"IsDeleted\" = FALSE"); + + b.HasIndex("UserId", "IsDeleted"); + + b.ToTable("Habits"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.HabitLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "HabitId", "Date" }, "IX_HabitLogs_HabitId_Date"); + + b.HasIndex(new[] { "HabitId", "Date" }, "IX_HabitLogs_HabitId_Date_Completed") + .IsUnique() + .HasFilter("\"Value\" > 0 AND NOT \"IsDeleted\""); + + b.ToTable("HabitLogs"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsRead") + .HasColumnType("boolean"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Url") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Url") + .HasFilter("\"Url\" IS NOT NULL"); + + b.HasIndex("UserId", "CreatedAtUtc") + .IsDescending(false, true); + + b.HasIndex("UserId", "IsDeleted"); + + b.HasIndex("UserId", "IsRead"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.PendingAgentOperationState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ArgumentsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CapabilityId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfirmationRequirement") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ConfirmationTokenHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ConfirmedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OperationFingerprint") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OperationId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RiskClass") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("StepUpSatisfiedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Surface") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CapabilityId"); + + b.HasIndex("UserId", "OperationFingerprint"); + + b.ToTable("PendingAgentOperations"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.PendingClarification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MissingArgumentKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PartialArgumentsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("QuickActionsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ResolvedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ToolName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("UserId", "CreatedAtUtc"); + + b.ToTable("PendingClarifications"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ProcessedPlayNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MessageId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ProcessedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("MessageId") + .IsUnique(); + + b.ToTable("ProcessedPlayNotifications"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ProcessedStripeEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EventId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ProcessedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("EventId") + .IsUnique(); + + b.ToTable("ProcessedStripeEvents"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.PushSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Auth") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Endpoint") + .IsRequired() + .HasColumnType("text"); + + b.Property("P256dh") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Endpoint") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("PushSubscriptions"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Referral", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferredUserId") + .HasColumnType("uuid"); + + b.Property("ReferrerId") + .HasColumnType("uuid"); + + b.Property("RewardGrantedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("ReferredUserId") + .IsUnique(); + + b.HasIndex("ReferrerId"); + + b.ToTable("Referrals"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Report", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CheerId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Details") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ReportedUserId") + .HasColumnType("uuid"); + + b.Property("ReporterId") + .HasColumnType("uuid"); + + b.Property("ReviewedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("CheerId"); + + b.HasIndex("ReportedUserId"); + + b.HasIndex("ReporterId"); + + b.HasIndex("Status"); + + b.ToTable("Reports"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.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() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "IsDeleted"); + + b.HasIndex("UserId", "Name") + .IsUnique(); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AdRewardBonusMessages") + .HasColumnType("integer"); + + b.Property("AdRewardsClaimedToday") + .HasColumnType("integer"); + + b.Property("AiMemoryEnabled") + .HasColumnType("boolean"); + + b.Property("AiMessagesResetAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AiMessagesUsedThisMonth") + .HasColumnType("integer"); + + b.Property("AiSummaryEnabled") + .HasColumnType("boolean"); + + b.Property("ColorScheme") + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentStreak") + .HasColumnType("integer"); + + b.Property("DeactivatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("GoogleAccessToken") + .HasColumnType("text"); + + b.Property("GoogleCalendarAutoSyncEnabled") + .HasColumnType("boolean"); + + b.Property("GoogleCalendarAutoSyncStatus") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("GoogleCalendarLastSyncError") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("GoogleCalendarLastSyncedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GoogleCalendarSelectedIds") + .HasColumnType("text"); + + b.Property("GoogleCalendarSyncReconciledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GoogleRefreshToken") + .HasColumnType("text"); + + b.Property("Handle") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("HasCompletedOnboarding") + .HasColumnType("boolean"); + + b.Property("HasCompletedOnboardingChecklist") + .HasColumnType("boolean"); + + b.Property("HasCompletedTour") + .HasColumnType("boolean"); + + b.Property("HasCreatedFirstHabit") + .HasColumnType("boolean"); + + b.Property("HasImportedCalendar") + .HasColumnType("boolean"); + + b.Property("HasLoggedFirstHabit") + .HasColumnType("boolean"); + + b.Property("HasSeenImportPrompt") + .HasColumnType("boolean"); + + b.Property("HasTriedAstra") + .HasColumnType("boolean"); + + b.Property("IsDeactivated") + .HasColumnType("boolean"); + + b.Property("IsLifetimePro") + .HasColumnType("boolean"); + + b.Property("Language") + .HasColumnType("text"); + + b.Property("LastActiveDate") + .HasColumnType("date"); + + b.Property("LastAdRewardAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastAdRewardLocalDate") + .HasColumnType("date"); + + b.Property("LastFreezeAwardStreak") + .HasColumnType("integer"); + + b.Property("Level") + .HasColumnType("integer"); + + b.Property("LongestStreak") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Plan") + .HasColumnType("integer"); + + b.Property("PlanExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayPurchaseToken") + .HasColumnType("text"); + + b.Property("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") + .HasColumnType("text"); + + b.Property("TimeZone") + .HasColumnType("text"); + + b.Property("TotalXp") + .HasColumnType("integer"); + + b.Property("TrialEndsAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WeekStartDay") + .HasColumnType("integer"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("PlayPurchaseToken") + .IsUnique() + .HasFilter("\"PlayPurchaseToken\" IS NOT NULL"); + + b.HasIndex("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.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); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.HabitLog", b => + { + b.HasOne("Orbit.Domain.Entities.Habit", null) + .WithMany("Logs") + .HasForeignKey("HabitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.PendingClarification", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.PushSubscription", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.Report", b => + { + b.HasOne("Orbit.Domain.Entities.Cheer", null) + .WithMany() + .HasForeignKey("CheerId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("ReportedUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("ReporterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.SentStreakFreezeAlert", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.StreakFreeze", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.UserSession", b => + { + b.HasOne("Orbit.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.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/20260705031342_AddHasSeenImportPrompt.cs b/src/Orbit.Infrastructure/Migrations/20260705031342_AddHasSeenImportPrompt.cs new file mode 100644 index 00000000..95066d82 --- /dev/null +++ b/src/Orbit.Infrastructure/Migrations/20260705031342_AddHasSeenImportPrompt.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Orbit.Infrastructure.Migrations +{ + /// + public partial class AddHasSeenImportPrompt : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "HasSeenImportPrompt", + table: "Users", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "HasSeenImportPrompt", + table: "Users"); + } + } +} diff --git a/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs b/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs index da97ab9d..8252f61a 100644 --- a/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs +++ b/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs @@ -1894,6 +1894,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("HasLoggedFirstHabit") .HasColumnType("boolean"); + b.Property("HasSeenImportPrompt") + .HasColumnType("boolean"); + b.Property("HasTriedAstra") .HasColumnType("boolean"); diff --git a/src/Orbit.Infrastructure/Services/AgentCatalogService.Capabilities.cs b/src/Orbit.Infrastructure/Services/AgentCatalogService.Capabilities.cs index 4721307e..08ed6489 100644 --- a/src/Orbit.Infrastructure/Services/AgentCatalogService.Capabilities.cs +++ b/src/Orbit.Infrastructure/Services/AgentCatalogService.Capabilities.cs @@ -462,6 +462,8 @@ private static AgentCapability[] ProfileCapabilities() "ProfileController.SetWeekStartDay", "ProfileController.SetThemePreference", "ProfileController.CompleteOnboarding", + "ProfileController.ApplyOnboarding", + "ProfileController.DismissImportPrompt", "ProfileController.CompleteTour", "ProfileController.ResetTour" ]), diff --git a/tests/Orbit.Application.Tests/Commands/Profile/ApplyOnboardingCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Profile/ApplyOnboardingCommandHandlerTests.cs new file mode 100644 index 00000000..84cfc80c --- /dev/null +++ b/tests/Orbit.Application.Tests/Commands/Profile/ApplyOnboardingCommandHandlerTests.cs @@ -0,0 +1,248 @@ +using FluentAssertions; +using Microsoft.Extensions.Caching.Memory; +using NSubstitute; +using Orbit.Application.Common; +using Orbit.Application.Profile.Commands; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Enums; +using Orbit.Domain.Interfaces; +using System.Linq.Expressions; + +namespace Orbit.Application.Tests.Commands.Profile; + +public class ApplyOnboardingCommandHandlerTests +{ + private readonly IGenericRepository _userRepo = Substitute.For>(); + private readonly IGenericRepository _habitRepo = Substitute.For>(); + private readonly IGenericRepository _goalRepo = Substitute.For>(); + private readonly IPayGateService _payGate = Substitute.For(); + private readonly IUserDateService _userDateService = Substitute.For(); + private readonly IAppConfigService _appConfig = Substitute.For(); + private readonly IUnitOfWork _unitOfWork = Substitute.For(); + private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions()); + + private static readonly Guid UserId = Guid.NewGuid(); + private static readonly DateOnly Today = new(2026, 7, 5); + + public ApplyOnboardingCommandHandlerTests() + { + _userDateService.GetUserTodayAsync(Arg.Any(), Arg.Any()).Returns(Today); + _payGate.CanAccessGoals(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Success())); + _appConfig.GetAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(AppConstants.DefaultFreeMaxHabits); + _unitOfWork.ExecuteInTransactionAsync( + Arg.Any>(), + Arg.Any()) + .Returns(call => + { + var operation = call.ArgAt>(0); + var ct = call.ArgAt(1); + return operation(ct); + }); + } + + private ApplyOnboardingCommandHandler CreateHandler() => new( + _userRepo, _habitRepo, _goalRepo, _payGate, _userDateService, _appConfig, _unitOfWork, _cache); + + private void SetupUser(User user) + { + _userRepo.FindOneTrackedAsync( + Arg.Any>>(), + Arg.Any, IQueryable>?>(), + Arg.Any()) + .Returns(user); + } + + private static User CreateProUser() + { + return User.Create("Test User", "test@example.com").Value; + } + + private static User CreateFreeUser() + { + var user = User.Create("Free User", "free@example.com").Value; + user.StartTrial(DateTime.UtcNow.AddDays(-1)); + return user; + } + + private static ApplyHabitInput Habit(string title) => + new(title, null, null, FrequencyUnit.Day, 1); + + [Fact] + public async Task Apply_HappyPath_CreatesEverythingAndCompletesOnboarding() + { + var user = CreateProUser(); + SetupUser(user); + + var command = new ApplyOnboardingCommand( + UserId, + [Habit("Drink water"), Habit("Read")], + new ApplyLogInput(0, Today), + new ApplyGoalInput("Run 100km", null, 100, "km"), + WeekStartDay: 0, + ColorScheme: "blue"); + + var result = await CreateHandler().Handle(command, CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Applied.Should().BeTrue(); + result.Value.CreatedHabitCount.Should().Be(2); + result.Value.CreatedGoal.Should().BeTrue(); + result.Value.LoggedFirstHabit.Should().BeTrue(); + user.HasCompletedOnboarding.Should().BeTrue(); + user.WeekStartDay.Should().Be(0); + user.ColorScheme.Should().Be("blue"); + await _habitRepo.Received(2).AddAsync(Arg.Any(), Arg.Any()); + await _goalRepo.Received(1).AddAsync(Arg.Any(), Arg.Any()); + await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task Apply_AlreadyOnboarded_IsNoOp() + { + var user = CreateProUser(); + user.CompleteOnboarding(); + SetupUser(user); + + var command = new ApplyOnboardingCommand( + UserId, [Habit("Drink water")], null, null, null, null); + + var result = await CreateHandler().Handle(command, CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Applied.Should().BeFalse(); + result.Value.CreatedHabitCount.Should().Be(0); + await _habitRepo.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + await _unitOfWork.DidNotReceive().SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task Apply_UserNotFound_ReturnsFailure() + { + _userRepo.FindOneTrackedAsync( + Arg.Any>>(), + Arg.Any, IQueryable>?>(), + Arg.Any()) + .Returns((User?)null); + + var command = new ApplyOnboardingCommand( + UserId, [Habit("Drink water")], null, null, null, null); + + var result = await CreateHandler().Handle(command, CancellationToken.None); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().Be("User not found."); + await _unitOfWork.DidNotReceive().SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task Apply_HabitCreationFails_RollsBackWithoutSaving() + { + var user = CreateProUser(); + SetupUser(user); + + var command = new ApplyOnboardingCommand( + UserId, [Habit("Valid"), Habit(" ")], null, null, null, null); + + var result = await CreateHandler().Handle(command, CancellationToken.None); + + result.IsFailure.Should().BeTrue(); + user.HasCompletedOnboarding.Should().BeFalse(); + await _unitOfWork.DidNotReceive().SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task Apply_FreeUserOverCap_TrimsToAllowance() + { + var user = CreateFreeUser(); + SetupUser(user); + _habitRepo.CountAsync(Arg.Any>>(), Arg.Any()) + .Returns(AppConstants.DefaultFreeMaxHabits - 1); + + var command = new ApplyOnboardingCommand( + UserId, [Habit("One"), Habit("Two"), Habit("Three")], null, null, null, null); + + var result = await CreateHandler().Handle(command, CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Applied.Should().BeTrue(); + result.Value.CreatedHabitCount.Should().Be(1); + await _habitRepo.Received(1).AddAsync(Arg.Any(), Arg.Any()); + user.HasCompletedOnboarding.Should().BeTrue(); + } + + [Fact] + public async Task Apply_GoalGateFails_SkipsGoalButStillApplies() + { + var user = CreateFreeUser(); + SetupUser(user); + _payGate.CanAccessGoals(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.PayGateFailure("Goals are a Pro feature. Upgrade to unlock!"))); + + var command = new ApplyOnboardingCommand( + UserId, [Habit("Drink water")], null, + new ApplyGoalInput("Run 100km", null, 100, "km"), null, null); + + var result = await CreateHandler().Handle(command, CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Applied.Should().BeTrue(); + result.Value.CreatedGoal.Should().BeFalse(); + result.Value.CreatedHabitCount.Should().Be(1); + await _goalRepo.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + user.HasCompletedOnboarding.Should().BeTrue(); + } + + [Fact] + public async Task Apply_GoalDeadlineInPast_ReturnsFailureWithoutSaving() + { + var user = CreateProUser(); + SetupUser(user); + + var command = new ApplyOnboardingCommand( + UserId, [Habit("Drink water")], null, + new ApplyGoalInput("Run 100km", null, 100, "km", Today.AddDays(-1)), null, null); + + var result = await CreateHandler().Handle(command, CancellationToken.None); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().Be("Deadline cannot be in the past."); + user.HasCompletedOnboarding.Should().BeFalse(); + await _unitOfWork.DidNotReceive().SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task Apply_FirstLogIndexOutOfRange_AppliesWithoutLogging() + { + var user = CreateProUser(); + SetupUser(user); + + var command = new ApplyOnboardingCommand( + UserId, [Habit("Drink water")], new ApplyLogInput(5, Today), null, null, null); + + var result = await CreateHandler().Handle(command, CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Applied.Should().BeTrue(); + result.Value.LoggedFirstHabit.Should().BeFalse(); + user.HasCompletedOnboarding.Should().BeTrue(); + } + + [Fact] + public async Task Apply_InvalidColorScheme_ReturnsFailureWithoutSaving() + { + var user = CreateProUser(); + SetupUser(user); + + var command = new ApplyOnboardingCommand( + UserId, [Habit("Drink water")], null, null, WeekStartDay: null, ColorScheme: "magenta"); + + var result = await CreateHandler().Handle(command, CancellationToken.None); + + result.IsFailure.Should().BeTrue(); + user.HasCompletedOnboarding.Should().BeFalse(); + await _unitOfWork.DidNotReceive().SaveChangesAsync(Arg.Any()); + } +} diff --git a/tests/Orbit.Infrastructure.Tests/Controllers/ProfileControllerTests.cs b/tests/Orbit.Infrastructure.Tests/Controllers/ProfileControllerTests.cs index c9d19dae..41b19a52 100644 --- a/tests/Orbit.Infrastructure.Tests/Controllers/ProfileControllerTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Controllers/ProfileControllerTests.cs @@ -244,6 +244,54 @@ public async Task CompleteOnboarding_Failure_ReturnsBadRequest() result.Should().BeAssignableTo().Which.StatusCode.Should().Be(400); } + [Fact] + public async Task ApplyOnboarding_Success_ReturnsOkWithResponse() + { + var response = new ApplyOnboardingResponse(true, 2, false, true); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Result.Success(response)); + + var request = new ProfileController.ApplyOnboardingRequest( + [new ApplyHabitInput("Drink water", null, null, null, null)], null, null, 1, "purple"); + var result = await _controller.ApplyOnboarding(request, CancellationToken.None); + + result.Should().BeOfType().Which.Value.Should().Be(response); + } + + [Fact] + public async Task ApplyOnboarding_Failure_ReturnsBadRequest() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Result.Failure("Error")); + + var request = new ProfileController.ApplyOnboardingRequest(null, null, null, null, null); + var result = await _controller.ApplyOnboarding(request, CancellationToken.None); + + result.Should().BeAssignableTo().Which.StatusCode.Should().Be(400); + } + + [Fact] + public async Task DismissImportPrompt_Success_ReturnsNoContent() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Result.Success()); + + var result = await _controller.DismissImportPrompt(CancellationToken.None); + + result.Should().BeOfType(); + } + + [Fact] + public async Task DismissImportPrompt_Failure_ReturnsBadRequest() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Result.Failure("Error")); + + var result = await _controller.DismissImportPrompt(CancellationToken.None); + + result.Should().BeAssignableTo().Which.StatusCode.Should().Be(400); + } + [Fact] public async Task ResetAccount_Success_ReturnsOk() { diff --git a/tests/Orbit.Infrastructure.Tests/Mcp/ProfileToolsTests.cs b/tests/Orbit.Infrastructure.Tests/Mcp/ProfileToolsTests.cs index b713efa6..25dd940e 100644 --- a/tests/Orbit.Infrastructure.Tests/Mcp/ProfileToolsTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Mcp/ProfileToolsTests.cs @@ -52,7 +52,7 @@ public async Task GetProfile_Success_ReturnsFormattedProfile() var profile = new ProfileResponse( "Thomas", "thomas@example.com", "America/Sao_Paulo", true, true, true, true, true, true, true, true, "pt-BR", "Pro", true, false, null, null, - 5, 100, false, false, null, null, false, 1, 500, 5, "Achiever", + 5, 100, false, false, false, null, null, false, 1, 500, 5, "Achiever", 0, 10, 12, 2, null, null, false, GoogleCalendarAutoSyncStatus.Idle, null, true, null, false); diff --git a/tests/Orbit.Infrastructure.Tests/Persistence/ApplyOnboardingConcurrencyTests.cs b/tests/Orbit.Infrastructure.Tests/Persistence/ApplyOnboardingConcurrencyTests.cs new file mode 100644 index 00000000..18ea86e9 --- /dev/null +++ b/tests/Orbit.Infrastructure.Tests/Persistence/ApplyOnboardingConcurrencyTests.cs @@ -0,0 +1,98 @@ +using FluentAssertions; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Caching.Memory; +using NSubstitute; +using Orbit.Application.Behaviors; +using Orbit.Application.Common; +using Orbit.Application.Profile.Commands; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Enums; +using Orbit.Domain.Interfaces; +using Orbit.Infrastructure.Persistence; + +namespace Orbit.Infrastructure.Tests.Persistence; + +/// +/// Verifies that applies exactly once when the first save hits a +/// simulated stale-token conflict and the concurrency-retry pipeline re-runs the whole handler. The +/// in-memory provider does not enforce xmin, so the conflict is injected with a save interceptor. +/// +public class ApplyOnboardingConcurrencyTests +{ + [Fact] + public async Task Apply_ConflictOnFirstSave_RetriesAndAppliesExactlyOnce() + { + var dbName = $"ApplyOnboardingConcurrency_{Guid.NewGuid()}"; + Guid userId; + + await using (var seed = CreateContext(dbName)) + { + var user = User.Create("Tester", $"{Guid.NewGuid():N}@example.com").Value; + seed.Users.Add(user); + await seed.SaveChangesAsync(); + userId = user.Id; + } + + var interceptor = new ConflictOnceInterceptor(); + await using var context = CreateContext(dbName, interceptor); + var unitOfWork = new UnitOfWork(context); + var handler = new ApplyOnboardingCommandHandler( + new GenericRepository(context), + new GenericRepository(context), + new GenericRepository(context), + Substitute.For(), + StubToday(new DateOnly(2026, 7, 5)), + Substitute.For(), + unitOfWork, + new MemoryCache(new MemoryCacheOptions())); + + var command = new ApplyOnboardingCommand( + userId, + [new ApplyHabitInput("Drink water", null, null, FrequencyUnit.Day, 1)], + null, null, null, null); + + var behavior = new ConcurrencyRetryBehavior>(unitOfWork); + var result = await behavior.Handle(command, ct => handler.Handle(command, ct), CancellationToken.None); + + interceptor.SaveAttempts.Should().Be(2); + result.IsSuccess.Should().BeTrue(); + result.Value.Applied.Should().BeTrue(); + result.Value.CreatedHabitCount.Should().Be(1); + + await using var verify = CreateContext(dbName); + verify.Users.Single(u => u.Id == userId).HasCompletedOnboarding.Should().BeTrue(); + verify.Habits.Count(h => h.UserId == userId).Should().Be(1); + } + + private static OrbitDbContext CreateContext(string dbName, ISaveChangesInterceptor? interceptor = null) + { + var builder = new DbContextOptionsBuilder().UseInMemoryDatabase(dbName); + if (interceptor is not null) + builder.AddInterceptors(interceptor); + return new OrbitDbContext(builder.Options); + } + + private static IUserDateService StubToday(DateOnly today) + { + var service = Substitute.For(); + service.GetUserTodayAsync(Arg.Any(), Arg.Any()).Returns(today); + return service; + } + + private sealed class ConflictOnceInterceptor : SaveChangesInterceptor + { + public int SaveAttempts { get; private set; } + + public override ValueTask> SavingChangesAsync( + DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { + SaveAttempts++; + if (SaveAttempts == 1) + throw new DbUpdateConcurrencyException("simulated stale token"); + return base.SavingChangesAsync(eventData, result, cancellationToken); + } + } +}