From 4c7f4acd1684254b213fa6bb249d76f131538f45 Mon Sep 17 00:00:00 2001 From: thomasluizon Date: Sat, 8 Aug 2026 00:53:42 -0300 Subject: [PATCH 1/6] chore: start ORB-7 From 042b2c7f32bc9867e2a3367a2b1da588ad0e29e0 Mon Sep 17 00:00:00 2001 From: thomasluizon Date: Sat, 8 Aug 2026 01:03:10 -0300 Subject: [PATCH 2/6] fix: record Astra chat usage for ORB-7 --- src/Orbit.Domain/Entities/AiUsageDaily.cs | 8 +- .../Interfaces/IAiUsageRecorder.cs | 3 +- src/Orbit.Domain/Models/AiToolModels.cs | 2 + .../AI/AiCompletionClient.cs | 2 + .../AI/AiUsageRecorder.cs | 16 +- ...0120_RecordAstraChatTokenUsage.Designer.cs | 2611 +++++++++++++++++ ...0260808040120_RecordAstraChatTokenUsage.cs | 48 + .../Migrations/OrbitDbContextModelSnapshot.cs | 7 +- .../Persistence/OrbitDbContext.cs | 4 +- .../Services/AiIntentService.cs | 74 +- .../AI/AiCompletionClientTests.cs | 5 +- .../AI/AiIntentServiceStreamingTests.cs | 171 +- 12 files changed, 2918 insertions(+), 33 deletions(-) create mode 100644 src/Orbit.Infrastructure/Migrations/20260808040120_RecordAstraChatTokenUsage.Designer.cs create mode 100644 src/Orbit.Infrastructure/Migrations/20260808040120_RecordAstraChatTokenUsage.cs diff --git a/src/Orbit.Domain/Entities/AiUsageDaily.cs b/src/Orbit.Domain/Entities/AiUsageDaily.cs index 2facfd7e..7c717d4e 100644 --- a/src/Orbit.Domain/Entities/AiUsageDaily.cs +++ b/src/Orbit.Domain/Entities/AiUsageDaily.cs @@ -12,7 +12,8 @@ public record AiUsageTotals( decimal CostUsd); /// -/// Aggregated AI token usage and computed dollar cost for a single (UTC date, model, purpose) triple. +/// Aggregated AI token usage and computed dollar cost for a single +/// (UTC date, model, purpose, optional user) tuple. /// Rows are UPSERTed at the AI completion chokepoint and read once per day by the usage-summary job. /// public class AiUsageDaily : Entity @@ -20,6 +21,7 @@ public class AiUsageDaily : Entity public DateOnly Date { get; private set; } public string Model { get; private set; } = string.Empty; public string Purpose { get; private set; } = string.Empty; + public Guid? UserId { get; private set; } public long Calls { get; private set; } public long CachedTokens { get; private set; } public long PromptTokens { get; private set; } @@ -33,13 +35,15 @@ public static AiUsageDaily Create( DateOnly date, string model, string purpose, - AiUsageTotals totals) + AiUsageTotals totals, + Guid? userId = null) { return new AiUsageDaily { Date = date, Model = model, Purpose = purpose, + UserId = userId, Calls = totals.Calls, CachedTokens = totals.CachedTokens, PromptTokens = totals.PromptTokens, diff --git a/src/Orbit.Domain/Interfaces/IAiUsageRecorder.cs b/src/Orbit.Domain/Interfaces/IAiUsageRecorder.cs index 95b5639a..777092b8 100644 --- a/src/Orbit.Domain/Interfaces/IAiUsageRecorder.cs +++ b/src/Orbit.Domain/Interfaces/IAiUsageRecorder.cs @@ -13,5 +13,6 @@ Task RecordAsync( long promptTokens, long completionTokens, long totalTokens, - CancellationToken cancellationToken = default); + CancellationToken cancellationToken = default, + Guid? userId = null); } diff --git a/src/Orbit.Domain/Models/AiToolModels.cs b/src/Orbit.Domain/Models/AiToolModels.cs index 284e8ed1..323c5cce 100644 --- a/src/Orbit.Domain/Models/AiToolModels.cs +++ b/src/Orbit.Domain/Models/AiToolModels.cs @@ -38,6 +38,8 @@ public sealed class AiConversationContext public object Messages { get; init; } = null!; /// Opaque options object. Only consumed by AiIntentService. public object Options { get; init; } = null!; + /// User attributed to each model round in this conversation. + public Guid? UserId { get; init; } } public record AiResponse diff --git a/src/Orbit.Infrastructure/AI/AiCompletionClient.cs b/src/Orbit.Infrastructure/AI/AiCompletionClient.cs index e3581556..ab397702 100644 --- a/src/Orbit.Infrastructure/AI/AiCompletionClient.cs +++ b/src/Orbit.Infrastructure/AI/AiCompletionClient.cs @@ -80,6 +80,8 @@ internal AiCompletionClient( /// public ChatClient ChatClient => _chatClient; + internal string ChatModel => _primaryModel; + /// /// Requests a plain-text chat completion from the configured model tier. /// diff --git a/src/Orbit.Infrastructure/AI/AiUsageRecorder.cs b/src/Orbit.Infrastructure/AI/AiUsageRecorder.cs index e9103043..e90e81a2 100644 --- a/src/Orbit.Infrastructure/AI/AiUsageRecorder.cs +++ b/src/Orbit.Infrastructure/AI/AiUsageRecorder.cs @@ -10,9 +10,10 @@ namespace Orbit.Infrastructure.AI; /// /// Singleton recorder that converts one completion's tokens into a dollar cost from the configured -/// per-model price map and atomically UPSERTs it into the daily (date, model, purpose) aggregate via a -/// child DI scope, so the singleton AI client never holds a scoped DbContext. Best-effort: any write -/// failure is logged once at Warning and swallowed so the user's AI response is never affected. +/// per-model price map and atomically UPSERTs it into the daily +/// (date, model, purpose, optional user) aggregate via a child DI scope, so the singleton AI client +/// never holds a scoped DbContext. Best-effort: any write failure is logged once at Warning and +/// swallowed so the user's AI response is never affected. /// public sealed partial class AiUsageRecorder( IServiceScopeFactory scopeFactory, @@ -30,7 +31,8 @@ public async Task RecordAsync( long promptTokens, long completionTokens, long totalTokens, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + Guid? userId = null) { var costUsd = ComputeCostUsd(_pricing.GetValueOrDefault(model), cachedTokens, promptTokens, completionTokens); #pragma warning disable ORBIT0004 // WHY: pre-existing deliberate UTC-date window or UTC-keyed dedupe/aggregation bucket (not a user's calendar date), per-site justification ledger: https://github.com/thomasluizon/orbit-api/issues/431 @@ -43,9 +45,9 @@ public async Task RecordAsync( var dbContext = scope.ServiceProvider.GetRequiredService(); await dbContext.Database.ExecuteSqlInterpolatedAsync($""" INSERT INTO "AiUsageDaily" - ("Id", "Date", "Model", "Purpose", "Calls", "CachedTokens", "PromptTokens", "CompletionTokens", "TotalTokens", "CostUsd") - VALUES ({Guid.NewGuid()}, {date}, {model}, {purpose}, 1, {cachedTokens}, {promptTokens}, {completionTokens}, {totalTokens}, {costUsd}) - ON CONFLICT ("Date", "Model", "Purpose") DO UPDATE SET + ("Id", "Date", "Model", "Purpose", "UserId", "Calls", "CachedTokens", "PromptTokens", "CompletionTokens", "TotalTokens", "CostUsd") + VALUES ({Guid.NewGuid()}, {date}, {model}, {purpose}, {userId}, 1, {cachedTokens}, {promptTokens}, {completionTokens}, {totalTokens}, {costUsd}) + ON CONFLICT ("Date", "Model", "Purpose", "UserId") DO UPDATE SET "Calls" = "AiUsageDaily"."Calls" + 1, "CachedTokens" = "AiUsageDaily"."CachedTokens" + EXCLUDED."CachedTokens", "PromptTokens" = "AiUsageDaily"."PromptTokens" + EXCLUDED."PromptTokens", diff --git a/src/Orbit.Infrastructure/Migrations/20260808040120_RecordAstraChatTokenUsage.Designer.cs b/src/Orbit.Infrastructure/Migrations/20260808040120_RecordAstraChatTokenUsage.Designer.cs new file mode 100644 index 00000000..03fd4348 --- /dev/null +++ b/src/Orbit.Infrastructure/Migrations/20260808040120_RecordAstraChatTokenUsage.Designer.cs @@ -0,0 +1,2611 @@ +// +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("20260808040120_RecordAstraChatTokenUsage")] + partial class RecordAstraChatTokenUsage + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("HabitGoals", b => + { + b.Property("GoalId") + .HasColumnType("uuid"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.HasKey("GoalId", "HabitId"); + + b.HasIndex("HabitId"); + + b.ToTable("HabitGoals"); + }); + + modelBuilder.Entity("HabitTags", b => + { + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("TagId") + .HasColumnType("uuid"); + + b.HasKey("HabitId", "TagId"); + + b.HasIndex("TagId"); + + b.ToTable("HabitTags"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AccountabilityCheckIn", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("Note") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PairId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PairId", "CreatedAtUtc"); + + b.HasIndex("PairId", "UserId", "Date") + .IsUnique(); + + b.ToTable("AccountabilityCheckIns"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AccountabilityPair", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AddresseeId") + .HasColumnType("uuid"); + + b.Property("Cadence") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EndedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("AddresseeId"); + + b.HasIndex("RequesterId"); + + b.ToTable("AccountabilityPairs"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AccountabilityPairHabit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("HabitId") + .HasColumnType("uuid"); + + b.Property("PairId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("HabitId"); + + b.HasIndex("PairId", "UserId", "HabitId") + .IsUnique(); + + b.ToTable("AccountabilityPairHabits"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AgentAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthMethod") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CapabilityId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CorrelationId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Error") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("OutcomeStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("PolicyDecision") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RedactedArguments") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RiskClass") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ShadowPolicyDecision") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ShadowReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("SourceName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Summary") + .HasColumnType("text"); + + b.Property("Surface") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TargetId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TargetName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CapabilityId", "CreatedAtUtc"); + + b.HasIndex("UserId", "CreatedAtUtc"); + + b.ToTable("AgentAuditLogs"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AgentStepUpChallengeState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttemptCount") + .HasColumnType("integer"); + + b.Property("CodeHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PendingOperationId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("VerifiedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "PendingOperationId", "CreatedAtUtc"); + + b.ToTable("AgentStepUpChallenges"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AiFactExtractionBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BatchId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("InputFileId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("OutputFileId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("UserId"); + + b.ToTable("AiFactExtractionBatches"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AiUsageDaily", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CachedTokens") + .HasColumnType("bigint"); + + b.Property("Calls") + .HasColumnType("bigint"); + + b.Property("CompletionTokens") + .HasColumnType("bigint"); + + b.Property("CostUsd") + .HasColumnType("numeric"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PromptTokens") + .HasColumnType("bigint"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TotalTokens") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Date", "Model", "Purpose", "UserId") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("Date", "Model", "Purpose", "UserId"), false); + + b.ToTable("AiUsageDaily"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsReadOnly") + .HasColumnType("boolean"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("KeyHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("KeyPrefix") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("character varying(12)"); + + b.Property("LastUsedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Scopes") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("KeyPrefix"); + + b.HasIndex("UserId"); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("Orbit.Domain.Entities.AppConfig", b => + { + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Key"); + + b.ToTable("AppConfigs"); + + b.HasData( + new + { + Key = "MaxUserFacts", + Description = "Maximum number of facts the AI can remember per user", + Value = "50" + }, + new + { + Key = "MaxHabitDepth", + Description = "Maximum nesting depth for sub-habits", + Value = "5" + }, + new + { + Key = "MaxTagsPerHabit", + Description = "Maximum number of tags per habit", + Value = "5" + }, + new + { + Key = "ReferralRewardDays", + Description = "Days of Pro added per successful referral", + Value = "10" + }, + new + { + Key = "MaxReferrals", + Description = "Maximum successful referrals per user", + Value = "10" + }, + new + { + Key = "MinSupportedVersion", + Description = "Minimum supported client app version; clients below this receive HTTP 426", + Value = "0.0.0" + }); + }); + + 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.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/20260808040120_RecordAstraChatTokenUsage.cs b/src/Orbit.Infrastructure/Migrations/20260808040120_RecordAstraChatTokenUsage.cs new file mode 100644 index 00000000..80dde1f5 --- /dev/null +++ b/src/Orbit.Infrastructure/Migrations/20260808040120_RecordAstraChatTokenUsage.cs @@ -0,0 +1,48 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Orbit.Infrastructure.Migrations +{ + /// + public partial class RecordAstraChatTokenUsage : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_AiUsageDaily_Date_Model_Purpose", + table: "AiUsageDaily"); + + migrationBuilder.AddColumn( + name: "UserId", + table: "AiUsageDaily", + type: "uuid", + nullable: true); + + migrationBuilder.Sql(""" + CREATE UNIQUE INDEX IF NOT EXISTS "IX_AiUsageDaily_Date_Model_Purpose_UserId" + ON "AiUsageDaily" ("Date", "Model", "Purpose", "UserId") NULLS NOT DISTINCT; + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_AiUsageDaily_Date_Model_Purpose_UserId", + table: "AiUsageDaily"); + + migrationBuilder.DropColumn( + name: "UserId", + table: "AiUsageDaily"); + + migrationBuilder.CreateIndex( + name: "IX_AiUsageDaily_Date_Model_Purpose", + table: "AiUsageDaily", + columns: new[] { "Date", "Model", "Purpose" }, + unique: true); + } + } +} diff --git a/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs b/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs index b6a46fb3..5fa10b77 100644 --- a/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs +++ b/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs @@ -358,11 +358,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("TotalTokens") .HasColumnType("bigint"); + b.Property("UserId") + .HasColumnType("uuid"); + b.HasKey("Id"); - b.HasIndex("Date", "Model", "Purpose") + b.HasIndex("Date", "Model", "Purpose", "UserId") .IsUnique(); + NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("Date", "Model", "Purpose", "UserId"), false); + b.ToTable("AiUsageDaily"); }); diff --git a/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs b/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs index 75c65069..569729e5 100644 --- a/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs +++ b/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs @@ -275,7 +275,9 @@ private static void ConfigureAiUsageDailyEntity(ModelBuilder modelBuilder) { modelBuilder.Entity(entity => { - entity.HasIndex(u => new { u.Date, u.Model, u.Purpose }).IsUnique(); + entity.HasIndex(u => new { u.Date, u.Model, u.Purpose, u.UserId }) + .IsUnique() + .AreNullsDistinct(false); entity.Property(u => u.Model).HasMaxLength(64); entity.Property(u => u.Purpose).HasMaxLength(64); entity.Property(u => u.CostUsd).HasColumnType("numeric"); diff --git a/src/Orbit.Infrastructure/Services/AiIntentService.cs b/src/Orbit.Infrastructure/Services/AiIntentService.cs index 5df29106..8e217d3a 100644 --- a/src/Orbit.Infrastructure/Services/AiIntentService.cs +++ b/src/Orbit.Infrastructure/Services/AiIntentService.cs @@ -17,6 +17,7 @@ namespace Orbit.Infrastructure.Services; public sealed partial class AiIntentService( AiCompletionClient aiClient, + IAiUsageRecorder usageRecorder, ILogger logger) : IAiIntentService { private static readonly JsonSerializerOptions SerializeOptions = new() @@ -76,7 +77,8 @@ public async Task> SendWithToolsAsync( options.Tools.Add(tool); } - return await CallWithToolsAsync(messages, options, streamSink, cancellationToken); + var usageUserId = userId == Guid.Empty ? (Guid?)null : userId; + return await CallWithToolsAsync(messages, options, usageUserId, streamSink, cancellationToken); } public async Task> ContinueWithToolResultsAsync( @@ -107,12 +109,18 @@ public async Task> ContinueWithToolResultsAsync( messages.Add(new ToolChatMessage(result.Id, JsonSerializer.Serialize(payload))); } - return await CallWithToolsAsync(messages, options, streamSink, cancellationToken); + return await CallWithToolsAsync( + messages, + options, + conversationContext.UserId, + streamSink, + cancellationToken); } private async Task> CallWithToolsAsync( List messages, ChatCompletionOptions options, + Guid? userId, Func? streamSink, CancellationToken cancellationToken) { @@ -122,8 +130,8 @@ private async Task> CallWithToolsAsync( var stopwatch = System.Diagnostics.Stopwatch.StartNew(); var round = streamSink is null - ? await CompleteBufferedRoundAsync(messages, options, cancellationToken) - : await CompleteStreamingRoundAsync(messages, options, streamSink, stopwatch, cancellationToken); + ? await CompleteBufferedRoundAsync(messages, options, userId, cancellationToken) + : await CompleteStreamingRoundAsync(messages, options, userId, streamSink, stopwatch, cancellationToken); stopwatch.Stop(); LogAiApiResponded(logger, stopwatch.ElapsedMilliseconds); @@ -135,7 +143,12 @@ private async Task> CallWithToolsAsync( LogAiReturnedToolCalls(logger, toolCalls.Count, string.Join(", ", toolCalls.Select(tc => tc.Name))); - var convCtx = new AiConversationContext { Messages = messages, Options = options }; + var convCtx = new AiConversationContext + { + Messages = messages, + Options = options, + UserId = userId + }; return Result.Success(new AiResponse { ToolCalls = toolCalls, ConversationContext = convCtx }); } @@ -158,12 +171,16 @@ private async Task> CallWithToolsAsync( } private async Task CompleteBufferedRoundAsync( - List messages, ChatCompletionOptions options, CancellationToken cancellationToken) + List messages, + ChatCompletionOptions options, + Guid? userId, + CancellationToken cancellationToken) { var completion = await aiClient.ChatClient.CompleteChatAsync(messages, options, cancellationToken); var result = completion.Value; LogChatUsage(result.Usage, "buffered"); + await RecordChatUsageAsync(result.Usage, "buffered", userId, cancellationToken); messages.Add(new AssistantChatMessage(result)); @@ -179,6 +196,7 @@ private async Task CompleteBufferedRoundAsync( private async Task CompleteStreamingRoundAsync( List messages, ChatCompletionOptions options, + Guid? userId, Func streamSink, System.Diagnostics.Stopwatch stopwatch, CancellationToken cancellationToken) @@ -189,6 +207,8 @@ private async Task CompleteStreamingRoundAsync( var firstTokenLogged = false; ChatTokenUsage? streamedUsage = null; + IncludeUsage(options); + await foreach (var update in aiClient.ChatClient.CompleteChatStreamingAsync(messages, options, cancellationToken)) { if (update.Usage is not null) @@ -204,6 +224,7 @@ private async Task CompleteStreamingRoundAsync( } LogChatUsage(streamedUsage, "streaming"); + await RecordChatUsageAsync(streamedUsage, "streaming", userId, cancellationToken); if (finishReason == ChatFinishReason.ToolCalls && toolCallBuilders.Count > 0) { @@ -415,6 +436,41 @@ private void LogChatUsage(ChatTokenUsage? usage, string phase) usage.TotalTokenCount); } +#pragma warning disable SCME0001 + private static void IncludeUsage(ChatCompletionOptions options) => + options.Patch.Set("$.stream_options.include_usage"u8, true); +#pragma warning restore SCME0001 + + private async Task RecordChatUsageAsync( + ChatTokenUsage? usage, + string phase, + Guid? userId, + CancellationToken cancellationToken) + { + if (usage is null) + { + LogChatUsageMissing(logger, phase); + return; + } + + try + { + await usageRecorder.RecordAsync( + "chat", + aiClient.ChatModel, + usage.InputTokenDetails?.CachedTokenCount ?? 0, + usage.InputTokenCount, + usage.OutputTokenCount, + usage.TotalTokenCount, + cancellationToken, + userId); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + LogChatUsageRecordFailed(logger, aiClient.ChatModel, phase, ex); + } + } + [LoggerMessage(EventId = 1, Level = LogLevel.Debug, Message = "Calling AI API with tools...")] private static partial void LogCallingAiWithTools(ILogger logger); @@ -445,4 +501,10 @@ private void LogChatUsage(ChatTokenUsage? usage, string phase) [LoggerMessage(EventId = 10, Level = LogLevel.Warning, Message = "History overflow summary failed; falling back to truncation")] private static partial void LogHistorySummaryFailed(ILogger logger, Exception ex); + [LoggerMessage(EventId = 11, Level = LogLevel.Warning, Message = "AI token usage was missing from the {Phase} chat response")] + private static partial void LogChatUsageMissing(ILogger logger, string phase); + + [LoggerMessage(EventId = 12, Level = LogLevel.Warning, Message = "Failed to record chat usage for {Model} ({Phase})")] + private static partial void LogChatUsageRecordFailed(ILogger logger, string model, string phase, Exception ex); + } diff --git a/tests/Orbit.Infrastructure.Tests/AI/AiCompletionClientTests.cs b/tests/Orbit.Infrastructure.Tests/AI/AiCompletionClientTests.cs index 1f771a29..5b83a926 100644 --- a/tests/Orbit.Infrastructure.Tests/AI/AiCompletionClientTests.cs +++ b/tests/Orbit.Infrastructure.Tests/AI/AiCompletionClientTests.cs @@ -18,12 +18,15 @@ public class AiCompletionClientTests public async Task CompleteJsonAsync_SubTaskTier_OmitsTemperature() { var handler = new CapturingHandler(); - var client = new AiCompletionClient(BuildChatClient(handler), NullLogger.Instance, Substitute.For()); + var usageRecorder = Substitute.For(); + var client = new AiCompletionClient(BuildChatClient(handler), NullLogger.Instance, usageRecorder); await client.CompleteJsonAsync("You are a helpful assistant. Respond only with valid JSON.", "extract facts", purpose: "fact_extraction", tier: AiModelTier.SubTask); handler.LastRequestBody.Should().NotBeNull(); handler.LastRequestBody.Should().NotContain("temperature"); + await usageRecorder.Received(1).RecordAsync( + "fact_extraction", "subtask-test", 0, 1, 2, 3, Arg.Any()); } [Fact] diff --git a/tests/Orbit.Infrastructure.Tests/AI/AiIntentServiceStreamingTests.cs b/tests/Orbit.Infrastructure.Tests/AI/AiIntentServiceStreamingTests.cs index 0e0580f5..caeade0b 100644 --- a/tests/Orbit.Infrastructure.Tests/AI/AiIntentServiceStreamingTests.cs +++ b/tests/Orbit.Infrastructure.Tests/AI/AiIntentServiceStreamingTests.cs @@ -106,6 +106,109 @@ public async Task SendWithToolsAsync_NullSink_UsesBufferedCompletion() result.Value.TextMessage.Should().Be("Hi there"); sink.Events.Should().BeEmpty(); handler.LastRequestBody.Should().NotContain("\"stream\":true"); + handler.LastRequestBody.Should().NotContain("stream_options"); + } + + [Fact] + public async Task SendWithToolsAsync_BufferedRound_RecordsUsageForUser() + { + var userId = Guid.NewGuid(); + var usageRecorder = Substitute.For(); + var (service, _) = BuildService(new JsonHandler(BufferedCompletion), usageRecorder); + + var result = await service.SendWithToolsAsync(new AiToolRequest("hello", "system", [], userId)); + + result.IsSuccess.Should().BeTrue(); + await usageRecorder.Received(1).RecordAsync( + "chat", "primary-test", 3, 11, 7, 18, Arg.Any(), userId); + } + + [Fact] + public async Task SendWithToolsAsync_StreamingRound_RequestsAndRecordsUsageForUser() + { + var userId = Guid.NewGuid(); + var usageRecorder = Substitute.For(); + var handler = new SseHandler( + RoleChunk() + ContentChunk("Hello") + FinishChunk("stop") + UsageChunk() + Done()); + var (service, sink) = BuildService(handler, usageRecorder); + + var result = await service.SendWithToolsAsync( + new AiToolRequest("hello", "system", [], userId), + streamSink: sink.Handle); + + result.IsSuccess.Should().BeTrue(); + handler.LastRequestBody.Should().Contain("\"stream_options\":{\"include_usage\":true}"); + await usageRecorder.Received(1).RecordAsync( + "chat", "primary-test", 3, 11, 7, 18, Arg.Any(), userId); + } + + [Fact] + public async Task SendWithToolsAsync_StreamingRoundWithoutUsage_LogsAndDoesNotRecord() + { + var usageRecorder = Substitute.For(); + var body = RoleChunk() + ContentChunk("Hello") + FinishChunk("stop") + Done(); + var (service, logger) = BuildServiceWithRecordingLogger(new SseHandler(body), usageRecorder); + var sink = new CollectingSink(); + + var result = await service.SendWithToolsAsync( + new AiToolRequest("hello", "system", [], Guid.NewGuid()), + streamSink: sink.Handle); + + result.IsSuccess.Should().BeTrue(); + logger.WarningEventIds.Should().Contain(11); + await usageRecorder.DidNotReceiveWithAnyArgs().RecordAsync( + default!, default!, default, default, default, default, default, default); + } + + [Fact] + public async Task SendWithToolsAsync_RecorderFailure_DoesNotFailCompletedStream() + { + var usageRecorder = Substitute.For(); + usageRecorder.RecordAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromException(new InvalidOperationException("recorder failed"))); + var body = RoleChunk() + ContentChunk("Hello") + FinishChunk("stop") + UsageChunk() + Done(); + var (service, logger) = BuildServiceWithRecordingLogger(new SseHandler(body), usageRecorder); + var sink = new CollectingSink(); + + var result = await service.SendWithToolsAsync( + new AiToolRequest("hello", "system", [], Guid.NewGuid()), + streamSink: sink.Handle); + + result.IsSuccess.Should().BeTrue(); + result.Value.TextMessage.Should().Be("Hello"); + sink.Events.Should().ContainSingle(); + logger.WarningEventIds.Should().Contain(12); + } + + [Fact] + public async Task ContinueWithToolResultsAsync_TwoRounds_RecordTwoCallsForSameUser() + { + var userId = Guid.NewGuid(); + var usageRecorder = Substitute.For(); + var handler = new SequenceSseHandler( + RoleChunk() + + ToolCallStartChunk(0, "call_1", "create_habit") + + ToolCallArgsChunk(0, "{}") + + FinishChunk("tool_calls") + + UsageChunk() + + Done(), + RoleChunk() + ContentChunk("Done") + FinishChunk("stop") + UsageChunk() + Done()); + var (service, sink) = BuildService(handler, usageRecorder); + + var first = await service.SendWithToolsAsync( + new AiToolRequest("create it", "system", [], userId), + streamSink: sink.Handle); + var second = await service.ContinueWithToolResultsAsync( + first.Value.ConversationContext!, + [new AiToolCallResult("create_habit", "call_1", true, null, null, null)], + streamSink: sink.Handle); + + second.IsSuccess.Should().BeTrue(); + second.Value.TextMessage.Should().Be("Done"); + await usageRecorder.Received(2).RecordAsync( + "chat", "primary-test", 3, 11, 7, 18, Arg.Any(), userId); } [Fact] @@ -143,8 +246,9 @@ public async Task SendWithToolsAsync_StreamingLengthFinish_LogsTruncationWarning public async Task SendWithToolsAsync_WithUserId_SetsEndUserIdForCacheRouting() { var handler = new JsonHandler(BufferedCompletion); - var aiClient = new AiCompletionClient(BuildChatClient(handler), NullLogger.Instance, Substitute.For()); - var service = new AiIntentService(aiClient, NullLogger.Instance); + var usageRecorder = Substitute.For(); + var aiClient = new AiCompletionClient(BuildChatClient(handler), NullLogger.Instance, usageRecorder); + var service = new AiIntentService(aiClient, usageRecorder, NullLogger.Instance); var userId = Guid.NewGuid(); await service.SendWithToolsAsync(new AiToolRequest("hello", "system", [], userId)); @@ -156,8 +260,9 @@ public async Task SendWithToolsAsync_WithUserId_SetsEndUserIdForCacheRouting() public async Task SendWithToolsAsync_HistoryWithinWindow_DoesNotSummarize() { var handler = new CountingJsonHandler(BufferedCompletion); - var aiClient = new AiCompletionClient(BuildChatClient(handler), NullLogger.Instance, Substitute.For()); - var service = new AiIntentService(aiClient, NullLogger.Instance); + var usageRecorder = Substitute.For(); + var aiClient = new AiCompletionClient(BuildChatClient(handler), NullLogger.Instance, usageRecorder); + var service = new AiIntentService(aiClient, usageRecorder, NullLogger.Instance); await service.SendWithToolsAsync(new AiToolRequest("hello", "system", [], Guid.NewGuid(), History: BuildHistory(40))); @@ -168,8 +273,9 @@ public async Task SendWithToolsAsync_HistoryWithinWindow_DoesNotSummarize() public async Task SendWithToolsAsync_HistoryOverflowsWindow_SummarizesOlderMessages() { var handler = new CountingJsonHandler(BufferedCompletion); - var aiClient = new AiCompletionClient(BuildChatClient(handler), NullLogger.Instance, Substitute.For()); - var service = new AiIntentService(aiClient, NullLogger.Instance); + var usageRecorder = Substitute.For(); + var aiClient = new AiCompletionClient(BuildChatClient(handler), NullLogger.Instance, usageRecorder); + var service = new AiIntentService(aiClient, usageRecorder, NullLogger.Instance); await service.SendWithToolsAsync(new AiToolRequest("hello", "system", [], Guid.NewGuid(), History: BuildHistory(50))); @@ -179,7 +285,7 @@ public async Task SendWithToolsAsync_HistoryOverflowsWindow_SummarizesOlderMessa private const string BufferedCompletion = """ {"id":"chatcmpl-test","object":"chat.completion","created":1700000000,"model":"gpt-test", "choices":[{"index":0,"message":{"role":"assistant","content":"Hi there"},"finish_reason":"stop"}], - "usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}} + "usage":{"prompt_tokens":11,"completion_tokens":7,"total_tokens":18,"prompt_tokens_details":{"cached_tokens":3}}} """; private static List BuildHistory(int count) => @@ -187,18 +293,24 @@ private static List BuildHistory(int count) => .Select(i => new ChatHistoryMessage(i % 2 == 0 ? "user" : "assistant", $"message {i}")) .ToList(); - private static (AiIntentService Service, CollectingSink Sink) BuildService(HttpMessageHandler handler) + private static (AiIntentService Service, CollectingSink Sink) BuildService( + HttpMessageHandler handler, + IAiUsageRecorder? usageRecorder = null) { - var aiClient = new AiCompletionClient(BuildChatClient(handler), NullLogger.Instance, Substitute.For()); - var service = new AiIntentService(aiClient, NullLogger.Instance); + usageRecorder ??= Substitute.For(); + var aiClient = new AiCompletionClient(BuildChatClient(handler), NullLogger.Instance, usageRecorder); + var service = new AiIntentService(aiClient, usageRecorder, NullLogger.Instance); return (service, new CollectingSink()); } - private static (AiIntentService Service, RecordingLogger Logger) BuildServiceWithRecordingLogger(HttpMessageHandler handler) + private static (AiIntentService Service, RecordingLogger Logger) BuildServiceWithRecordingLogger( + HttpMessageHandler handler, + IAiUsageRecorder? usageRecorder = null) { - var aiClient = new AiCompletionClient(BuildChatClient(handler), NullLogger.Instance, Substitute.For()); + usageRecorder ??= Substitute.For(); + var aiClient = new AiCompletionClient(BuildChatClient(handler), NullLogger.Instance, usageRecorder); var logger = new RecordingLogger(); - var service = new AiIntentService(aiClient, logger); + var service = new AiIntentService(aiClient, usageRecorder, logger); return (service, logger); } @@ -236,6 +348,11 @@ private static string ToolCallArgsChunk(int index, string argsFragment) private static string FinishChunk(string reason) => Chunk("{}", $"\"{reason}\""); + private static string UsageChunk() => + "data: {\"id\":\"chatcmpl-test\",\"object\":\"chat.completion.chunk\",\"created\":1700000000," + + "\"model\":\"gpt-test\",\"choices\":[],\"usage\":{\"prompt_tokens\":11,\"completion_tokens\":7," + + "\"total_tokens\":18,\"prompt_tokens_details\":{\"cached_tokens\":3}}}\n\n"; + private static string Done() => "data: [DONE]\n\n"; private sealed class CollectingSink @@ -272,6 +389,32 @@ public void Dispose() { } private sealed class SseHandler(string body) : HttpMessageHandler { + public string? LastRequestBody { get; private set; } + + protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequestBody = request.Content?.ReadAsStringAsync(cancellationToken).GetAwaiter().GetResult(); + return BuildResponse(request); + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (request.Content is not null) + LastRequestBody = await request.Content.ReadAsStringAsync(cancellationToken); + return BuildResponse(request); + } + + private HttpResponseMessage BuildResponse(HttpRequestMessage request) + { + var content = new StringContent(body, Encoding.UTF8, "text/event-stream"); + return new HttpResponseMessage(HttpStatusCode.OK) { RequestMessage = request, Content = content }; + } + } + + private sealed class SequenceSseHandler(params string[] bodies) : HttpMessageHandler + { + private readonly Queue _bodies = new(bodies); + protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken) => BuildResponse(request); @@ -280,7 +423,7 @@ protected override Task SendAsync(HttpRequestMessage reques private HttpResponseMessage BuildResponse(HttpRequestMessage request) { - var content = new StringContent(body, Encoding.UTF8, "text/event-stream"); + var content = new StringContent(_bodies.Dequeue(), Encoding.UTF8, "text/event-stream"); return new HttpResponseMessage(HttpStatusCode.OK) { RequestMessage = request, Content = content }; } } From c955289a7bb52919833b72c9d533d21d51fe6cb2 Mon Sep 17 00:00:00 2001 From: thomasluizon Date: Sat, 8 Aug 2026 01:05:53 -0300 Subject: [PATCH 3/6] test: update AI usage index expectation --- .../Persistence/OrbitDbContextTests.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/Orbit.Infrastructure.Tests/Persistence/OrbitDbContextTests.cs b/tests/Orbit.Infrastructure.Tests/Persistence/OrbitDbContextTests.cs index 59c32325..0bdc8d9e 100644 --- a/tests/Orbit.Infrastructure.Tests/Persistence/OrbitDbContextTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Persistence/OrbitDbContextTests.cs @@ -352,7 +352,10 @@ public static TheoryData UniqueIndexCases() nameof(ProcessedRequest.UserId), nameof(ProcessedRequest.IdempotencyKey), nameof(ProcessedRequest.RequestType), }), (typeof(AiFactExtractionBatch), new[] { nameof(AiFactExtractionBatch.BatchId) }), - (typeof(AiUsageDaily), new[] { nameof(AiUsageDaily.Date), nameof(AiUsageDaily.Model), nameof(AiUsageDaily.Purpose) }), + (typeof(AiUsageDaily), new[] + { + nameof(AiUsageDaily.Date), nameof(AiUsageDaily.Model), nameof(AiUsageDaily.Purpose), nameof(AiUsageDaily.UserId), + }), (typeof(Referral), new[] { nameof(Referral.ReferredUserId) }), (typeof(UserAchievement), new[] { nameof(UserAchievement.UserId), nameof(UserAchievement.AchievementId) }), (typeof(StreakFreeze), new[] { nameof(StreakFreeze.UserId), nameof(StreakFreeze.UsedOnDate) }), From acd3fb3614c8903eae9456ce5836bacb2ae27ee8 Mon Sep 17 00:00:00 2001 From: thomasluizon Date: Sat, 8 Aug 2026 01:09:12 -0300 Subject: [PATCH 4/6] fix: preserve usage totals on migration rollback --- ...0260808040120_RecordAstraChatTokenUsage.cs | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/src/Orbit.Infrastructure/Migrations/20260808040120_RecordAstraChatTokenUsage.cs b/src/Orbit.Infrastructure/Migrations/20260808040120_RecordAstraChatTokenUsage.cs index 80dde1f5..1982c1b8 100644 --- a/src/Orbit.Infrastructure/Migrations/20260808040120_RecordAstraChatTokenUsage.cs +++ b/src/Orbit.Infrastructure/Migrations/20260808040120_RecordAstraChatTokenUsage.cs @@ -30,6 +30,52 @@ CREATE UNIQUE INDEX IF NOT EXISTS "IX_AiUsageDaily_Date_Model_Purpose_UserId" /// protected override void Down(MigrationBuilder migrationBuilder) { + migrationBuilder.Sql(""" + UPDATE "AiUsageDaily" AS target + SET + "Calls" = totals."Calls", + "CachedTokens" = totals."CachedTokens", + "PromptTokens" = totals."PromptTokens", + "CompletionTokens" = totals."CompletionTokens", + "TotalTokens" = totals."TotalTokens", + "CostUsd" = totals."CostUsd" + FROM ( + SELECT + "Date", + "Model", + "Purpose", + SUM("Calls")::bigint AS "Calls", + SUM("CachedTokens")::bigint AS "CachedTokens", + SUM("PromptTokens")::bigint AS "PromptTokens", + SUM("CompletionTokens")::bigint AS "CompletionTokens", + SUM("TotalTokens")::bigint AS "TotalTokens", + SUM("CostUsd") AS "CostUsd" + FROM "AiUsageDaily" + GROUP BY "Date", "Model", "Purpose" + ) AS totals + WHERE target."Id" = ( + SELECT source."Id" + FROM "AiUsageDaily" AS source + WHERE source."Date" = totals."Date" + AND source."Model" = totals."Model" + AND source."Purpose" = totals."Purpose" + ORDER BY source."Id" + LIMIT 1 + ); + + DELETE FROM "AiUsageDaily" AS target + USING ( + SELECT + "Id", + ROW_NUMBER() OVER ( + PARTITION BY "Date", "Model", "Purpose" + ORDER BY "Id") AS "RowNumber" + FROM "AiUsageDaily" + ) AS ranked + WHERE target."Id" = ranked."Id" + AND ranked."RowNumber" > 1; + """); + migrationBuilder.DropIndex( name: "IX_AiUsageDaily_Date_Model_Purpose_UserId", table: "AiUsageDaily"); From 332136c82c7346cdc42a5dc8359a3fb88435719b Mon Sep 17 00:00:00 2001 From: thomasluizon Date: Sat, 8 Aug 2026 01:45:16 -0300 Subject: [PATCH 5/6] fix: delete attributed AI usage with accounts An explicit delete matches AccountResetRepository's existing user linked row removal pattern and avoids introducing a new schema relationship. --- .../Persistence/AccountResetRepository.cs | 4 ++++ .../Services/AccountDeletionServiceDbTests.cs | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/Orbit.Infrastructure/Persistence/AccountResetRepository.cs b/src/Orbit.Infrastructure/Persistence/AccountResetRepository.cs index 97b40a52..6ea341f1 100644 --- a/src/Orbit.Infrastructure/Persistence/AccountResetRepository.cs +++ b/src/Orbit.Infrastructure/Persistence/AccountResetRepository.cs @@ -43,6 +43,10 @@ await context.GoalProgressLogs .ExecuteDeleteAsync(cancellationToken); } + await context.AiUsageDaily + .Where(u => u.UserId == userId) + .ExecuteDeleteAsync(cancellationToken); + await context.Notifications .IgnoreQueryFilters() .Where(n => n.UserId == userId) diff --git a/tests/Orbit.Infrastructure.Tests/Services/AccountDeletionServiceDbTests.cs b/tests/Orbit.Infrastructure.Tests/Services/AccountDeletionServiceDbTests.cs index 0ad38437..76768edb 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/AccountDeletionServiceDbTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/AccountDeletionServiceDbTests.cs @@ -102,6 +102,25 @@ public async Task RunAsync_DeletesPastDueUserWithOwnedDataAndIdempotencyLedger() (await _dbContext.SentProactiveCheckins.AnyAsync(p => p.UserId == userId)).Should().BeFalse(); } + [Fact] + public async Task RunAsync_DeletesPastDueUsersAttributedAiUsage() + { + var userId = Guid.NewGuid(); + SeedDeactivatedUser(userId, "ai-usage@example.com", DateTime.UtcNow.AddDays(-1)); + _dbContext.AiUsageDaily.Add(AiUsageDaily.Create( + DateOnly.FromDateTime(DateTime.UtcNow), + "gpt-4.1-mini", + "astra_chat", + new AiUsageTotals(1, 0, 100, 50, 150, 0.001m), + userId)); + await _dbContext.SaveChangesAsync(); + _dbContext.ChangeTracker.Clear(); + + await _service.RunAsync(CancellationToken.None); + + (await _dbContext.AiUsageDaily.AnyAsync(u => u.UserId == userId)).Should().BeFalse(); + } + [Fact] public async Task CleanupStaleSentRecords_RemovesProcessedRequestsOlderThan30Days_KeepsRecent() { From 063488d5feddf33a61760b85cd34c301648e9dbc Mon Sep 17 00:00:00 2001 From: thomasluizon Date: Sat, 8 Aug 2026 01:47:43 -0300 Subject: [PATCH 6/6] chore: regenerate the architecture map after the account-deletion fix The arch-map drift gate regenerates architecture.json and architecture.html with node tools/arch-map.mjs and fails on any diff. The P1 account-deletion fix changed AccountResetRepository without regenerating them, so the committed map was stale. Generated output only, produced by the repository's own generator. --- architecture.html | 2 +- architecture.json | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/architecture.html b/architecture.html index fb6098b7..81e71ce8 100644 --- a/architecture.html +++ b/architecture.html @@ -47,7 +47,7 @@

Handlers with no endpoint

RequestHandler file

Entities

EntityDomain file
- +