Adding Flags API

This commit is contained in:
2026-04-07 15:30:40 -07:00
parent 7b15086fe5
commit 0ba076b650
78 changed files with 3862 additions and 81 deletions

View File

@@ -0,0 +1,19 @@
using MicCheck.Api.ApiKeys;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace MicCheck.Api.Data.Configurations;
public class ApiKeyConfiguration : IEntityTypeConfiguration<ApiKey>
{
public void Configure(EntityTypeBuilder<ApiKey> builder)
{
builder.HasKey(k => k.Id);
builder.Property(k => k.Key).HasMaxLength(512).IsRequired();
builder.Property(k => k.Prefix).HasMaxLength(8).IsRequired();
builder.Property(k => k.Name).HasMaxLength(200).IsRequired();
builder.Property(k => k.CreatedAt).IsRequired();
builder.HasIndex(k => k.Key).IsUnique();
}
}

View File

@@ -0,0 +1,19 @@
using MicCheck.Api.Audit;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace MicCheck.Api.Data.Configurations;
public class AuditLogConfiguration : IEntityTypeConfiguration<AuditLog>
{
public void Configure(EntityTypeBuilder<AuditLog> builder)
{
builder.HasKey(a => a.Id);
builder.Property(a => a.ResourceType).HasMaxLength(100).IsRequired();
builder.Property(a => a.ResourceId).HasMaxLength(100).IsRequired();
builder.Property(a => a.Action).HasMaxLength(50).IsRequired();
builder.Property(a => a.CreatedAt).IsRequired();
builder.HasIndex(a => new { a.OrganizationId, a.CreatedAt });
}
}

View File

@@ -0,0 +1,18 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using AppEnvironment = MicCheck.Api.Environments.Environment;
namespace MicCheck.Api.Data.Configurations;
public class EnvironmentConfiguration : IEntityTypeConfiguration<AppEnvironment>
{
public void Configure(EntityTypeBuilder<AppEnvironment> builder)
{
builder.HasKey(e => e.Id);
builder.Property(e => e.Name).HasMaxLength(200).IsRequired();
builder.Property(e => e.ApiKey).HasMaxLength(100).IsRequired();
builder.Property(e => e.CreatedAt).IsRequired();
builder.HasIndex(e => e.ApiKey).IsUnique();
}
}

View File

@@ -0,0 +1,29 @@
using MicCheck.Api.Features;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace MicCheck.Api.Data.Configurations;
public class FeatureConfiguration : IEntityTypeConfiguration<Feature>
{
public void Configure(EntityTypeBuilder<Feature> builder)
{
builder.HasKey(f => f.Id);
builder.Property(f => f.Name).HasMaxLength(150).IsRequired();
builder.Property(f => f.InitialValue).HasMaxLength(20_000);
builder.Property(f => f.Description).HasMaxLength(2_000);
builder.Property(f => f.CreatedAt).IsRequired();
builder.HasIndex(f => new { f.ProjectId, f.Name }).IsUnique();
builder.HasMany(f => f.FeatureStates)
.WithOne()
.HasForeignKey(fs => fs.FeatureId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(f => f.Tags)
.WithOne()
.HasForeignKey(t => t.ProjectId)
.OnDelete(DeleteBehavior.NoAction);
}
}

View File

@@ -0,0 +1,18 @@
using MicCheck.Api.Features;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace MicCheck.Api.Data.Configurations;
public class FeatureSegmentConfiguration : IEntityTypeConfiguration<FeatureSegment>
{
public void Configure(EntityTypeBuilder<FeatureSegment> builder)
{
builder.HasKey(fs => fs.Id);
builder.HasOne(fsg => fsg.FeatureState)
.WithOne()
.HasForeignKey<MicCheck.Api.Features.FeatureState>(fs => fs.FeatureSegmentId)
.OnDelete(DeleteBehavior.SetNull);
}
}

View File

@@ -0,0 +1,21 @@
using MicCheck.Api.Features;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace MicCheck.Api.Data.Configurations;
public class FeatureStateConfiguration : IEntityTypeConfiguration<FeatureState>
{
public void Configure(EntityTypeBuilder<FeatureState> builder)
{
builder.HasKey(fs => fs.Id);
builder.Property(fs => fs.Value).HasMaxLength(20_000);
builder.Property(fs => fs.CreatedAt).IsRequired();
builder.Property(fs => fs.UpdatedAt).IsRequired();
builder.Property(fs => fs.Version).IsConcurrencyToken();
builder.HasIndex(fs => new { fs.FeatureId, fs.EnvironmentId, fs.IdentityId }).IsUnique();
builder.HasIndex(fs => fs.EnvironmentId);
builder.HasIndex(fs => fs.IdentityId);
}
}

View File

@@ -0,0 +1,27 @@
using MicCheck.Api.Identities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace MicCheck.Api.Data.Configurations;
public class IdentityConfiguration : IEntityTypeConfiguration<Identity>
{
public void Configure(EntityTypeBuilder<Identity> builder)
{
builder.HasKey(i => i.Id);
builder.Property(i => i.Identifier).HasMaxLength(2_000).IsRequired();
builder.Property(i => i.CreatedAt).IsRequired();
builder.HasIndex(i => new { i.EnvironmentId, i.Identifier }).IsUnique();
builder.HasMany(i => i.Traits)
.WithOne()
.HasForeignKey(t => t.IdentityId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(i => i.FeatureStateOverrides)
.WithOne()
.HasForeignKey(fs => fs.IdentityId)
.OnDelete(DeleteBehavior.Cascade);
}
}

View File

@@ -0,0 +1,17 @@
using MicCheck.Api.Identities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace MicCheck.Api.Data.Configurations;
public class IdentityTraitConfiguration : IEntityTypeConfiguration<IdentityTrait>
{
public void Configure(EntityTypeBuilder<IdentityTrait> builder)
{
builder.HasKey(t => t.Id);
builder.Property(t => t.Key).HasMaxLength(200).IsRequired();
builder.Property(t => t.Value).HasMaxLength(2_000).IsRequired();
builder.HasIndex(t => new { t.IdentityId, t.Key }).IsUnique();
}
}

View File

@@ -0,0 +1,30 @@
using MicCheck.Api.Organizations;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace MicCheck.Api.Data.Configurations;
public class OrganizationConfiguration : IEntityTypeConfiguration<Organization>
{
public void Configure(EntityTypeBuilder<Organization> builder)
{
builder.HasKey(o => o.Id);
builder.Property(o => o.Name).HasMaxLength(200).IsRequired();
builder.Property(o => o.CreatedAt).IsRequired();
builder.HasMany(o => o.Members)
.WithOne()
.HasForeignKey(ou => ou.OrganizationId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(o => o.ApiKeys)
.WithOne()
.HasForeignKey(k => k.OrganizationId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(o => o.Projects)
.WithOne()
.HasForeignKey(p => p.OrganizationId)
.OnDelete(DeleteBehavior.Cascade);
}
}

View File

@@ -0,0 +1,14 @@
using MicCheck.Api.Organizations;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace MicCheck.Api.Data.Configurations;
public class OrganizationUserConfiguration : IEntityTypeConfiguration<OrganizationUser>
{
public void Configure(EntityTypeBuilder<OrganizationUser> builder)
{
builder.HasKey(ou => new { ou.OrganizationId, ou.UserId });
builder.Property(ou => ou.Role).IsRequired();
}
}

View File

@@ -0,0 +1,15 @@
using MicCheck.Api.Projects;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace MicCheck.Api.Data.Configurations;
public class ProjectConfiguration : IEntityTypeConfiguration<Project>
{
public void Configure(EntityTypeBuilder<Project> builder)
{
builder.HasKey(p => p.Id);
builder.Property(p => p.Name).HasMaxLength(200).IsRequired();
builder.Property(p => p.CreatedAt).IsRequired();
}
}

View File

@@ -0,0 +1,24 @@
using MicCheck.Api.Users;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace MicCheck.Api.Data.Configurations;
public class RefreshTokenConfiguration : IEntityTypeConfiguration<RefreshToken>
{
public void Configure(EntityTypeBuilder<RefreshToken> builder)
{
builder.HasKey(rt => rt.Id);
builder.Property(rt => rt.Token).HasMaxLength(512).IsRequired();
builder.Property(rt => rt.ExpiresAt).IsRequired();
builder.Property(rt => rt.CreatedAt).IsRequired();
builder.HasIndex(rt => rt.Token).IsUnique();
builder.HasIndex(rt => rt.UserId);
builder.HasOne(rt => rt.User)
.WithMany()
.HasForeignKey(rt => rt.UserId)
.OnDelete(DeleteBehavior.Cascade);
}
}

View File

@@ -0,0 +1,15 @@
using MicCheck.Api.Segments;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace MicCheck.Api.Data.Configurations;
public class SegmentConditionConfiguration : IEntityTypeConfiguration<SegmentCondition>
{
public void Configure(EntityTypeBuilder<SegmentCondition> builder)
{
builder.HasKey(c => c.Id);
builder.Property(c => c.Property).HasMaxLength(200).IsRequired();
builder.Property(c => c.Value).HasMaxLength(1_000).IsRequired();
}
}

View File

@@ -0,0 +1,20 @@
using MicCheck.Api.Segments;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace MicCheck.Api.Data.Configurations;
public class SegmentConfiguration : IEntityTypeConfiguration<Segment>
{
public void Configure(EntityTypeBuilder<Segment> builder)
{
builder.HasKey(s => s.Id);
builder.Property(s => s.Name).HasMaxLength(200).IsRequired();
builder.Property(s => s.CreatedAt).IsRequired();
builder.HasMany(s => s.Rules)
.WithOne()
.HasForeignKey(r => r.SegmentId)
.OnDelete(DeleteBehavior.Cascade);
}
}

View File

@@ -0,0 +1,23 @@
using MicCheck.Api.Segments;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace MicCheck.Api.Data.Configurations;
public class SegmentRuleConfiguration : IEntityTypeConfiguration<SegmentRule>
{
public void Configure(EntityTypeBuilder<SegmentRule> builder)
{
builder.HasKey(r => r.Id);
builder.HasMany(r => r.Conditions)
.WithOne()
.HasForeignKey(c => c.RuleId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(r => r.ChildRules)
.WithOne()
.HasForeignKey(r => r.ParentRuleId)
.OnDelete(DeleteBehavior.NoAction);
}
}

View File

@@ -0,0 +1,15 @@
using MicCheck.Api.Features;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace MicCheck.Api.Data.Configurations;
public class TagConfiguration : IEntityTypeConfiguration<Tag>
{
public void Configure(EntityTypeBuilder<Tag> builder)
{
builder.HasKey(t => t.Id);
builder.Property(t => t.Label).HasMaxLength(200).IsRequired();
builder.Property(t => t.Color).HasMaxLength(20).IsRequired();
}
}

View File

@@ -0,0 +1,25 @@
using MicCheck.Api.Users;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace MicCheck.Api.Data.Configurations;
public class UserConfiguration : IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> builder)
{
builder.HasKey(u => u.Id);
builder.Property(u => u.Email).HasMaxLength(500).IsRequired();
builder.Property(u => u.PasswordHash).IsRequired();
builder.Property(u => u.FirstName).HasMaxLength(200).IsRequired();
builder.Property(u => u.LastName).HasMaxLength(200).IsRequired();
builder.Property(u => u.CreatedAt).IsRequired();
builder.HasIndex(u => u.Email).IsUnique();
builder.HasMany(u => u.Organizations)
.WithOne()
.HasForeignKey(ou => ou.UserId)
.OnDelete(DeleteBehavior.Cascade);
}
}

View File

@@ -0,0 +1,22 @@
using MicCheck.Api.Authorization;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace MicCheck.Api.Data.Configurations;
public class UserProjectPermissionConfiguration : IEntityTypeConfiguration<UserProjectPermission>
{
public void Configure(EntityTypeBuilder<UserProjectPermission> builder)
{
builder.HasKey(p => new { p.UserId, p.ProjectId });
builder.Property(p => p.Permissions)
.HasConversion(
v => string.Join(',', v.Select(p => p.ToString())),
v => string.IsNullOrEmpty(v)
? new List<ProjectPermission>()
: v.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(Enum.Parse<ProjectPermission>)
.ToList());
}
}

View File

@@ -0,0 +1,18 @@
using MicCheck.Api.Webhooks;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace MicCheck.Api.Data.Configurations;
public class WebhookConfiguration : IEntityTypeConfiguration<Webhook>
{
public void Configure(EntityTypeBuilder<Webhook> builder)
{
builder.HasKey(w => w.Id);
builder.Property(w => w.Url).HasMaxLength(500).IsRequired();
builder.Property(w => w.Secret).HasMaxLength(200);
builder.Property(w => w.CreatedAt).IsRequired();
builder.HasIndex(w => w.EnvironmentId);
}
}

View File

@@ -0,0 +1,17 @@
using MicCheck.Api.Webhooks;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace MicCheck.Api.Data.Configurations;
public class WebhookDeliveryLogConfiguration : IEntityTypeConfiguration<WebhookDeliveryLog>
{
public void Configure(EntityTypeBuilder<WebhookDeliveryLog> builder)
{
builder.HasKey(d => d.Id);
builder.Property(d => d.EventType).HasMaxLength(100).IsRequired();
builder.Property(d => d.AttemptedAt).IsRequired();
builder.HasIndex(d => d.WebhookId);
}
}

View File

@@ -0,0 +1,53 @@
using MicCheck.Api.Organizations;
using MicCheck.Api.Projects;
using Microsoft.EntityFrameworkCore;
using AppEnvironment = MicCheck.Api.Environments.Environment;
namespace MicCheck.Api.Data;
public class DatabaseSeeder
{
private readonly MicCheckDbContext _db;
public DatabaseSeeder(MicCheckDbContext db)
{
_db = db;
}
public async Task SeedAsync(CancellationToken ct = default)
{
if (await _db.Organizations.AnyAsync(ct))
return;
var organization = new Organization
{
Name = "Default",
CreatedAt = DateTimeOffset.UtcNow
};
_db.Organizations.Add(organization);
await _db.SaveChangesAsync(ct);
var project = new Project
{
Name = "My Project",
OrganizationId = organization.Id,
CreatedAt = DateTimeOffset.UtcNow
};
_db.Projects.Add(project);
await _db.SaveChangesAsync(ct);
var environmentNames = new[] { "Development", "Staging", "Production" };
foreach (var name in environmentNames)
{
_db.Environments.Add(new AppEnvironment
{
Name = name,
ApiKey = $"env-{Guid.NewGuid():N}",
ProjectId = project.Id,
CreatedAt = DateTimeOffset.UtcNow
});
}
await _db.SaveChangesAsync(ct);
}
}

View File

@@ -0,0 +1,21 @@
namespace MicCheck.Api.Data;
public static class DatabaseUrlParser
{
/// <summary>
/// Converts a DATABASE_URL in postgresql://user:password@host:port/dbname format
/// to an Npgsql connection string.
/// </summary>
public static string ToNpgsqlConnectionString(string databaseUrl)
{
var uri = new Uri(databaseUrl);
var userInfo = uri.UserInfo.Split(':');
var host = uri.Host;
var port = uri.Port > 0 ? uri.Port : 5432;
var database = uri.AbsolutePath.TrimStart('/');
var username = userInfo.Length > 0 ? userInfo[0] : string.Empty;
var password = userInfo.Length > 1 ? userInfo[1] : string.Empty;
return $"Host={host};Port={port};Database={database};Username={username};Password={password}";
}
}

View File

@@ -0,0 +1,43 @@
using MicCheck.Api.ApiKeys;
using MicCheck.Api.Audit;
using MicCheck.Api.Authorization;
using MicCheck.Api.Features;
using MicCheck.Api.Identities;
using MicCheck.Api.Organizations;
using MicCheck.Api.Projects;
using MicCheck.Api.Segments;
using MicCheck.Api.Users;
using MicCheck.Api.Webhooks;
using Microsoft.EntityFrameworkCore;
using AppEnvironment = MicCheck.Api.Environments.Environment;
namespace MicCheck.Api.Data;
public class MicCheckDbContext : DbContext
{
public MicCheckDbContext(DbContextOptions<MicCheckDbContext> options) : base(options) { }
public DbSet<Organization> Organizations => Set<Organization>();
public DbSet<OrganizationUser> OrganizationUsers => Set<OrganizationUser>();
public DbSet<Project> Projects => Set<Project>();
public DbSet<AppEnvironment> Environments => Set<AppEnvironment>();
public DbSet<Feature> Features => Set<Feature>();
public DbSet<FeatureState> FeatureStates => Set<FeatureState>();
public DbSet<FeatureSegment> FeatureSegments => Set<FeatureSegment>();
public DbSet<Tag> Tags => Set<Tag>();
public DbSet<Segment> Segments => Set<Segment>();
public DbSet<SegmentRule> SegmentRules => Set<SegmentRule>();
public DbSet<SegmentCondition> SegmentConditions => Set<SegmentCondition>();
public DbSet<Identity> Identities => Set<Identity>();
public DbSet<IdentityTrait> IdentityTraits => Set<IdentityTrait>();
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
public DbSet<Webhook> Webhooks => Set<Webhook>();
public DbSet<WebhookDeliveryLog> WebhookDeliveryLogs => Set<WebhookDeliveryLog>();
public DbSet<ApiKey> ApiKeys => Set<ApiKey>();
public DbSet<User> Users => Set<User>();
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
public DbSet<UserProjectPermission> UserProjectPermissions => Set<UserProjectPermission>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
=> modelBuilder.ApplyConfigurationsFromAssembly(typeof(MicCheckDbContext).Assembly);
}