From 2c17dc0662e2f6c980280cb165550306fca5140a Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 12 Feb 2026 16:19:51 -0600 Subject: [PATCH] feat(infra): update db context with modeling for institution and relationships --- .../Accounts/Plaid/PlaidMetadata.cs | 27 +++ src/FiscalOS.Infra/Data/AppDbContext.cs | 41 +++- ...2215015_InstitutionAndMetadata.Designer.cs | 215 ++++++++++++++++++ .../20260212215015_InstitutionAndMetadata.cs | 81 +++++++ .../Migrations/AppDbContextModelSnapshot.cs | 103 +++++++++ src/FiscalOS.Infra/Usings.cs | 1 + 6 files changed, 464 insertions(+), 4 deletions(-) create mode 100644 src/FiscalOS.Infra/Accounts/Plaid/PlaidMetadata.cs create mode 100644 src/FiscalOS.Infra/Migrations/20260212215015_InstitutionAndMetadata.Designer.cs create mode 100644 src/FiscalOS.Infra/Migrations/20260212215015_InstitutionAndMetadata.cs diff --git a/src/FiscalOS.Infra/Accounts/Plaid/PlaidMetadata.cs b/src/FiscalOS.Infra/Accounts/Plaid/PlaidMetadata.cs new file mode 100644 index 0000000..002cc66 --- /dev/null +++ b/src/FiscalOS.Infra/Accounts/Plaid/PlaidMetadata.cs @@ -0,0 +1,27 @@ +namespace FiscalOS.Infra.Accounts.Plaid; + +public sealed class PlaidMetadata : InstitutionMetadata +{ + public const string TypeValue = "Plaid"; + public string PlaidId { get; init; } = string.Empty; + public string PlaidName { get; init; } = string.Empty; + public string EncryptedAccessToken { get; init; } = string.Empty; + + private PlaidMetadata() : base(TypeValue) + { + } + + public static PlaidMetadata From( + string plaidId, + string plaidName, + string encryptedAccessToken + ) + { + return new PlaidMetadata + { + PlaidId = plaidId, + PlaidName = plaidName, + EncryptedAccessToken = encryptedAccessToken + }; + } +} \ No newline at end of file diff --git a/src/FiscalOS.Infra/Data/AppDbContext.cs b/src/FiscalOS.Infra/Data/AppDbContext.cs index fc2abe3..8fe388a 100644 --- a/src/FiscalOS.Infra/Data/AppDbContext.cs +++ b/src/FiscalOS.Infra/Data/AppDbContext.cs @@ -49,15 +49,20 @@ public sealed class AppDbContext( modelBuilder.Entity(static eb => { + eb.Property(static u => u.Username); + eb.HasIndex(static u => u.Username).IsUnique(); + + eb.Property(static u => u.HashedPassword); + eb.HasMany(static u => u.RefreshTokens) .WithOne(static t => t.User) .HasForeignKey(static t => t.UserId) .OnDelete(DeleteBehavior.Cascade); - eb.Property(static u => u.Username); - eb.HasIndex(static u => u.Username).IsUnique(); - - eb.Property(static u => u.HashedPassword); + eb.HasMany(static u => u.Institutions) + .WithOne() + .HasForeignKey(static i => i.UserId) + .OnDelete(DeleteBehavior.Cascade); }); modelBuilder.Entity(static eb => @@ -69,5 +74,33 @@ public sealed class AppDbContext( eb.Property(static t => t.Token); eb.HasIndex(static t => t.Token).IsUnique(); }); + + modelBuilder.Entity(static eb => + { + eb.Property(static i => i.Name); + + eb.HasOne(static i => i.Metadata) + .WithOne() + .HasForeignKey(static m => m.InstitutionId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(static eb => + { + eb.HasDiscriminator(static m => m.Type) + .HasValue(Accounts.Plaid.PlaidMetadata.TypeValue); + + eb.Property(static m => m.InstitutionId); + eb.Property(static m => m.Type); + }); + + modelBuilder.Entity(static eb => + { + eb.HasBaseType(); + + eb.Property(static m => m.PlaidId); + eb.Property(static m => m.PlaidName); + eb.Property(static m => m.EncryptedAccessToken); + }); } } \ No newline at end of file diff --git a/src/FiscalOS.Infra/Migrations/20260212215015_InstitutionAndMetadata.Designer.cs b/src/FiscalOS.Infra/Migrations/20260212215015_InstitutionAndMetadata.Designer.cs new file mode 100644 index 0000000..8ad1e6c --- /dev/null +++ b/src/FiscalOS.Infra/Migrations/20260212215015_InstitutionAndMetadata.Designer.cs @@ -0,0 +1,215 @@ +// +using System; +using FiscalOS.Infra.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FiscalOS.Infra.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260212215015_InstitutionAndMetadata")] + partial class InstitutionAndMetadata + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.2"); + + modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Institution"); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("InstitutionId") + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(21) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("InstitutionId") + .IsUnique(); + + b.ToTable("InstitutionMetadata"); + + b.HasDiscriminator("Type").HasValue("InstitutionMetadata"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("Revoked") + .HasColumnType("INTEGER"); + + b.Property("Token") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("FiscalOS.Core.Identity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("EncryptionKeyId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("HashedPassword") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidMetadata", b => + { + b.HasBaseType("FiscalOS.Core.Accounts.InstitutionMetadata"); + + b.Property("EncryptedAccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PlaidId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PlaidName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasDiscriminator().HasValue("Plaid"); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b => + { + b.HasOne("FiscalOS.Core.Identity.User", null) + .WithMany("Institutions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b => + { + b.HasOne("FiscalOS.Core.Accounts.Institution", null) + .WithOne("Metadata") + .HasForeignKey("FiscalOS.Core.Accounts.InstitutionMetadata", "InstitutionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b => + { + b.HasOne("FiscalOS.Core.Identity.User", "User") + .WithMany("RefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b => + { + b.Navigation("Metadata") + .IsRequired(); + }); + + modelBuilder.Entity("FiscalOS.Core.Identity.User", b => + { + b.Navigation("Institutions"); + + b.Navigation("RefreshTokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FiscalOS.Infra/Migrations/20260212215015_InstitutionAndMetadata.cs b/src/FiscalOS.Infra/Migrations/20260212215015_InstitutionAndMetadata.cs new file mode 100644 index 0000000..14537e8 --- /dev/null +++ b/src/FiscalOS.Infra/Migrations/20260212215015_InstitutionAndMetadata.cs @@ -0,0 +1,81 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FiscalOS.Infra.Migrations +{ + /// + public partial class InstitutionAndMetadata : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Institution", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + UserId = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Institution", x => x.Id); + table.ForeignKey( + name: "FK_Institution_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "InstitutionMetadata", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Type = table.Column(type: "TEXT", maxLength: 21, nullable: false), + InstitutionId = table.Column(type: "TEXT", nullable: false), + PlaidId = table.Column(type: "TEXT", nullable: true), + PlaidName = table.Column(type: "TEXT", nullable: true), + EncryptedAccessToken = table.Column(type: "TEXT", nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_InstitutionMetadata", x => x.Id); + table.ForeignKey( + name: "FK_InstitutionMetadata_Institution_InstitutionId", + column: x => x.InstitutionId, + principalTable: "Institution", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Institution_UserId", + table: "Institution", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_InstitutionMetadata_InstitutionId", + table: "InstitutionMetadata", + column: "InstitutionId", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "InstitutionMetadata"); + + migrationBuilder.DropTable( + name: "Institution"); + } + } +} diff --git a/src/FiscalOS.Infra/Migrations/AppDbContextModelSnapshot.cs b/src/FiscalOS.Infra/Migrations/AppDbContextModelSnapshot.cs index c15695f..d6bdd49 100644 --- a/src/FiscalOS.Infra/Migrations/AppDbContextModelSnapshot.cs +++ b/src/FiscalOS.Infra/Migrations/AppDbContextModelSnapshot.cs @@ -17,6 +17,64 @@ namespace FiscalOS.Infra.Migrations #pragma warning disable 612, 618 modelBuilder.HasAnnotation("ProductVersion", "10.0.2"); + modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Institution"); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("InstitutionId") + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(21) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("InstitutionId") + .IsUnique(); + + b.ToTable("InstitutionMetadata"); + + b.HasDiscriminator("Type").HasValue("InstitutionMetadata"); + + b.UseTphMappingStrategy(); + }); + modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b => { b.Property("Id") @@ -88,6 +146,43 @@ namespace FiscalOS.Infra.Migrations b.ToTable("Users"); }); + modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidMetadata", b => + { + b.HasBaseType("FiscalOS.Core.Accounts.InstitutionMetadata"); + + b.Property("EncryptedAccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PlaidId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PlaidName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasDiscriminator().HasValue("Plaid"); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b => + { + b.HasOne("FiscalOS.Core.Identity.User", null) + .WithMany("Institutions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b => + { + b.HasOne("FiscalOS.Core.Accounts.Institution", null) + .WithOne("Metadata") + .HasForeignKey("FiscalOS.Core.Accounts.InstitutionMetadata", "InstitutionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b => { b.HasOne("FiscalOS.Core.Identity.User", "User") @@ -99,8 +194,16 @@ namespace FiscalOS.Infra.Migrations b.Navigation("User"); }); + modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b => + { + b.Navigation("Metadata") + .IsRequired(); + }); + modelBuilder.Entity("FiscalOS.Core.Identity.User", b => { + b.Navigation("Institutions"); + b.Navigation("RefreshTokens"); }); #pragma warning restore 612, 618 diff --git a/src/FiscalOS.Infra/Usings.cs b/src/FiscalOS.Infra/Usings.cs index a681a67..8d87713 100644 --- a/src/FiscalOS.Infra/Usings.cs +++ b/src/FiscalOS.Infra/Usings.cs @@ -5,6 +5,7 @@ global using System.Security.Cryptography; global using System.Text; global using System.Text.Json; +global using FiscalOS.Core.Accounts; global using FiscalOS.Core.Authentication; global using FiscalOS.Core.Data; global using FiscalOS.Core.Identity;