using System.Linq.Expressions; using System.Reflection; using DysonNetwork.Common.Models; using DysonNetwork.Pass.Features.Auth.Models; using DysonNetwork.Sphere.Permission; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; using NodaTime; using Quartz; using Account = DysonNetwork.Pass.Features.Auth.Models.Account; using AccountConnection = DysonNetwork.Pass.Features.Auth.Models.AccountConnection; using AccountAuthFactor = DysonNetwork.Pass.Features.Auth.Models.AccountAuthFactor; using AuthSession = DysonNetwork.Pass.Features.Auth.Models.AuthSession; using AuthChallenge = DysonNetwork.Pass.Features.Auth.Models.AuthChallenge; namespace DysonNetwork.Pass.Data; public class PassDatabase( DbContextOptions options, IConfiguration configuration ) : DbContext(options) { public DbSet PermissionNodes { get; set; } public DbSet PermissionGroups { get; set; } public DbSet PermissionGroupMembers { get; set; } public DbSet MagicSpells { get; set; } public DbSet Accounts { get; set; } = null!; public DbSet AccountConnections { get; set; } = null!; public DbSet AccountAuthFactors { get; set; } = null!; public DbSet AccountRelationships { get; set; } public DbSet Notifications { get; set; } public DbSet Badges { get; set; } public DbSet ActionLogs { get; set; } public DbSet AbuseReports { get; set; } public DbSet AuthSessions { get; set; } public DbSet AuthChallenges { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { optionsBuilder.UseNpgsql( configuration.GetConnectionString("App"), opt => opt .ConfigureDataSource(optSource => optSource.EnableDynamicJson()) .UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery) .UseNetTopologySuite() .UseNodaTime() ).UseSnakeCaseNamingConvention(); optionsBuilder.UseAsyncSeeding(async (context, _, cancellationToken) => { // Add any initial seeding logic here if needed for PassDatabase }); optionsBuilder.UseSeeding((context, _) => {}); base.OnConfiguring(optionsBuilder); } protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.Entity() .HasKey(pg => new { pg.GroupId, pg.Actor }); modelBuilder.Entity() .HasOne(pg => pg.Group) .WithMany(g => g.Members) .HasForeignKey(pg => pg.GroupId) .OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity() .HasKey(r => new { FromAccountId = r.AccountId, ToAccountId = r.RelatedId }); modelBuilder.Entity() .HasOne(r => r.Account) .WithMany(a => a.OutgoingRelationships) .HasForeignKey(r => r.AccountId); modelBuilder.Entity() .HasOne(r => r.Related) .WithMany(a => a.IncomingRelationships) .HasForeignKey(r => r.RelatedId); // Configure AuthSession modelBuilder.Entity(entity => { entity.HasKey(e => e.Id); entity.Property(e => e.Id) .ValueGeneratedOnAdd() .HasDefaultValueSql("gen_random_uuid()"); entity.Property(e => e.Label) .HasMaxLength(500); entity.Property(e => e.LastGrantedAt) .IsRequired(); entity.Property(e => e.ExpiredAt) .IsRequired(); entity.Property(e => e.AccessToken) .HasMaxLength(1000); entity.Property(e => e.RefreshToken) .HasMaxLength(1000); entity.Property(e => e.IpAddress) .HasMaxLength(128); entity.Property(e => e.UserAgent) .HasMaxLength(500); entity.HasOne(s => s.Account) .WithMany(a => a.Sessions) .HasForeignKey(s => s.AccountId) .OnDelete(DeleteBehavior.Cascade); entity.HasOne(s => s.Challenge) .WithMany() .HasForeignKey(s => s.ChallengeId) .OnDelete(DeleteBehavior.SetNull); entity.Property(e => e.Metadata) .HasColumnType("jsonb"); }); // Configure AuthChallenge modelBuilder.Entity(entity => { entity.HasKey(e => e.Id); entity.Property(e => e.Type) .IsRequired() .HasConversion(); entity.Property(e => e.Platform) .IsRequired() .HasConversion(); entity.Property(e => e.CreatedAt) .IsRequired(); entity.Property(e => e.ExpiredAt); entity.Property(e => e.StepRemain) .IsRequired() .HasDefaultValue(1); entity.Property(e => e.StepTotal) .IsRequired() .HasDefaultValue(1); entity.Property(e => e.FailedAttempts) .IsRequired() .HasDefaultValue(0); entity.Property(e => e.IpAddress) .HasMaxLength(128); entity.Property(e => e.UserAgent) .HasMaxLength(512); entity.Property(e => e.DeviceId) .HasMaxLength(256); entity.Property(e => e.Nonce) .HasMaxLength(1024); entity.Property(e => e.BlacklistFactors) .HasColumnType("jsonb"); entity.Property(e => e.Audiences) .HasColumnType("jsonb"); entity.Property(e => e.Scopes) .HasColumnType("jsonb"); entity.HasOne() .WithMany(a => a.Challenges) .HasForeignKey(e => e.AccountId) .OnDelete(DeleteBehavior.Cascade); entity.Ignore(e => e.Location); // Ignore Point type as it's not directly supported by EF Core }); // Configure AccountAuthFactor modelBuilder.Entity(entity => { entity.HasKey(e => e.Id); entity.Property(e => e.Id) .ValueGeneratedOnAdd() .HasDefaultValueSql("gen_random_uuid()"); entity.Property(e => e.FactorType) .IsRequired() .HasConversion(); entity.Property(e => e.Name) .IsRequired() .HasMaxLength(100); entity.Property(e => e.Description) .HasMaxLength(500); entity.Property(e => e.Secret) .IsRequired() .HasMaxLength(1024); entity.Property(e => e.IsDefault) .IsRequired() .HasDefaultValue(false); entity.Property(e => e.IsBackup) .IsRequired() .HasDefaultValue(false); entity.Property(e => e.LastUsedAt); entity.Property(e => e.EnabledAt); entity.Property(e => e.DisabledAt); entity.Property(e => e.Metadata) .HasColumnType("jsonb"); entity.HasOne(f => f.Account) .WithMany(a => a.AuthFactors) .HasForeignKey(f => f.AccountId) .OnDelete(DeleteBehavior.Cascade); // Remove the incorrect relationship configuration // The relationship is already defined in the AuthSession configuration }); // Configure Account modelBuilder.Entity(entity => { entity.HasKey(e => e.Id); entity.Property(e => e.Email) .IsRequired() .HasMaxLength(256); entity.Property(e => e.Name) .IsRequired() .HasMaxLength(256); entity.Property(e => e.Status) .HasMaxLength(32); entity.Property(e => e.CreatedAt) .IsRequired(); entity.Property(e => e.UpdatedAt) .IsRequired(); }); // Configure AccountConnection modelBuilder.Entity(entity => { entity.HasKey(e => e.Id); entity.Property(e => e.Provider) .IsRequired() .HasMaxLength(50); entity.Property(e => e.ProviderId) .IsRequired() .HasMaxLength(256); entity.Property(e => e.DisplayName) .HasMaxLength(256); entity.Property(e => e.AccessToken) .HasMaxLength(1000); entity.Property(e => e.RefreshToken) .HasMaxLength(1000); entity.Property(e => e.ExpiresAt); entity.Property(e => e.ProfileData) .HasColumnType("jsonb"); entity.HasOne() .WithMany(a => a.Connections) .HasForeignKey(e => e.AccountId) .OnDelete(DeleteBehavior.Cascade); }); // Automatically apply soft-delete filter to all entities inheriting BaseModel foreach (var entityType in modelBuilder.Model.GetEntityTypes()) { if (!typeof(ModelBase).IsAssignableFrom(entityType.ClrType)) continue; var method = typeof(PassDatabase) .GetMethod(nameof(SetSoftDeleteFilter), BindingFlags.NonPublic | BindingFlags.Static)! .MakeGenericMethod(entityType.ClrType); method.Invoke(null, [modelBuilder]); } } private static void SetSoftDeleteFilter(ModelBuilder modelBuilder) where TEntity : ModelBase { modelBuilder.Entity().HasQueryFilter(e => e.DeletedAt == null); } public override async Task SaveChangesAsync(CancellationToken cancellationToken = default) { var now = SystemClock.Instance.GetCurrentInstant(); foreach (var entry in ChangeTracker.Entries()) { switch (entry.State) { case EntityState.Added: entry.Entity.CreatedAt = now; entry.Entity.UpdatedAt = now; break; case EntityState.Modified: entry.Entity.UpdatedAt = now; break; case EntityState.Deleted: entry.State = EntityState.Modified; entry.Entity.DeletedAt = now; break; case EntityState.Detached: case EntityState.Unchanged: default: break; } } return await base.SaveChangesAsync(cancellationToken); } } public class PassDatabaseFactory : IDesignTimeDbContextFactory { public PassDatabase CreateDbContext(string[] args) { var configuration = new ConfigurationBuilder() .SetBasePath(Directory.GetCurrentDirectory()) .AddJsonFile("appsettings.json") .Build(); var optionsBuilder = new DbContextOptionsBuilder(); return new PassDatabase(optionsBuilder.Options, configuration); } } public class PassDatabaseRecyclingJob(PassDatabase db, ILogger logger) : IJob { public async Task Execute(IJobExecutionContext context) { var now = SystemClock.Instance.GetCurrentInstant(); logger.LogInformation("Cleaning up expired records..."); // Expired relationships var affectedRows = await db.AccountRelationships .Where(x => x.ExpiredAt != null && x.ExpiredAt <= now) .ExecuteDeleteAsync(); logger.LogDebug("Removed {Count} records of expired relationships.", affectedRows); logger.LogInformation("Deleting soft-deleted records..."); var threshold = now - Duration.FromDays(7); var entityTypes = db.Model.GetEntityTypes() .Where(t => typeof(ModelBase).IsAssignableFrom(t.ClrType) && t.ClrType != typeof(ModelBase)) .Select(t => t.ClrType); foreach (var entityType in entityTypes) { var set = (IQueryable)db.GetType().GetMethod(nameof(DbContext.Set), Type.EmptyTypes)! .MakeGenericMethod(entityType).Invoke(db, null)!; var parameter = Expression.Parameter(entityType, "e"); var property = Expression.Property(parameter, nameof(ModelBase.DeletedAt)); var condition = Expression.LessThan(property, Expression.Constant(threshold, typeof(Instant?))); var notNull = Expression.NotEqual(property, Expression.Constant(null, typeof(Instant?))); var finalCondition = Expression.AndAlso(notNull, condition); var lambda = Expression.Lambda(finalCondition, parameter); var queryable = set.Provider.CreateQuery( Expression.Call( typeof(Queryable), "Where", [entityType], set.Expression, Expression.Quote(lambda) ) ); var toListAsync = typeof(EntityFrameworkQueryableExtensions) .GetMethod(nameof(EntityFrameworkQueryableExtensions.ToListAsync))! .MakeGenericMethod(entityType); var items = await (dynamic)toListAsync.Invoke(null, [queryable, CancellationToken.None])!; db.RemoveRange(items); } await db.SaveChangesAsync(); } }