diff --git a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs index 608e735c..aa53e915 100644 --- a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs +++ b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs @@ -9,6 +9,7 @@ using Sentry.AspNetCore; using Orbit.Api.Authentication; using Orbit.Api.Authorization; +using Orbit.Api.Idempotency; using Orbit.Api.OAuth; using Orbit.Application.Behaviors; using Orbit.Application.Common; @@ -67,6 +68,7 @@ public static WebApplicationBuilder AddOrbitDatabase(this WebApplicationBuilder builder.Services.AddScoped(typeof(IGenericRepository<>), typeof(GenericRepository<>)); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -214,11 +216,15 @@ public static WebApplicationBuilder AddOrbitInfrastructure(this WebApplicationBu builder.Services.AddValidatorsFromAssemblyContaining(); + builder.Services.AddHttpContextAccessor(); + builder.Services.AddScoped(); + builder.Services.AddMediatR(cfg => { cfg.RegisterServicesFromAssembly(typeof(Orbit.Application.Chat.Commands.ProcessUserChatCommand).Assembly); cfg.AddOpenBehavior(typeof(ConcurrencyRetryBehavior<,>)); cfg.AddOpenBehavior(typeof(ValidationBehavior<,>)); + cfg.AddOpenBehavior(typeof(IdempotencyBehavior<,>)); }); AddCorsPolicies(builder); diff --git a/src/Orbit.Api/Idempotency/HttpIdempotencyContext.cs b/src/Orbit.Api/Idempotency/HttpIdempotencyContext.cs new file mode 100644 index 00000000..c1840918 --- /dev/null +++ b/src/Orbit.Api/Idempotency/HttpIdempotencyContext.cs @@ -0,0 +1,37 @@ +using System.Diagnostics.CodeAnalysis; +using System.Security.Claims; +using Orbit.Application.Common; + +namespace Orbit.Api.Idempotency; + +/// +/// Reads the Idempotency-Key header and authenticated user id from the current HTTP request. +/// Only requests that carry the header (the mobile offline queue's replayable mutations) opt into +/// idempotency; every read and un-keyed request bypasses it. See thomasluizon/orbit-ui-mobile#243. +/// +public sealed class HttpIdempotencyContext(IHttpContextAccessor httpContextAccessor) : IIdempotencyContext +{ + private const string IdempotencyKeyHeaderName = "Idempotency-Key"; + private const int MaxKeyLength = 200; + + public bool TryGetRequestKey(out Guid userId, [NotNullWhen(true)] out string idempotencyKey) + { + userId = Guid.Empty; + idempotencyKey = ""; + + var httpContext = httpContextAccessor.HttpContext; + if (httpContext is null) + return false; + + var key = httpContext.Request.Headers[IdempotencyKeyHeaderName].ToString().Trim(); + if (string.IsNullOrEmpty(key) || key.Length > MaxKeyLength) + return false; + + var userIdClaim = httpContext.User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (userIdClaim is null || !Guid.TryParse(userIdClaim, out userId)) + return false; + + idempotencyKey = key; + return true; + } +} diff --git a/src/Orbit.Application/Behaviors/IdempotencyBehavior.cs b/src/Orbit.Application/Behaviors/IdempotencyBehavior.cs new file mode 100644 index 00000000..87936005 --- /dev/null +++ b/src/Orbit.Application/Behaviors/IdempotencyBehavior.cs @@ -0,0 +1,72 @@ +using System.Text.Json; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Orbit.Application.Common; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Behaviors; + +/// +/// Makes replays of opt-in mutations that carry an Idempotency-Key +/// header exactly-once: a replayed request (a retry after a lost network ACK, routine on mobile) returns the +/// stored response instead of re-executing the handler. The reservation row is flushed first — isolating a +/// ledger unique-violation from the handler's own constraints — then commits in one transaction with the +/// handler's mutation, so a crash cannot leave the mutation applied without its idempotency record. A +/// concurrent duplicate loses the unique-index race and replays the winner's response. The ledger key is +/// scoped by request type so one key reused across two commands can't cross wires. See +/// thomasluizon/orbit-ui-mobile#243. +/// +public sealed class IdempotencyBehavior( + IIdempotencyContext idempotencyContext, + IIdempotencyStore idempotencyStore, + IUnitOfWork unitOfWork) : IPipelineBehavior + where TRequest : class +{ + private static readonly JsonSerializerOptions SerializerOptions = + new(JsonSerializerDefaults.Web) { Converters = { new ResultJsonConverterFactory() } }; + + private static readonly string RequestType = typeof(TRequest).FullName ?? typeof(TRequest).Name; + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + if (request is not IIdempotentCommand + || !idempotencyContext.TryGetRequestKey(out var userId, out var idempotencyKey)) + return await next(cancellationToken); + + var storedResponse = await idempotencyStore.FindResponseBodyAsync(userId, idempotencyKey, RequestType, cancellationToken); + if (storedResponse is not null) + return Deserialize(storedResponse); + + var response = default(TResponse)!; + try + { + await unitOfWork.ExecuteInTransactionAsync(async transactionToken => + { + var reservation = idempotencyStore.Reserve(userId, idempotencyKey, RequestType); + await unitOfWork.SaveChangesAsync(transactionToken); + response = await next(transactionToken); + reservation.SetResponseBody(Serialize(response)); + await unitOfWork.SaveChangesAsync(transactionToken); + }, cancellationToken); + } + catch (DbUpdateException exception) when (DbUniqueViolation.IsUniqueViolation(exception)) + { + var racedResponse = await idempotencyStore.FindResponseBodyAsync(userId, idempotencyKey, RequestType, cancellationToken); + if (racedResponse is null) + throw; + + return Deserialize(racedResponse); + } + + return response; + } + + private static string Serialize(TResponse response) => + JsonSerializer.Serialize(response, SerializerOptions); + + private static TResponse Deserialize(string responseBody) => + JsonSerializer.Deserialize(responseBody, SerializerOptions)!; +} diff --git a/src/Orbit.Application/Common/IIdempotencyContext.cs b/src/Orbit.Application/Common/IIdempotencyContext.cs new file mode 100644 index 00000000..b5ba1b13 --- /dev/null +++ b/src/Orbit.Application/Common/IIdempotencyContext.cs @@ -0,0 +1,10 @@ +namespace Orbit.Application.Common; + +/// +/// Surfaces the current request's client idempotency key and authenticated user, if both are present, +/// so the idempotency pipeline behavior can dedupe replayed mutations without depending on ASP.NET Core. +/// +public interface IIdempotencyContext +{ + bool TryGetRequestKey(out Guid userId, out string idempotencyKey); +} diff --git a/src/Orbit.Application/Common/IIdempotencyStore.cs b/src/Orbit.Application/Common/IIdempotencyStore.cs new file mode 100644 index 00000000..2be4f39d --- /dev/null +++ b/src/Orbit.Application/Common/IIdempotencyStore.cs @@ -0,0 +1,32 @@ +namespace Orbit.Application.Common; + +/// +/// Persists and retrieves idempotency-ledger records so a replayed client mutation (identified by an +/// Idempotency-Key) returns its original response instead of re-executing. See +/// thomasluizon/orbit-ui-mobile#243. +/// +public interface IIdempotencyStore +{ + /// + /// Returns the stored serialized response for a previously-processed (user, key, request type), or + /// null if that combination has not been processed. The request type scopes the key so reusing + /// one key across two different commands never returns the wrong command's cached response. + /// + Task FindResponseBodyAsync(Guid userId, string idempotencyKey, string requestType, CancellationToken cancellationToken); + + /// + /// Adds a tracked, uncommitted reservation for the (user, key, request type) so it commits atomically + /// with the wrapped handler's mutation. The response body is filled in via the returned reservation + /// after the handler runs. + /// + IIdempotencyReservation Reserve(Guid userId, string idempotencyKey, string requestType); +} + +/// +/// A tracked, not-yet-committed idempotency reservation whose response body is set after the wrapped +/// handler runs, so the reservation and the mutation persist in the same transaction. +/// +public interface IIdempotencyReservation +{ + void SetResponseBody(string responseBody); +} diff --git a/src/Orbit.Application/Common/IIdempotentCommand.cs b/src/Orbit.Application/Common/IIdempotentCommand.cs new file mode 100644 index 00000000..c718ee79 --- /dev/null +++ b/src/Orbit.Application/Common/IIdempotentCommand.cs @@ -0,0 +1,10 @@ +namespace Orbit.Application.Common; + +/// +/// Opt-in marker for commands whose replay must be deduped by the idempotency ledger — the small set of +/// non-idempotent, offline-queued mutations where a lost-ACK retry would double-apply (a duplicate entity, +/// a reversed habit-log toggle, a duplicate skip/progress row). Only these commands' responses are cached. +/// Naturally-idempotent commands and any command returning a one-time secret MUST NOT be marked, so a +/// secret can never land in the plaintext ledger. See thomasluizon/orbit-ui-mobile#243. +/// +public interface IIdempotentCommand; diff --git a/src/Orbit.Application/Common/ResultJsonConverterFactory.cs b/src/Orbit.Application/Common/ResultJsonConverterFactory.cs new file mode 100644 index 00000000..cdcf3be0 --- /dev/null +++ b/src/Orbit.Application/Common/ResultJsonConverterFactory.cs @@ -0,0 +1,100 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Orbit.Domain.Common; + +namespace Orbit.Application.Common; + +/// +/// Round-trips and through System.Text.Json for the +/// idempotency ledger. The default reflection converter cannot: throws on +/// a failed result and the non-generic has no public constructor. This converter +/// reads Value only when the result succeeded and rebuilds via the factory methods, so a cached +/// success or failure replays without crashing. See thomasluizon/orbit-ui-mobile#243. +/// +public sealed class ResultJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(Type typeToConvert) => + typeToConvert == typeof(Result) + || (typeToConvert.IsGenericType && typeToConvert.GetGenericTypeDefinition() == typeof(Result<>)); + + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + if (typeToConvert == typeof(Result)) + return new ResultConverter(); + + var valueType = typeToConvert.GetGenericArguments()[0]; + return (JsonConverter)Activator.CreateInstance( + typeof(GenericResultConverter<>).MakeGenericType(valueType))!; + } + + private static (bool IsSuccess, string Error, string? ErrorCode) ReadEnvelope(JsonElement root) + { + var isSuccess = root.GetProperty("isSuccess").GetBoolean(); + var error = root.TryGetProperty("error", out var errorElement) ? errorElement.GetString() ?? "" : ""; + var errorCode = root.TryGetProperty("errorCode", out var codeElement) && codeElement.ValueKind == JsonValueKind.String + ? codeElement.GetString() + : null; + return (isSuccess, error, errorCode); + } + + private static void WriteEnvelope(Utf8JsonWriter writer, Result value) + { + writer.WriteBoolean("isSuccess", value.IsSuccess); + writer.WriteString("error", value.Error); + if (value.ErrorCode is null) + writer.WriteNull("errorCode"); + else + writer.WriteString("errorCode", value.ErrorCode); + } + + private sealed class ResultConverter : JsonConverter + { + public override Result Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var document = JsonDocument.ParseValue(ref reader); + var (isSuccess, error, errorCode) = ReadEnvelope(document.RootElement); + if (isSuccess) + return Result.Success(); + return errorCode is null ? Result.Failure(error) : Result.Failure(error, errorCode); + } + + public override void Write(Utf8JsonWriter writer, Result value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + WriteEnvelope(writer, value); + writer.WriteEndObject(); + } + } + + private sealed class GenericResultConverter : JsonConverter> + { + public override Result Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var document = JsonDocument.ParseValue(ref reader); + var root = document.RootElement; + var (isSuccess, error, errorCode) = ReadEnvelope(root); + + if (isSuccess) + { + var value = root.TryGetProperty("value", out var valueElement) + ? valueElement.Deserialize(options) + : default; + return Result.Success(value!); + } + + return errorCode is null ? Result.Failure(error) : Result.Failure(error, errorCode); + } + + public override void Write(Utf8JsonWriter writer, Result value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + WriteEnvelope(writer, value); + if (value.IsSuccess) + { + writer.WritePropertyName("value"); + JsonSerializer.Serialize(writer, value.Value, options); + } + writer.WriteEndObject(); + } + } +} diff --git a/src/Orbit.Application/Goals/Commands/CreateGoalCommand.cs b/src/Orbit.Application/Goals/Commands/CreateGoalCommand.cs index 91696023..487f8e9b 100644 --- a/src/Orbit.Application/Goals/Commands/CreateGoalCommand.cs +++ b/src/Orbit.Application/Goals/Commands/CreateGoalCommand.cs @@ -16,7 +16,7 @@ public record CreateGoalCommand( string Unit, DateOnly? Deadline, int Position = 0, - GoalType Type = GoalType.Standard) : IRequest>; + GoalType Type = GoalType.Standard) : IRequest>, IIdempotentCommand; public partial class CreateGoalCommandHandler( IGenericRepository goalRepository, diff --git a/src/Orbit.Application/Goals/Commands/UpdateGoalProgressCommand.cs b/src/Orbit.Application/Goals/Commands/UpdateGoalProgressCommand.cs index 8f1ceef6..effb3a71 100644 --- a/src/Orbit.Application/Goals/Commands/UpdateGoalProgressCommand.cs +++ b/src/Orbit.Application/Goals/Commands/UpdateGoalProgressCommand.cs @@ -11,7 +11,7 @@ public record UpdateGoalProgressCommand( Guid UserId, Guid GoalId, decimal NewValue, - string? Note = null) : IRequest; + string? Note = null) : IRequest, IIdempotentCommand; public partial class UpdateGoalProgressCommandHandler( IGenericRepository goalRepository, diff --git a/src/Orbit.Application/Habits/Commands/CreateHabitCommand.cs b/src/Orbit.Application/Habits/Commands/CreateHabitCommand.cs index a61af261..44bf8aab 100644 --- a/src/Orbit.Application/Habits/Commands/CreateHabitCommand.cs +++ b/src/Orbit.Application/Habits/Commands/CreateHabitCommand.cs @@ -22,7 +22,7 @@ public record CreateHabitCommand( HabitCommandOptions? Options = null, IReadOnlyList? TagIds = null, IReadOnlyList? GoalIds = null, - string? Emoji = null) : IRequest>; + string? Emoji = null) : IRequest>, IIdempotentCommand; /// /// Groups repository dependencies for habit creation to reduce constructor parameter count (S107). diff --git a/src/Orbit.Application/Habits/Commands/LogHabitCommand.cs b/src/Orbit.Application/Habits/Commands/LogHabitCommand.cs index ec12e525..382b327a 100644 --- a/src/Orbit.Application/Habits/Commands/LogHabitCommand.cs +++ b/src/Orbit.Application/Habits/Commands/LogHabitCommand.cs @@ -29,7 +29,7 @@ public record LogHabitResponse( public record LogHabitCommand( Guid UserId, Guid HabitId, - DateOnly? Date = null) : IRequest>; + DateOnly? Date = null) : IRequest>, IIdempotentCommand; /// /// Groups repository dependencies for habit logging to reduce constructor parameter count (S107). diff --git a/src/Orbit.Application/Habits/Commands/SkipHabitCommand.cs b/src/Orbit.Application/Habits/Commands/SkipHabitCommand.cs index c4ef1cbf..e0a7d484 100644 --- a/src/Orbit.Application/Habits/Commands/SkipHabitCommand.cs +++ b/src/Orbit.Application/Habits/Commands/SkipHabitCommand.cs @@ -16,7 +16,7 @@ namespace Orbit.Application.Habits.Commands; public record SkipHabitCommand( Guid UserId, Guid HabitId, - DateOnly? Date = null) : IRequest, IConcurrencyRetryable; + DateOnly? Date = null) : IRequest, IConcurrencyRetryable, IIdempotentCommand; public partial class SkipHabitCommandHandler( IGenericRepository habitRepository, diff --git a/src/Orbit.Application/Tags/Commands/CreateTagCommand.cs b/src/Orbit.Application/Tags/Commands/CreateTagCommand.cs index 6365e5b7..e269dfae 100644 --- a/src/Orbit.Application/Tags/Commands/CreateTagCommand.cs +++ b/src/Orbit.Application/Tags/Commands/CreateTagCommand.cs @@ -9,7 +9,7 @@ namespace Orbit.Application.Tags.Commands; public record CreateTagCommand( Guid UserId, string Name, - string Color) : IRequest>; + string Color) : IRequest>, IIdempotentCommand; public class CreateTagCommandHandler( IGenericRepository tagRepository, diff --git a/src/Orbit.Domain/Entities/ProcessedRequest.cs b/src/Orbit.Domain/Entities/ProcessedRequest.cs new file mode 100644 index 00000000..228c97bb --- /dev/null +++ b/src/Orbit.Domain/Entities/ProcessedRequest.cs @@ -0,0 +1,40 @@ +using Orbit.Domain.Common; + +namespace Orbit.Domain.Entities; + +/// +/// Idempotency ledger for client-initiated mutations. Records that a request carrying a given client +/// Idempotency-Key (a mobile offline-queue mutation id) was processed for a user and stores the +/// serialized response, so a replay — a retry after a lost network ACK, routine on mobile — returns the +/// original outcome instead of re-executing the mutation. See thomasluizon/orbit-ui-mobile#243. +/// +public class ProcessedRequest : Entity +{ + public Guid UserId { get; private set; } + + public string IdempotencyKey { get; private set; } = ""; + + public string RequestType { get; private set; } = ""; + + public string ResponseBody { get; private set; } = ""; + + public DateTime CreatedAtUtc { get; private set; } + + private ProcessedRequest() { } + + public static ProcessedRequest Create(Guid userId, string idempotencyKey, string requestType) + { + return new ProcessedRequest + { + UserId = userId, + IdempotencyKey = idempotencyKey, + RequestType = requestType, + CreatedAtUtc = DateTime.UtcNow, + }; + } + + public void SetResponseBody(string responseBody) + { + ResponseBody = responseBody; + } +} diff --git a/src/Orbit.Domain/Interfaces/IAccountResetRepository.cs b/src/Orbit.Domain/Interfaces/IAccountResetRepository.cs index c58f3c24..d4184ca7 100644 --- a/src/Orbit.Domain/Interfaces/IAccountResetRepository.cs +++ b/src/Orbit.Domain/Interfaces/IAccountResetRepository.cs @@ -6,8 +6,10 @@ namespace Orbit.Domain.Interfaces; public interface IAccountResetRepository { /// - /// Deletes all user-created data for the given user in a single transaction. - /// Does not modify the User entity itself. + /// Bulk-deletes all user-owned data for the given user. Issues many auto-committing + /// ExecuteDeleteAsync calls and opens no transaction of its own, so callers MUST invoke it + /// inside for the deletes to be atomic. Does not + /// modify the User entity itself. /// Task DeleteAllUserDataAsync(Guid userId, CancellationToken cancellationToken = default); } diff --git a/src/Orbit.Infrastructure/Migrations/20260711012225_AddProcessedRequests.Designer.cs b/src/Orbit.Infrastructure/Migrations/20260711012225_AddProcessedRequests.Designer.cs new file mode 100644 index 00000000..23d106f0 --- /dev/null +++ b/src/Orbit.Infrastructure/Migrations/20260711012225_AddProcessedRequests.Designer.cs @@ -0,0 +1,2560 @@ +// +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("20260711012225_AddProcessedRequests")] + partial class AddProcessedRequests + { + /// + 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(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("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.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() + .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("IsAdmin") + .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("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") + .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.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.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/20260711012225_AddProcessedRequests.cs b/src/Orbit.Infrastructure/Migrations/20260711012225_AddProcessedRequests.cs new file mode 100644 index 00000000..3c063a79 --- /dev/null +++ b/src/Orbit.Infrastructure/Migrations/20260711012225_AddProcessedRequests.cs @@ -0,0 +1,55 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Orbit.Infrastructure.Migrations +{ + /// + public partial class AddProcessedRequests : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ProcessedRequests", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + IdempotencyKey = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + RequestType = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + ResponseBody = table.Column(type: "text", nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ProcessedRequests", x => x.Id); + table.ForeignKey( + name: "FK_ProcessedRequests_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ProcessedRequests_CreatedAtUtc", + table: "ProcessedRequests", + column: "CreatedAtUtc"); + + migrationBuilder.CreateIndex( + name: "IX_ProcessedRequests_UserId_IdempotencyKey_RequestType", + table: "ProcessedRequests", + columns: new[] { "UserId", "IdempotencyKey", "RequestType" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ProcessedRequests"); + } + } +} diff --git a/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs b/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs index 9a94ac6c..86fcd9b6 100644 --- a/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs +++ b/src/Orbit.Infrastructure/Migrations/OrbitDbContextModelSnapshot.cs @@ -1497,6 +1497,42 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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") @@ -2411,6 +2447,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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) diff --git a/src/Orbit.Infrastructure/Persistence/AccountResetRepository.cs b/src/Orbit.Infrastructure/Persistence/AccountResetRepository.cs index 8f70f0aa..97b40a52 100644 --- a/src/Orbit.Infrastructure/Persistence/AccountResetRepository.cs +++ b/src/Orbit.Infrastructure/Persistence/AccountResetRepository.cs @@ -107,6 +107,10 @@ await context.PendingClarifications .Where(pc => pc.UserId == userId) .ExecuteDeleteAsync(cancellationToken); + await context.ProcessedRequests + .Where(r => r.UserId == userId) + .ExecuteDeleteAsync(cancellationToken); + await context.Referrals .Where(r => r.ReferrerId == userId || r.ReferredUserId == userId) .ExecuteDeleteAsync(cancellationToken); diff --git a/src/Orbit.Infrastructure/Persistence/IdempotencyStore.cs b/src/Orbit.Infrastructure/Persistence/IdempotencyStore.cs new file mode 100644 index 00000000..b5a9339c --- /dev/null +++ b/src/Orbit.Infrastructure/Persistence/IdempotencyStore.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore; +using Orbit.Application.Common; +using Orbit.Domain.Entities; + +namespace Orbit.Infrastructure.Persistence; + +public sealed class IdempotencyStore(OrbitDbContext context) : IIdempotencyStore +{ + public async Task FindResponseBodyAsync(Guid userId, string idempotencyKey, string requestType, CancellationToken cancellationToken) + { + return await context.ProcessedRequests + .AsNoTracking() + .Where(request => request.UserId == userId + && request.IdempotencyKey == idempotencyKey + && request.RequestType == requestType) + .Select(request => request.ResponseBody) + .FirstOrDefaultAsync(cancellationToken); + } + + public IIdempotencyReservation Reserve(Guid userId, string idempotencyKey, string requestType) + { + var record = ProcessedRequest.Create(userId, idempotencyKey, requestType); + context.ProcessedRequests.Add(record); + return new Reservation(record); + } + + private sealed class Reservation(ProcessedRequest record) : IIdempotencyReservation + { + public void SetResponseBody(string responseBody) => record.SetResponseBody(responseBody); + } +} diff --git a/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs b/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs index c70702e2..fb4501f6 100644 --- a/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs +++ b/src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs @@ -57,6 +57,7 @@ public OrbitDbContext(DbContextOptions options, IEncryptionServi public DbSet GoogleCalendarSyncSuggestions => Set(); public DbSet ProcessedPlayNotifications => Set(); public DbSet ProcessedStripeEvents => Set(); + public DbSet ProcessedRequests => Set(); public DbSet AiFactExtractionBatches => Set(); public DbSet AiUsageDaily => Set(); public DbSet Friendships => Set(); @@ -99,6 +100,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) ConfigureSentStreakFreezeAlertEntity(modelBuilder); ConfigureProcessedPlayNotificationEntity(modelBuilder); ConfigureProcessedStripeEventEntity(modelBuilder); + ConfigureProcessedRequestEntity(modelBuilder); ConfigureAiFactExtractionBatchEntity(modelBuilder); ConfigureAiUsageDailyEntity(modelBuilder); ConfigureNotificationEntity(modelBuilder); @@ -234,6 +236,18 @@ private static void ConfigureProcessedStripeEventEntity(ModelBuilder modelBuilde }); } + private static void ConfigureProcessedRequestEntity(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasIndex(request => new { request.UserId, request.IdempotencyKey, request.RequestType }).IsUnique(); + entity.HasIndex(request => request.CreatedAtUtc); + entity.Property(request => request.IdempotencyKey).IsRequired().HasMaxLength(200); + entity.Property(request => request.RequestType).IsRequired().HasMaxLength(256); + entity.HasOne().WithMany().HasForeignKey(request => request.UserId).OnDelete(DeleteBehavior.Cascade); + }); + } + private static void ConfigureAiFactExtractionBatchEntity(ModelBuilder modelBuilder) { modelBuilder.Entity(entity => diff --git a/src/Orbit.Infrastructure/Persistence/UnitOfWork.cs b/src/Orbit.Infrastructure/Persistence/UnitOfWork.cs index 619bbda5..e25887ef 100644 --- a/src/Orbit.Infrastructure/Persistence/UnitOfWork.cs +++ b/src/Orbit.Infrastructure/Persistence/UnitOfWork.cs @@ -16,7 +16,8 @@ public async Task ExecuteInTransactionAsync( { ArgumentNullException.ThrowIfNull(operation); - if (!UseRelationalTransactionPath()) + // Join an ambient transaction (e.g. IdempotencyBehavior's) rather than nest, which Npgsql forbids: https://github.com/thomasluizon/orbit-ui-mobile/issues/243 + if (!UseRelationalTransactionPath() || context.Database.CurrentTransaction is not null) { await operation(cancellationToken); return; diff --git a/src/Orbit.Infrastructure/Services/AccountDeletionService.cs b/src/Orbit.Infrastructure/Services/AccountDeletionService.cs index 8037eb4c..0ec26dfe 100644 --- a/src/Orbit.Infrastructure/Services/AccountDeletionService.cs +++ b/src/Orbit.Infrastructure/Services/AccountDeletionService.cs @@ -91,10 +91,14 @@ private async Task DeleteUserAccountAsync(Guid userId, CancellationToken ct) var userToDelete = await dbContext.Users.FindAsync([userId], ct); if (userToDelete is not null) { + var unitOfWork = scope.ServiceProvider.GetRequiredService(); var resetRepository = scope.ServiceProvider.GetRequiredService(); - await resetRepository.DeleteAllUserDataAsync(userId, ct); - dbContext.Users.Remove(userToDelete); - await dbContext.SaveChangesAsync(ct); + await unitOfWork.ExecuteInTransactionAsync(async transactionToken => + { + await resetRepository.DeleteAllUserDataAsync(userId, transactionToken); + dbContext.Users.Remove(userToDelete); + await unitOfWork.SaveChangesAsync(transactionToken); + }, ct); } if (logger.IsEnabled(LogLevel.Information)) @@ -126,10 +130,17 @@ internal async Task CleanupStaleSentRecords(CancellationToken ct) .Where(a => a.FrozenDate < cutoff) .ExecuteDeleteAsync(ct); - if ((deletedReminders > 0 || deletedSlipAlerts > 0 || deletedStreakFreezeAlerts > 0) && logger.IsEnabled(LogLevel.Information)) - LogStaleRecordsCleaned(logger, deletedReminders, deletedSlipAlerts, deletedStreakFreezeAlerts); + var processedRequestCutoff = DateTime.UtcNow.AddDays(-ProcessedRequestRetentionDays); + var deletedProcessedRequests = await dbContext.ProcessedRequests + .Where(r => r.CreatedAtUtc < processedRequestCutoff) + .ExecuteDeleteAsync(ct); + + if ((deletedReminders > 0 || deletedSlipAlerts > 0 || deletedStreakFreezeAlerts > 0 || deletedProcessedRequests > 0) && logger.IsEnabled(LogLevel.Information)) + LogStaleRecordsCleaned(logger, deletedReminders, deletedSlipAlerts, deletedStreakFreezeAlerts, deletedProcessedRequests); } + private const int ProcessedRequestRetentionDays = 30; + [LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "AccountDeletionService started")] private static partial void LogServiceStarted(ILogger logger); @@ -148,7 +159,7 @@ internal async Task CleanupStaleSentRecords(CancellationToken ct) [LoggerMessage(EventId = 6, Level = LogLevel.Error, Message = "Failed to delete account {UserId}")] private static partial void LogAccountDeletionFailed(ILogger logger, Exception ex, Guid userId); - [LoggerMessage(EventId = 7, Level = LogLevel.Information, Message = "Cleaned up {Reminders} stale SentReminders, {SlipAlerts} stale SentSlipAlerts, and {StreakFreezeAlerts} stale SentStreakFreezeAlerts older than 90 days")] - private static partial void LogStaleRecordsCleaned(ILogger logger, int reminders, int slipAlerts, int streakFreezeAlerts); + [LoggerMessage(EventId = 7, Level = LogLevel.Information, Message = "Cleaned up {Reminders} stale SentReminders, {SlipAlerts} stale SentSlipAlerts, and {StreakFreezeAlerts} stale SentStreakFreezeAlerts older than 90 days, plus {ProcessedRequests} ProcessedRequests older than 30 days")] + private static partial void LogStaleRecordsCleaned(ILogger logger, int reminders, int slipAlerts, int streakFreezeAlerts, int processedRequests); } diff --git a/tests/Orbit.Infrastructure.Tests/Behaviors/IdempotencyBehaviorDbTests.cs b/tests/Orbit.Infrastructure.Tests/Behaviors/IdempotencyBehaviorDbTests.cs new file mode 100644 index 00000000..aa2a63f5 --- /dev/null +++ b/tests/Orbit.Infrastructure.Tests/Behaviors/IdempotencyBehaviorDbTests.cs @@ -0,0 +1,263 @@ +using FluentAssertions; +using MediatR; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Orbit.Application.Behaviors; +using Orbit.Application.Common; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Infrastructure.Persistence; + +namespace Orbit.Infrastructure.Tests.Behaviors; + +public class IdempotencyBehaviorDbTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly OrbitDbContext _dbContext; + private readonly UnitOfWork _unitOfWork; + private readonly IdempotencyStore _store; + private readonly Guid _userId = Guid.NewGuid(); + + private int _handlerCalls; + + public IdempotencyBehaviorDbTests() + { + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + _dbContext = new SqliteCompatOrbitDbContext(options); + _dbContext.Database.EnsureCreated(); + + var user = User.Create("Test User", "idem@example.com").Value; + typeof(User).GetProperty("Id")!.SetValue(user, _userId); + _dbContext.Users.Add(user); + _dbContext.SaveChanges(); + + _unitOfWork = new UnitOfWork(_dbContext); + _store = new IdempotencyStore(_dbContext); + } + + public void Dispose() + { + _dbContext.Dispose(); + _connection.Dispose(); + GC.SuppressFinalize(this); + } + + [Fact] + public async Task Handle_NewKey_ExecutesHandlerOnceAndStoresResponse() + { + var behavior = CreateBehavior(); + + var response = await behavior.Handle(new FakeRequest(), CreateTagHandler(), CancellationToken.None); + + response.Should().Be("response-1"); + _handlerCalls.Should().Be(1); + (await _dbContext.ProcessedRequests.CountAsync()).Should().Be(1); + (await _dbContext.Tags.CountAsync()).Should().Be(1); + } + + [Fact] + public async Task Handle_ReplayedKey_ReturnsStoredResponseWithoutReExecuting() + { + var behavior = CreateBehavior(); + var handler = CreateTagHandler(); + + var first = await behavior.Handle(new FakeRequest(), handler, CancellationToken.None); + var replay = await behavior.Handle(new FakeRequest(), handler, CancellationToken.None); + + first.Should().Be("response-1"); + replay.Should().Be("response-1"); + _handlerCalls.Should().Be(1); + (await _dbContext.Tags.CountAsync()).Should().Be(1); + (await _dbContext.ProcessedRequests.CountAsync()).Should().Be(1); + } + + [Fact] + public async Task Handle_SuccessResultResponse_RoundTripsThroughLedgerOnReplay() + { + var behavior = CreateBehavior>(); + RequestHandlerDelegate> handler = async _ => + { + _handlerCalls++; + _dbContext.Tags.Add(Tag.Create(_userId, "tag", "#ff0000").Value); + await _unitOfWork.SaveChangesAsync(); + return Result.Success("created-id"); + }; + + var first = await behavior.Handle(new ResultRequest(), handler, CancellationToken.None); + var replay = await behavior.Handle(new ResultRequest(), handler, CancellationToken.None); + + first.IsSuccess.Should().BeTrue(); + first.Value.Should().Be("created-id"); + replay.IsSuccess.Should().BeTrue(); + replay.Value.Should().Be("created-id"); + _handlerCalls.Should().Be(1); + (await _dbContext.Tags.CountAsync()).Should().Be(1); + } + + [Fact] + public async Task Handle_FailureResultResponse_DoesNotCrashAndReplaysTheFailure() + { + var behavior = CreateBehavior>(); + RequestHandlerDelegate> handler = _ => + { + _handlerCalls++; + return Task.FromResult(Result.Failure("habit not found", "NOT_FOUND")); + }; + + var first = await behavior.Handle(new ResultRequest(), handler, CancellationToken.None); + var replay = await behavior.Handle(new ResultRequest(), handler, CancellationToken.None); + + first.IsFailure.Should().BeTrue(); + first.Error.Should().Be("habit not found"); + first.ErrorCode.Should().Be("NOT_FOUND"); + replay.IsFailure.Should().BeTrue(); + replay.Error.Should().Be("habit not found"); + replay.ErrorCode.Should().Be("NOT_FOUND"); + _handlerCalls.Should().Be(1); + } + + [Fact] + public async Task Handle_NonGenericResultResponse_RoundTripsThroughLedgerOnReplay() + { + var behavior = CreateBehavior(); + RequestHandlerDelegate handler = _ => + { + _handlerCalls++; + return Task.FromResult(Result.Success()); + }; + + var first = await behavior.Handle(new PlainResultRequest(), handler, CancellationToken.None); + var replay = await behavior.Handle(new PlainResultRequest(), handler, CancellationToken.None); + + first.IsSuccess.Should().BeTrue(); + replay.IsSuccess.Should().BeTrue(); + _handlerCalls.Should().Be(1); + } + + [Fact] + public async Task Handle_SameKeyDifferentRequestTypes_BothExecute() + { + var context = new StubIdempotencyContext(true, _userId, "shared-key"); + var first = new IdempotencyBehavior(context, _store, _unitOfWork); + var second = new IdempotencyBehavior(context, _store, _unitOfWork); + + var firstResponse = await first.Handle(new FakeRequest(), CountingHandler("first"), CancellationToken.None); + var secondResponse = await second.Handle(new OtherRequest(), CountingHandler("second"), CancellationToken.None); + + firstResponse.Should().Be("first"); + secondResponse.Should().Be("second"); + _handlerCalls.Should().Be(2); + (await _dbContext.ProcessedRequests.CountAsync()).Should().Be(2); + } + + [Fact] + public async Task Handle_UnmarkedRequest_BypassesLedgerEvenWithKey() + { + var behavior = new IdempotencyBehavior( + new StubIdempotencyContext(true, _userId, "mutation-key-1"), _store, _unitOfWork); + + var response = await behavior.Handle(new UnmarkedRequest(), CountingHandler("value"), CancellationToken.None); + + response.Should().Be("value"); + _handlerCalls.Should().Be(1); + (await _dbContext.ProcessedRequests.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Handle_NoIdempotencyKey_BypassesLedgerAndRunsHandler() + { + var behavior = CreateBehavior(hasKey: false); + + var response = await behavior.Handle(new FakeRequest(), CreateTagHandler(), CancellationToken.None); + + response.Should().Be("response-1"); + _handlerCalls.Should().Be(1); + (await _dbContext.ProcessedRequests.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Handle_HandlerThrows_RollsBackReservationAndMutationTogether() + { + var behavior = CreateBehavior(); + RequestHandlerDelegate throwingHandler = async _ => + { + _dbContext.Tags.Add(Tag.Create(_userId, "doomed-tag", "#ff0000").Value); + await _unitOfWork.SaveChangesAsync(); + throw new InvalidOperationException("handler failed after a partial write"); + }; + + var act = () => behavior.Handle(new FakeRequest(), throwingHandler, CancellationToken.None); + + await act.Should().ThrowAsync(); + (await _dbContext.Tags.CountAsync()).Should().Be(0); + (await _dbContext.ProcessedRequests.CountAsync()).Should().Be(0); + } + + private IdempotencyBehavior CreateBehavior(bool hasKey = true) + where TRequest : class => + new(new StubIdempotencyContext(hasKey, _userId, "mutation-key-1"), _store, _unitOfWork); + + private RequestHandlerDelegate CreateTagHandler() => + async _ => + { + _handlerCalls++; + _dbContext.Tags.Add(Tag.Create(_userId, $"tag-{_handlerCalls}", "#ff0000").Value); + await _unitOfWork.SaveChangesAsync(); + return $"response-{_handlerCalls}"; + }; + + private RequestHandlerDelegate CountingHandler(string result) => + _ => + { + _handlerCalls++; + return Task.FromResult(result); + }; + + private sealed record FakeRequest : IRequest, IIdempotentCommand; + + private sealed record OtherRequest : IRequest, IIdempotentCommand; + + private sealed record ResultRequest : IRequest>, IIdempotentCommand; + + private sealed record PlainResultRequest : IRequest, IIdempotentCommand; + + private sealed record UnmarkedRequest : IRequest; + + private sealed class StubIdempotencyContext(bool hasKey, Guid userId, string key) : IIdempotencyContext + { + public bool TryGetRequestKey(out Guid resolvedUserId, out string idempotencyKey) + { + resolvedUserId = userId; + idempotencyKey = key; + return hasKey; + } + } + + private sealed class SqliteCompatOrbitDbContext(DbContextOptions options) + : OrbitDbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + foreach (var entityType in modelBuilder.Model.GetEntityTypes()) + { + foreach (var property in entityType.GetProperties()) + { + var defaultSql = property.GetDefaultValueSql(); + if (defaultSql is not null && defaultSql.Contains("::", StringComparison.Ordinal)) + property.SetDefaultValueSql(null); + } + + foreach (var index in entityType.GetIndexes()) + index.SetFilter(null); + } + } + } +} diff --git a/tests/Orbit.Infrastructure.Tests/Behaviors/IdempotencyBehaviorRaceTests.cs b/tests/Orbit.Infrastructure.Tests/Behaviors/IdempotencyBehaviorRaceTests.cs new file mode 100644 index 00000000..45cd62ec --- /dev/null +++ b/tests/Orbit.Infrastructure.Tests/Behaviors/IdempotencyBehaviorRaceTests.cs @@ -0,0 +1,85 @@ +using FluentAssertions; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Orbit.Application.Behaviors; +using Orbit.Application.Common; +using Orbit.Domain.Interfaces; + +namespace Orbit.Infrastructure.Tests.Behaviors; + +public class IdempotencyBehaviorRaceTests +{ + private static readonly Guid UserId = Guid.NewGuid(); + private const string Key = "mutation-key-1"; + + [Fact] + public async Task Handle_ConcurrentDuplicateLosesUniqueRace_ReplaysWinnerResponse() + { + var store = Substitute.For(); + store.FindResponseBodyAsync(UserId, Key, Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(null), Task.FromResult("\"winner-response\"")); + store.Reserve(UserId, Key, Arg.Any()).Returns(Substitute.For()); + + var unitOfWork = BuildUnitOfWorkThatThrowsUniqueViolationOnSave(); + var behavior = new IdempotencyBehavior(BuildContextWithKey(), store, unitOfWork); + + var handlerCalls = 0; + RequestHandlerDelegate next = _ => + { + handlerCalls++; + return Task.FromResult("loser-response"); + }; + + var result = await behavior.Handle(new FakeRequest(), next, CancellationToken.None); + + result.Should().Be("winner-response"); + handlerCalls.Should().Be(0); + } + + [Fact] + public async Task Handle_UniqueViolationWithNoStoredResponse_Rethrows() + { + var store = Substitute.For(); + store.FindResponseBodyAsync(UserId, Key, Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(null)); + store.Reserve(UserId, Key, Arg.Any()).Returns(Substitute.For()); + + var unitOfWork = BuildUnitOfWorkThatThrowsUniqueViolationOnSave(); + var behavior = new IdempotencyBehavior(BuildContextWithKey(), store, unitOfWork); + RequestHandlerDelegate next = _ => Task.FromResult("value"); + + var act = () => behavior.Handle(new FakeRequest(), next, CancellationToken.None); + + await act.Should().ThrowAsync(); + } + + private static IIdempotencyContext BuildContextWithKey() + { + var context = Substitute.For(); + context.TryGetRequestKey(out Arg.Any(), out Arg.Any()) + .Returns(call => + { + call[0] = UserId; + call[1] = Key; + return true; + }); + return context; + } + + private static IUnitOfWork BuildUnitOfWorkThatThrowsUniqueViolationOnSave() + { + var unitOfWork = Substitute.For(); + unitOfWork.ExecuteInTransactionAsync(Arg.Any>(), Arg.Any()) + .Returns(call => call.Arg>().Invoke(CancellationToken.None)); + unitOfWork.SaveChangesAsync(Arg.Any()) + .ThrowsAsync(new DbUpdateException( + "duplicate key value violates unique constraint", + new PostgresException("duplicate key", "ERROR", "ERROR", PostgresErrorCodes.UniqueViolation))); + return unitOfWork; + } + + private sealed record FakeRequest : IRequest, IIdempotentCommand; +} diff --git a/tests/Orbit.Infrastructure.Tests/Idempotency/HttpIdempotencyContextTests.cs b/tests/Orbit.Infrastructure.Tests/Idempotency/HttpIdempotencyContextTests.cs new file mode 100644 index 00000000..ab3a957e --- /dev/null +++ b/tests/Orbit.Infrastructure.Tests/Idempotency/HttpIdempotencyContextTests.cs @@ -0,0 +1,99 @@ +using System.Security.Claims; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using NSubstitute; +using Orbit.Api.Idempotency; + +namespace Orbit.Infrastructure.Tests.Idempotency; + +public class HttpIdempotencyContextTests +{ + private const string HeaderName = "Idempotency-Key"; + + [Fact] + public void TryGetRequestKey_WithHeaderAndAuthenticatedUser_ReturnsKeyAndUserId() + { + var userId = Guid.NewGuid(); + var sut = CreateSut(BuildContext("mutation-key-1", userId.ToString())); + + var result = sut.TryGetRequestKey(out var resolvedUserId, out var idempotencyKey); + + result.Should().BeTrue(); + resolvedUserId.Should().Be(userId); + idempotencyKey.Should().Be("mutation-key-1"); + } + + [Fact] + public void TryGetRequestKey_WhitespacePaddedHeader_IsTrimmed() + { + var userId = Guid.NewGuid(); + var sut = CreateSut(BuildContext(" mutation-key-1 ", userId.ToString())); + + var result = sut.TryGetRequestKey(out _, out var idempotencyKey); + + result.Should().BeTrue(); + idempotencyKey.Should().Be("mutation-key-1"); + } + + [Fact] + public void TryGetRequestKey_WithNoHttpContext_ReturnsFalse() + { + var sut = CreateSut(null); + + sut.TryGetRequestKey(out _, out _).Should().BeFalse(); + } + + [Fact] + public void TryGetRequestKey_WithoutHeader_ReturnsFalse() + { + var sut = CreateSut(BuildContext(null, Guid.NewGuid().ToString())); + + sut.TryGetRequestKey(out _, out _).Should().BeFalse(); + } + + [Fact] + public void TryGetRequestKey_WithOversizedHeader_ReturnsFalse() + { + var sut = CreateSut(BuildContext(new string('a', 201), Guid.NewGuid().ToString())); + + sut.TryGetRequestKey(out _, out _).Should().BeFalse(); + } + + [Fact] + public void TryGetRequestKey_WithHeaderButNoUserClaim_ReturnsFalse() + { + var sut = CreateSut(BuildContext("mutation-key-1", userIdClaim: null)); + + sut.TryGetRequestKey(out _, out _).Should().BeFalse(); + } + + [Fact] + public void TryGetRequestKey_WithNonGuidUserClaim_ReturnsFalse() + { + var sut = CreateSut(BuildContext("mutation-key-1", "not-a-guid")); + + sut.TryGetRequestKey(out _, out _).Should().BeFalse(); + } + + private static HttpIdempotencyContext CreateSut(HttpContext? httpContext) + { + var httpContextAccessor = Substitute.For(); + httpContextAccessor.HttpContext.Returns(httpContext); + return new HttpIdempotencyContext(httpContextAccessor); + } + + private static DefaultHttpContext BuildContext(string? headerValue, string? userIdClaim) + { + var context = new DefaultHttpContext(); + + if (headerValue is not null) + context.Request.Headers[HeaderName] = headerValue; + + var claims = userIdClaim is null + ? Array.Empty() + : [new Claim(ClaimTypes.NameIdentifier, userIdClaim)]; + context.User = new ClaimsPrincipal(new ClaimsIdentity(claims, "test")); + + return context; + } +} diff --git a/tests/Orbit.Infrastructure.Tests/Persistence/UnitOfWorkTests.cs b/tests/Orbit.Infrastructure.Tests/Persistence/UnitOfWorkTests.cs index a8dbaa8f..b627ff01 100644 --- a/tests/Orbit.Infrastructure.Tests/Persistence/UnitOfWorkTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Persistence/UnitOfWorkTests.cs @@ -43,4 +43,31 @@ public async Task ExecuteInTransactionAsync_WhenOperationThrows_ClearsTrackedEnt (await execute.Should().ThrowAsync()).Which.Should().BeSameAs(conflict); context.ChangeTracker.Entries().Should().BeEmpty(); } + + [Fact] + public async Task ExecuteInTransactionAsync_WhenAmbientTransactionActive_RunsInlineWithoutNesting() + { + using var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + using var context = new OrbitDbContext(options); + var unitOfWork = new UnitOfWork(context); + + await using var ambientTransaction = await context.Database.BeginTransactionAsync(); + + var operationRan = false; + var act = () => unitOfWork.ExecuteInTransactionAsync(_ => + { + operationRan = true; + return Task.CompletedTask; + }); + + await act.Should().NotThrowAsync(); + operationRan.Should().BeTrue(); + context.Database.CurrentTransaction.Should().BeSameAs(ambientTransaction); + } } diff --git a/tests/Orbit.Infrastructure.Tests/Services/AccountDeletionServiceDbTests.cs b/tests/Orbit.Infrastructure.Tests/Services/AccountDeletionServiceDbTests.cs index 8d2489cf..e3410103 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/AccountDeletionServiceDbTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/AccountDeletionServiceDbTests.cs @@ -31,6 +31,7 @@ public AccountDeletionServiceDbTests() var serviceProvider = new ServiceCollection() .AddSingleton(_dbContext) + .AddSingleton(new UnitOfWork(_dbContext)) .AddSingleton(new AccountResetRepository(_dbContext)) .BuildServiceProvider(); @@ -80,6 +81,45 @@ public async Task DeleteAllUserDataAsync_RemovesSentProactiveCheckins() .Should().BeFalse(); } + [Fact] + public async Task RunAsync_DeletesPastDueUserWithOwnedDataAndIdempotencyLedger() + { + var userId = Guid.NewGuid(); + SeedDeactivatedUser(userId, "owned@example.com", DateTime.UtcNow.AddDays(-1)); + _dbContext.Tags.Add(Tag.Create(userId, "Fitness", "#ff0000").Value); + _dbContext.ProcessedRequests.Add(ProcessedRequest.Create(userId, "mutation-key-1", "LogHabitCommand")); + _dbContext.SentProactiveCheckins.Add( + SentProactiveCheckin.Create(userId, DateOnly.FromDateTime(DateTime.UtcNow))); + await _dbContext.SaveChangesAsync(); + _dbContext.ChangeTracker.Clear(); + + await _service.RunAsync(CancellationToken.None); + + (await _dbContext.Users.AnyAsync(u => u.Id == userId)).Should().BeFalse(); + (await _dbContext.Tags.IgnoreQueryFilters().AnyAsync(t => t.UserId == userId)).Should().BeFalse(); + (await _dbContext.ProcessedRequests.AnyAsync(r => r.UserId == userId)).Should().BeFalse(); + (await _dbContext.SentProactiveCheckins.AnyAsync(p => p.UserId == userId)).Should().BeFalse(); + } + + [Fact] + public async Task CleanupStaleSentRecords_RemovesProcessedRequestsOlderThan30Days_KeepsRecent() + { + var userId = Guid.NewGuid(); + SeedUser(userId, "retention@example.com"); + await _dbContext.SaveChangesAsync(); + + var stale = ProcessedRequest.Create(userId, "stale-key", "LogHabitCommand"); + typeof(ProcessedRequest).GetProperty("CreatedAtUtc")!.SetValue(stale, DateTime.UtcNow.AddDays(-31)); + var recent = ProcessedRequest.Create(userId, "recent-key", "LogHabitCommand"); + _dbContext.ProcessedRequests.AddRange(stale, recent); + await _dbContext.SaveChangesAsync(); + + await _service.CleanupStaleSentRecords(CancellationToken.None); + + var remaining = await _dbContext.ProcessedRequests.Select(r => r.IdempotencyKey).ToListAsync(); + remaining.Should().ContainSingle().Which.Should().Be("recent-key"); + } + [Fact] public async Task RunAsync_DeletesOnlyPastDueDeactivatedUsers_PerUserScope() {