diff --git a/0-Nine.Core/Entities/Property.cs b/0-Nine.Core/Entities/Property.cs index 6be6ef1..66b7c9a 100644 --- a/0-Nine.Core/Entities/Property.cs +++ b/0-Nine.Core/Entities/Property.cs @@ -96,8 +96,8 @@ public class Property : BaseModel public string Description { get; set; } = string.Empty; [JsonInclude] - [Display(Name = "Is Available?", Description = "Indicates if the property is currently available for lease")] - public bool IsAvailable { get; set; } = true; + [Display(Name = "Is Active?", Description = "Indicates if the property is active in the system")] + public bool IsActive { get; set; } = true; [JsonInclude] [StringLength(50)] diff --git a/0-Nine.Core/Exceptions/DatabaseExceptions.cs b/0-Nine.Core/Exceptions/DatabaseExceptions.cs new file mode 100644 index 0000000..81f4537 --- /dev/null +++ b/0-Nine.Core/Exceptions/DatabaseExceptions.cs @@ -0,0 +1,54 @@ +namespace Nine.Core.Exceptions; + +/// +/// Groups database-specific exceptions thrown by the Nine database layer. +/// +public static class DatabaseExceptions +{ + /// + /// Thrown when the database schema is more than two major versions behind the + /// current release and cannot be bridged automatically. The database has been + /// copied to before this + /// exception is thrown; the user must import their data via the application's + /// import workflow instead. + /// + public class SchemaNotSupportedException : Exception + { + /// Full path of the backup copy made before this exception was thrown. + public string BackupPath { get; } + + public SchemaNotSupportedException(string backupPath) + : base( + "The database has an incompatible schema version and cannot be upgraded " + + $"automatically. A backup has been saved to: {backupPath}") + { + BackupPath = backupPath; + } + } + + /// + /// Thrown when the database schema structure is invalid, unrecognised, or + /// internally inconsistent in a way that prevents normal operation. + /// + public class SchemaInvalidException : Exception + { + public SchemaInvalidException(string message) : base(message) { } + + public SchemaInvalidException(string message, Exception inner) + : base(message, inner) { } + } + + /// + /// Thrown when an era bridge migration step fails. Used inside + /// ApplyFirstAncestorBridgeAsync and ApplySecondAncestorBridgeAsync + /// to surface a descriptive failure without leaking raw SQL exception details to + /// the UI layer. + /// + public class MigrationException : Exception + { + public MigrationException(string message) : base(message) { } + + public MigrationException(string message, Exception inner) + : base(message, inner) { } + } +} diff --git a/1-Nine.Infrastructure/Data/CompiledModels/PropertyEntityType.cs b/1-Nine.Infrastructure/Data/CompiledModels/PropertyEntityType.cs index dd0580a..5948734 100644 --- a/1-Nine.Infrastructure/Data/CompiledModels/PropertyEntityType.cs +++ b/1-Nine.Infrastructure/Data/CompiledModels/PropertyEntityType.cs @@ -87,11 +87,11 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas fieldInfo: typeof(Property).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), maxLength: 1000); - var isAvailable = runtimeEntityType.AddProperty( - "IsAvailable", + var isActive = runtimeEntityType.AddProperty( + "IsActive", typeof(bool), - propertyInfo: typeof(Property).GetProperty("IsAvailable", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), - fieldInfo: typeof(Property).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + propertyInfo: typeof(Property).GetProperty("IsActive", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(Property).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), sentinel: false); var isDeleted = runtimeEntityType.AddProperty( diff --git a/1-Nine.Infrastructure/Data/DesignTimeDbContextFactory.cs b/1-Nine.Infrastructure/Data/DesignTimeDbContextFactory.cs new file mode 100644 index 0000000..ede66f2 --- /dev/null +++ b/1-Nine.Infrastructure/Data/DesignTimeDbContextFactory.cs @@ -0,0 +1,17 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace Nine.Infrastructure.Data; + +/// +/// Design-time factory to allow dotnet-ef migrations to run without the full application host. +/// +public class DesignTimeDbContextFactory : IDesignTimeDbContextFactory +{ + public ApplicationDbContext CreateDbContext(string[] args) + { + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseSqlite("DataSource=design-time-temp.db"); + return new ApplicationDbContext(optionsBuilder.Options); + } +} diff --git a/1-Nine.Infrastructure/Data/Migrations/20260313122831_RenameIsAvailableToIsActive.Designer.cs b/1-Nine.Infrastructure/Data/Migrations/20260313122831_RenameIsAvailableToIsActive.Designer.cs new file mode 100644 index 0000000..26f5cae --- /dev/null +++ b/1-Nine.Infrastructure/Data/Migrations/20260313122831_RenameIsAvailableToIsActive.Designer.cs @@ -0,0 +1,4392 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Nine.Infrastructure.Data; + +#nullable disable + +namespace Nine.Infrastructure.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260313122831_RenameIsAvailableToIsActive")] + partial class RenameIsAvailableToIsActive + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.1"); + + modelBuilder.Entity("Nine.Core.Entities.ApplicationScreening", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BackgroundCheckCompletedOn") + .HasColumnType("TEXT"); + + b.Property("BackgroundCheckNotes") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("BackgroundCheckPassed") + .HasColumnType("INTEGER"); + + b.Property("BackgroundCheckRequested") + .HasColumnType("INTEGER"); + + b.Property("BackgroundCheckRequestedOn") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("CreditCheckCompletedOn") + .HasColumnType("TEXT"); + + b.Property("CreditCheckNotes") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("CreditCheckPassed") + .HasColumnType("INTEGER"); + + b.Property("CreditCheckRequested") + .HasColumnType("INTEGER"); + + b.Property("CreditCheckRequestedOn") + .HasColumnType("TEXT"); + + b.Property("CreditScore") + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsSampleData") + .HasColumnType("INTEGER"); + + b.Property("LastModifiedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastModifiedOn") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("OverallResult") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("RentalApplicationId") + .HasColumnType("TEXT"); + + b.Property("ResultNotes") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("OverallResult"); + + b.HasIndex("RentalApplicationId") + .IsUnique(); + + b.ToTable("ApplicationScreenings"); + }); + + modelBuilder.Entity("Nine.Core.Entities.CalendarEvent", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("DurationMinutes") + .HasColumnType("INTEGER"); + + b.Property("EndOn") + .HasColumnType("TEXT"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Icon") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsSampleData") + .HasColumnType("INTEGER"); + + b.Property("LastModifiedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastModifiedOn") + .HasColumnType("TEXT"); + + b.Property("Location") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PropertyId") + .HasColumnType("TEXT"); + + b.Property("SourceEntityId") + .HasColumnType("TEXT"); + + b.Property("SourceEntityType") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("StartOn") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EventType"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("PropertyId"); + + b.HasIndex("SourceEntityId"); + + b.HasIndex("StartOn"); + + b.HasIndex("SourceEntityType", "SourceEntityId"); + + b.ToTable("CalendarEvents"); + }); + + modelBuilder.Entity("Nine.Core.Entities.CalendarSettings", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AutoCreateEvents") + .HasColumnType("INTEGER"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("DefaultColor") + .HasColumnType("TEXT"); + + b.Property("DefaultIcon") + .HasColumnType("TEXT"); + + b.Property("DisplayOrder") + .HasColumnType("INTEGER"); + + b.Property("EntityType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsSampleData") + .HasColumnType("INTEGER"); + + b.Property("LastModifiedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastModifiedOn") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("ShowOnCalendar") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("OrganizationId", "EntityType") + .IsUnique(); + + b.ToTable("CalendarSettings"); + }); + + modelBuilder.Entity("Nine.Core.Entities.Checklist", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ChecklistTemplateId") + .HasColumnType("TEXT"); + + b.Property("ChecklistType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CompletedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CompletedOn") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("DocumentId") + .HasColumnType("TEXT"); + + b.Property("GeneralNotes") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsSampleData") + .HasColumnType("INTEGER"); + + b.Property("LastModifiedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastModifiedOn") + .HasColumnType("TEXT"); + + b.Property("LeaseId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PropertyId") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChecklistTemplateId"); + + b.HasIndex("ChecklistType"); + + b.HasIndex("CompletedOn"); + + b.HasIndex("DocumentId"); + + b.HasIndex("LeaseId"); + + b.HasIndex("PropertyId"); + + b.HasIndex("Status"); + + b.ToTable("Checklists"); + }); + + modelBuilder.Entity("Nine.Core.Entities.ChecklistItem", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CategorySection") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ChecklistId") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("IsChecked") + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsSampleData") + .HasColumnType("INTEGER"); + + b.Property("ItemOrder") + .HasColumnType("INTEGER"); + + b.Property("ItemText") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastModifiedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastModifiedOn") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PhotoUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("RequiresValue") + .HasColumnType("INTEGER"); + + b.Property("SectionOrder") + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChecklistId"); + + b.ToTable("ChecklistItems"); + }); + + modelBuilder.Entity("Nine.Core.Entities.ChecklistTemplate", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsSampleData") + .HasColumnType("INTEGER"); + + b.Property("IsSystemTemplate") + .HasColumnType("INTEGER"); + + b.Property("LastModifiedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastModifiedOn") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Category"); + + b.HasIndex("OrganizationId"); + + b.ToTable("ChecklistTemplates"); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0001-000000000001"), + Category = "Tour", + CreatedBy = "", + CreatedOn = new DateTime(2025, 11, 30, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Standard property showing checklist", + IsDeleted = false, + IsSampleData = false, + IsSystemTemplate = true, + Name = "Property Tour", + OrganizationId = new Guid("00000000-0000-0000-0000-000000000000") + }, + new + { + Id = new Guid("00000000-0000-0000-0001-000000000002"), + Category = "MoveIn", + CreatedBy = "", + CreatedOn = new DateTime(2025, 11, 30, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Move-in inspection checklist", + IsDeleted = false, + IsSampleData = false, + IsSystemTemplate = true, + Name = "Move-In", + OrganizationId = new Guid("00000000-0000-0000-0000-000000000000") + }, + new + { + Id = new Guid("00000000-0000-0000-0001-000000000003"), + Category = "MoveOut", + CreatedBy = "", + CreatedOn = new DateTime(2025, 11, 30, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Move-out inspection checklist", + IsDeleted = false, + IsSampleData = false, + IsSystemTemplate = true, + Name = "Move-Out", + OrganizationId = new Guid("00000000-0000-0000-0000-000000000000") + }, + new + { + Id = new Guid("00000000-0000-0000-0001-000000000004"), + Category = "Tour", + CreatedBy = "", + CreatedOn = new DateTime(2025, 11, 30, 0, 0, 0, 0, DateTimeKind.Utc), + Description = "Open house event checklist", + IsDeleted = false, + IsSampleData = false, + IsSystemTemplate = true, + Name = "Open House", + OrganizationId = new Guid("00000000-0000-0000-0000-000000000000") + }); + }); + + modelBuilder.Entity("Nine.Core.Entities.ChecklistTemplateItem", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AllowsNotes") + .HasColumnType("INTEGER"); + + b.Property("CategorySection") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ChecklistTemplateId") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsRequired") + .HasColumnType("INTEGER"); + + b.Property("IsSampleData") + .HasColumnType("INTEGER"); + + b.Property("ItemOrder") + .HasColumnType("INTEGER"); + + b.Property("ItemText") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastModifiedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastModifiedOn") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RequiresValue") + .HasColumnType("INTEGER"); + + b.Property("SectionOrder") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChecklistTemplateId"); + + b.ToTable("ChecklistTemplateItems"); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0002-000000000001"), + AllowsNotes = true, + CategorySection = "Arrival & Introduction", + ChecklistTemplateId = new Guid("00000000-0000-0000-0001-000000000001"), + CreatedBy = "", + CreatedOn = new DateTime(2025, 11, 30, 0, 0, 0, 0, DateTimeKind.Utc), + IsDeleted = false, + IsRequired = true, + IsSampleData = false, + ItemOrder = 1, + ItemText = "Greeted prospect and verified appointment", + OrganizationId = new Guid("00000000-0000-0000-0000-000000000000"), + RequiresValue = false, + SectionOrder = 1 + }, + new + { + Id = new Guid("00000000-0000-0000-0002-000000000002"), + AllowsNotes = true, + CategorySection = "Arrival & Introduction", + ChecklistTemplateId = new Guid("00000000-0000-0000-0001-000000000001"), + CreatedBy = "", + CreatedOn = new DateTime(2025, 11, 30, 0, 0, 0, 0, DateTimeKind.Utc), + IsDeleted = false, + IsRequired = true, + IsSampleData = false, + ItemOrder = 2, + ItemText = "Reviewed property exterior and curb appeal", + OrganizationId = new Guid("00000000-0000-0000-0000-000000000000"), + RequiresValue = false, + SectionOrder = 1 + }, + new + { + Id = new Guid("00000000-0000-0000-0002-000000000003"), + AllowsNotes = true, + CategorySection = "Arrival & Introduction", + ChecklistTemplateId = new Guid("00000000-0000-0000-0001-000000000001"), + CreatedBy = "", + CreatedOn = new DateTime(2025, 11, 30, 0, 0, 0, 0, DateTimeKind.Utc), + IsDeleted = false, + IsRequired = true, + IsSampleData = false, + ItemOrder = 3, + ItemText = "Showed parking area/garage", + OrganizationId = new Guid("00000000-0000-0000-0000-000000000000"), + RequiresValue = false, + SectionOrder = 1 + }, + new + { + Id = new Guid("00000000-0000-0000-0002-000000000004"), + AllowsNotes = true, + CategorySection = "Interior Tour", + ChecklistTemplateId = new Guid("00000000-0000-0000-0001-000000000001"), + CreatedBy = "", + CreatedOn = new DateTime(2025, 11, 30, 0, 0, 0, 0, DateTimeKind.Utc), + IsDeleted = false, + IsRequired = true, + IsSampleData = false, + ItemOrder = 4, + ItemText = "Toured living room/common areas", + OrganizationId = new Guid("00000000-0000-0000-0000-000000000000"), + RequiresValue = false, + SectionOrder = 2 + }, + new + { + Id = new Guid("00000000-0000-0000-0002-000000000005"), + AllowsNotes = true, + CategorySection = "Interior Tour", + ChecklistTemplateId = new Guid("00000000-0000-0000-0001-000000000001"), + CreatedBy = "", + CreatedOn = new DateTime(2025, 11, 30, 0, 0, 0, 0, DateTimeKind.Utc), + IsDeleted = false, + IsRequired = true, + IsSampleData = false, + ItemOrder = 5, + ItemText = "Showed all bedrooms", + OrganizationId = new Guid("00000000-0000-0000-0000-000000000000"), + RequiresValue = false, + SectionOrder = 2 + }, + new + { + Id = new Guid("00000000-0000-0000-0002-000000000006"), + AllowsNotes = true, + CategorySection = "Interior Tour", + ChecklistTemplateId = new Guid("00000000-0000-0000-0001-000000000001"), + CreatedBy = "", + CreatedOn = new DateTime(2025, 11, 30, 0, 0, 0, 0, DateTimeKind.Utc), + IsDeleted = false, + IsRequired = true, + IsSampleData = false, + ItemOrder = 6, + ItemText = "Showed all bathrooms", + OrganizationId = new Guid("00000000-0000-0000-0000-000000000000"), + RequiresValue = false, + SectionOrder = 2 + }, + new + { + Id = new Guid("00000000-0000-0000-0002-000000000007"), + AllowsNotes = true, + CategorySection = "Kitchen & Appliances", + ChecklistTemplateId = new Guid("00000000-0000-0000-0001-000000000001"), + CreatedBy = "", + CreatedOn = new DateTime(2025, 11, 30, 0, 0, 0, 0, DateTimeKind.Utc), + IsDeleted = false, + IsRequired = true, + IsSampleData = false, + ItemOrder = 7, + ItemText = "Toured kitchen and demonstrated appliances", + OrganizationId = new Guid("00000000-0000-0000-0000-000000000000"), + RequiresValue = false, + SectionOrder = 3 + }, + new + { + Id = new Guid("00000000-0000-0000-0002-000000000008"), + AllowsNotes = true, + CategorySection = "Kitchen & Appliances", + ChecklistTemplateId = new Guid("00000000-0000-0000-0001-000000000001"), + CreatedBy = "", + CreatedOn = new DateTime(2025, 11, 30, 0, 0, 0, 0, DateTimeKind.Utc), + IsDeleted = false, + IsRequired = true, + IsSampleData = false, + ItemOrder = 8, + ItemText = "Explained which appliances are included", + OrganizationId = new Guid("00000000-0000-0000-0000-000000000000"), + RequiresValue = false, + SectionOrder = 3 + }, + new + { + Id = new Guid("00000000-0000-0000-0002-000000000009"), + AllowsNotes = true, + CategorySection = "Utilities & Systems", + ChecklistTemplateId = new Guid("00000000-0000-0000-0001-000000000001"), + CreatedBy = "", + CreatedOn = new DateTime(2025, 11, 30, 0, 0, 0, 0, DateTimeKind.Utc), + IsDeleted = false, + IsRequired = true, + IsSampleData = false, + ItemOrder = 9, + ItemText = "Explained HVAC system and thermostat controls", + OrganizationId = new Guid("00000000-0000-0000-0000-000000000000"), + RequiresValue = false, + SectionOrder = 4 + }, + new + { + Id = new Guid("00000000-0000-0000-0002-000000000010"), + AllowsNotes = true, + CategorySection = "Utilities & Systems", + ChecklistTemplateId = new Guid("00000000-0000-0000-0001-000000000001"), + CreatedBy = "", + CreatedOn = new DateTime(2025, 11, 30, 0, 0, 0, 0, DateTimeKind.Utc), + IsDeleted = false, + IsRequired = true, + IsSampleData = false, + ItemOrder = 10, + ItemText = "Reviewed utility responsibilities (tenant vs landlord)", + OrganizationId = new Guid("00000000-0000-0000-0000-000000000000"), + RequiresValue = false, + SectionOrder = 4 + }, + new + { + Id = new Guid("00000000-0000-0000-0002-000000000011"), + AllowsNotes = true, + CategorySection = "Utilities & Systems", + ChecklistTemplateId = new Guid("00000000-0000-0000-0001-000000000001"), + CreatedBy = "", + CreatedOn = new DateTime(2025, 11, 30, 0, 0, 0, 0, DateTimeKind.Utc), + IsDeleted = false, + IsRequired = true, + IsSampleData = false, + ItemOrder = 11, + ItemText = "Showed water heater location", + OrganizationId = new Guid("00000000-0000-0000-0000-000000000000"), + RequiresValue = false, + SectionOrder = 4 + }, + new + { + Id = new Guid("00000000-0000-0000-0002-000000000012"), + AllowsNotes = true, + CategorySection = "Storage & Amenities", + ChecklistTemplateId = new Guid("00000000-0000-0000-0001-000000000001"), + CreatedBy = "", + CreatedOn = new DateTime(2025, 11, 30, 0, 0, 0, 0, DateTimeKind.Utc), + IsDeleted = false, + IsRequired = true, + IsSampleData = false, + ItemOrder = 12, + ItemText = "Showed storage areas (closets, attic, basement)", + OrganizationId = new Guid("00000000-0000-0000-0000-000000000000"), + RequiresValue = false, + SectionOrder = 5 + }, + new + { + Id = new Guid("00000000-0000-0000-0002-000000000013"), + AllowsNotes = true, + CategorySection = "Storage & Amenities", + ChecklistTemplateId = new Guid("00000000-0000-0000-0001-000000000001"), + CreatedBy = "", + CreatedOn = new DateTime(2025, 11, 30, 0, 0, 0, 0, DateTimeKind.Utc), + IsDeleted = false, + IsRequired = true, + IsSampleData = false, + ItemOrder = 13, + ItemText = "Showed laundry facilities", + OrganizationId = new Guid("00000000-0000-0000-0000-000000000000"), + RequiresValue = false, + SectionOrder = 5 + }, + new + { + Id = new Guid("00000000-0000-0000-0002-000000000014"), + AllowsNotes = true, + CategorySection = "Storage & Amenities", + ChecklistTemplateId = new Guid("00000000-0000-0000-0001-000000000001"), + CreatedBy = "", + CreatedOn = new DateTime(2025, 11, 30, 0, 0, 0, 0, DateTimeKind.Utc), + IsDeleted = false, + IsRequired = true, + IsSampleData = false, + ItemOrder = 14, + ItemText = "Showed outdoor space (yard, patio, balcony)", + OrganizationId = new Guid("00000000-0000-0000-0000-000000000000"), + RequiresValue = false, + SectionOrder = 5 + }, + new + { + Id = new Guid("00000000-0000-0000-0002-000000000015"), + AllowsNotes = true, + CategorySection = "Lease Terms", + ChecklistTemplateId = new Guid("00000000-0000-0000-0001-000000000001"), + CreatedBy = "", + CreatedOn = new DateTime(2025, 11, 30, 0, 0, 0, 0, DateTimeKind.Utc), + IsDeleted = false, + IsRequired = true, + IsSampleData = false, + ItemOrder = 15, + ItemText = "Discussed monthly rent amount", + OrganizationId = new Guid("00000000-0000-0000-0000-000000000000"), + RequiresValue = false, + SectionOrder = 6 + }, + new + { + Id = new Guid("00000000-0000-0000-0002-000000000016"), + AllowsNotes = true, + CategorySection = "Lease Terms", + ChecklistTemplateId = new Guid("00000000-0000-0000-0001-000000000001"), + CreatedBy = "", + CreatedOn = new DateTime(2025, 11, 30, 0, 0, 0, 0, DateTimeKind.Utc), + IsDeleted = false, + IsRequired = true, + IsSampleData = false, + ItemOrder = 16, + ItemText = "Explained security deposit and move-in costs", + OrganizationId = new Guid("00000000-0000-0000-0000-000000000000"), + RequiresValue = false, + SectionOrder = 6 + }, + new + { + Id = new Guid("00000000-0000-0000-0002-000000000017"), + AllowsNotes = true, + CategorySection = "Lease Terms", + ChecklistTemplateId = new Guid("00000000-0000-0000-0001-000000000001"), + CreatedBy = "", + CreatedOn = new DateTime(2025, 11, 30, 0, 0, 0, 0, DateTimeKind.Utc), + IsDeleted = false, + IsRequired = true, + IsSampleData = false, + ItemOrder = 17, + ItemText = "Reviewed lease term length and start date", + OrganizationId = new Guid("00000000-0000-0000-0000-000000000000"), + RequiresValue = false, + SectionOrder = 6 + }, + new + { + Id = new Guid("00000000-0000-0000-0002-000000000018"), + AllowsNotes = true, + CategorySection = "Lease Terms", + ChecklistTemplateId = new Guid("00000000-0000-0000-0001-000000000001"), + CreatedBy = "", + CreatedOn = new DateTime(2025, 11, 30, 0, 0, 0, 0, DateTimeKind.Utc), + IsDeleted = false, + IsRequired = true, + IsSampleData = false, + ItemOrder = 18, + ItemText = "Explained pet policy", + OrganizationId = new Guid("00000000-0000-0000-0000-000000000000"), + RequiresValue = false, + SectionOrder = 6 + }, + new + { + Id = new Guid("00000000-0000-0000-0002-000000000019"), + AllowsNotes = true, + CategorySection = "Next Steps", + ChecklistTemplateId = new Guid("00000000-0000-0000-0001-000000000001"), + CreatedBy = "", + CreatedOn = new DateTime(2025, 11, 30, 0, 0, 0, 0, DateTimeKind.Utc), + IsDeleted = false, + IsRequired = true, + IsSampleData = false, + ItemOrder = 19, + ItemText = "Explained application process and requirements", + OrganizationId = new Guid("00000000-0000-0000-0000-000000000000"), + RequiresValue = false, + SectionOrder = 7 + }, + new + { + Id = new Guid("00000000-0000-0000-0002-000000000020"), + AllowsNotes = true, + CategorySection = "Next Steps", + ChecklistTemplateId = new Guid("00000000-0000-0000-0001-000000000001"), + CreatedBy = "", + CreatedOn = new DateTime(2025, 11, 30, 0, 0, 0, 0, DateTimeKind.Utc), + IsDeleted = false, + IsRequired = true, + IsSampleData = false, + ItemOrder = 20, + ItemText = "Reviewed screening process (background, credit check)", + OrganizationId = new Guid("00000000-0000-0000-0000-000000000000"), + RequiresValue = false, + SectionOrder = 7 + }, + new + { + Id = new Guid("00000000-0000-0000-0002-000000000021"), + AllowsNotes = true, + CategorySection = "Next Steps", + ChecklistTemplateId = new Guid("00000000-0000-0000-0001-000000000001"), + CreatedBy = "", + CreatedOn = new DateTime(2025, 11, 30, 0, 0, 0, 0, DateTimeKind.Utc), + IsDeleted = false, + IsRequired = true, + IsSampleData = false, + ItemOrder = 21, + ItemText = "Answered all prospect questions", + OrganizationId = new Guid("00000000-0000-0000-0000-000000000000"), + RequiresValue = false, + SectionOrder = 7 + }, + new + { + Id = new Guid("00000000-0000-0000-0002-000000000022"), + AllowsNotes = true, + CategorySection = "Assessment", + ChecklistTemplateId = new Guid("00000000-0000-0000-0001-000000000001"), + CreatedBy = "", + CreatedOn = new DateTime(2025, 11, 30, 0, 0, 0, 0, DateTimeKind.Utc), + IsDeleted = false, + IsRequired = true, + IsSampleData = false, + ItemOrder = 22, + ItemText = "Prospect Interest Level", + OrganizationId = new Guid("00000000-0000-0000-0000-000000000000"), + RequiresValue = true, + SectionOrder = 8 + }, + new + { + Id = new Guid("00000000-0000-0000-0002-000000000023"), + AllowsNotes = true, + CategorySection = "Assessment", + ChecklistTemplateId = new Guid("00000000-0000-0000-0001-000000000001"), + CreatedBy = "", + CreatedOn = new DateTime(2025, 11, 30, 0, 0, 0, 0, DateTimeKind.Utc), + IsDeleted = false, + IsRequired = true, + IsSampleData = false, + ItemOrder = 23, + ItemText = "Overall showing feedback and notes", + OrganizationId = new Guid("00000000-0000-0000-0000-000000000000"), + RequiresValue = false, + SectionOrder = 8 + }, + new + { + Id = new Guid("00000000-0000-0000-0002-000000000024"), + AllowsNotes = true, + CategorySection = "General", + ChecklistTemplateId = new Guid("00000000-0000-0000-0001-000000000002"), + CreatedBy = "", + CreatedOn = new DateTime(2025, 11, 30, 0, 0, 0, 0, DateTimeKind.Utc), + IsDeleted = false, + IsRequired = true, + IsSampleData = false, + ItemOrder = 1, + ItemText = "Document property condition", + OrganizationId = new Guid("00000000-0000-0000-0000-000000000000"), + RequiresValue = false, + SectionOrder = 1 + }, + new + { + Id = new Guid("00000000-0000-0000-0002-000000000025"), + AllowsNotes = true, + CategorySection = "General", + ChecklistTemplateId = new Guid("00000000-0000-0000-0001-000000000002"), + CreatedBy = "", + CreatedOn = new DateTime(2025, 11, 30, 0, 0, 0, 0, DateTimeKind.Utc), + IsDeleted = false, + IsRequired = true, + IsSampleData = false, + ItemOrder = 2, + ItemText = "Collect keys and access codes", + OrganizationId = new Guid("00000000-0000-0000-0000-000000000000"), + RequiresValue = false, + SectionOrder = 1 + }, + new + { + Id = new Guid("00000000-0000-0000-0002-000000000026"), + AllowsNotes = true, + CategorySection = "General", + ChecklistTemplateId = new Guid("00000000-0000-0000-0001-000000000002"), + CreatedBy = "", + CreatedOn = new DateTime(2025, 11, 30, 0, 0, 0, 0, DateTimeKind.Utc), + IsDeleted = false, + IsRequired = true, + IsSampleData = false, + ItemOrder = 3, + ItemText = "Review lease terms with tenant", + OrganizationId = new Guid("00000000-0000-0000-0000-000000000000"), + RequiresValue = false, + SectionOrder = 1 + }, + new + { + Id = new Guid("00000000-0000-0000-0002-000000000027"), + AllowsNotes = true, + CategorySection = "General", + ChecklistTemplateId = new Guid("00000000-0000-0000-0001-000000000003"), + CreatedBy = "", + CreatedOn = new DateTime(2025, 11, 30, 0, 0, 0, 0, DateTimeKind.Utc), + IsDeleted = false, + IsRequired = true, + IsSampleData = false, + ItemOrder = 1, + ItemText = "Inspect property condition", + OrganizationId = new Guid("00000000-0000-0000-0000-000000000000"), + RequiresValue = false, + SectionOrder = 1 + }, + new + { + Id = new Guid("00000000-0000-0000-0002-000000000028"), + AllowsNotes = true, + CategorySection = "General", + ChecklistTemplateId = new Guid("00000000-0000-0000-0001-000000000003"), + CreatedBy = "", + CreatedOn = new DateTime(2025, 11, 30, 0, 0, 0, 0, DateTimeKind.Utc), + IsDeleted = false, + IsRequired = true, + IsSampleData = false, + ItemOrder = 2, + ItemText = "Collect all keys and access devices", + OrganizationId = new Guid("00000000-0000-0000-0000-000000000000"), + RequiresValue = false, + SectionOrder = 1 + }, + new + { + Id = new Guid("00000000-0000-0000-0002-000000000029"), + AllowsNotes = true, + CategorySection = "General", + ChecklistTemplateId = new Guid("00000000-0000-0000-0001-000000000003"), + CreatedBy = "", + CreatedOn = new DateTime(2025, 11, 30, 0, 0, 0, 0, DateTimeKind.Utc), + IsDeleted = false, + IsRequired = true, + IsSampleData = false, + ItemOrder = 3, + ItemText = "Document damages and needed repairs", + OrganizationId = new Guid("00000000-0000-0000-0000-000000000000"), + RequiresValue = false, + SectionOrder = 1 + }, + new + { + Id = new Guid("00000000-0000-0000-0002-000000000030"), + AllowsNotes = true, + CategorySection = "Preparation", + ChecklistTemplateId = new Guid("00000000-0000-0000-0001-000000000004"), + CreatedBy = "", + CreatedOn = new DateTime(2025, 11, 30, 0, 0, 0, 0, DateTimeKind.Utc), + IsDeleted = false, + IsRequired = true, + IsSampleData = false, + ItemOrder = 1, + ItemText = "Set up signage and directional markers", + OrganizationId = new Guid("00000000-0000-0000-0000-000000000000"), + RequiresValue = false, + SectionOrder = 1 + }, + new + { + Id = new Guid("00000000-0000-0000-0002-000000000031"), + AllowsNotes = true, + CategorySection = "Preparation", + ChecklistTemplateId = new Guid("00000000-0000-0000-0001-000000000004"), + CreatedBy = "", + CreatedOn = new DateTime(2025, 11, 30, 0, 0, 0, 0, DateTimeKind.Utc), + IsDeleted = false, + IsRequired = true, + IsSampleData = false, + ItemOrder = 2, + ItemText = "Prepare information packets", + OrganizationId = new Guid("00000000-0000-0000-0000-000000000000"), + RequiresValue = false, + SectionOrder = 1 + }, + new + { + Id = new Guid("00000000-0000-0000-0002-000000000032"), + AllowsNotes = true, + CategorySection = "Preparation", + ChecklistTemplateId = new Guid("00000000-0000-0000-0001-000000000004"), + CreatedBy = "", + CreatedOn = new DateTime(2025, 11, 30, 0, 0, 0, 0, DateTimeKind.Utc), + IsDeleted = false, + IsRequired = true, + IsSampleData = false, + ItemOrder = 3, + ItemText = "Set up visitor sign-in sheet", + OrganizationId = new Guid("00000000-0000-0000-0000-000000000000"), + RequiresValue = false, + SectionOrder = 1 + }); + }); + + modelBuilder.Entity("Nine.Core.Entities.DatabaseSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DatabaseEncryptionEnabled") + .HasColumnType("INTEGER"); + + b.Property("EncryptionChangedOn") + .HasColumnType("TEXT"); + + b.Property("EncryptionSalt") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LastModifiedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastModifiedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("DatabaseSettings"); + }); + + modelBuilder.Entity("Nine.Core.Entities.Document", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DocumentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("FileData") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("FileExtension") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("FilePath") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("FileSize") + .HasColumnType("INTEGER"); + + b.Property("FileType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("InvoiceId") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsSampleData") + .HasColumnType("INTEGER"); + + b.Property("LastModifiedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastModifiedOn") + .HasColumnType("TEXT"); + + b.Property("LeaseId") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PaymentId") + .HasColumnType("TEXT"); + + b.Property("PropertyId") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("InvoiceId"); + + b.HasIndex("LeaseId"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("PaymentId"); + + b.HasIndex("PropertyId"); + + b.HasIndex("TenantId"); + + b.ToTable("Documents"); + }); + + modelBuilder.Entity("Nine.Core.Entities.Inspection", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActionItemsRequired") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("BathroomSinkGood") + .HasColumnType("INTEGER"); + + b.Property("BathroomSinkNotes") + .HasColumnType("TEXT"); + + b.Property("BathroomToiletGood") + .HasColumnType("INTEGER"); + + b.Property("BathroomToiletNotes") + .HasColumnType("TEXT"); + + b.Property("BathroomTubShowerGood") + .HasColumnType("INTEGER"); + + b.Property("BathroomTubShowerNotes") + .HasColumnType("TEXT"); + + b.Property("BathroomVentilationGood") + .HasColumnType("INTEGER"); + + b.Property("BathroomVentilationNotes") + .HasColumnType("TEXT"); + + b.Property("CalendarEventId") + .HasColumnType("TEXT"); + + b.Property("CarbonMonoxideDetectorsGood") + .HasColumnType("INTEGER"); + + b.Property("CarbonMonoxideDetectorsNotes") + .HasColumnType("TEXT"); + + b.Property("CompletedOn") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("DocumentId") + .HasColumnType("TEXT"); + + b.Property("ElectricalSystemGood") + .HasColumnType("INTEGER"); + + b.Property("ElectricalSystemNotes") + .HasColumnType("TEXT"); + + b.Property("ExteriorDoorsGood") + .HasColumnType("INTEGER"); + + b.Property("ExteriorDoorsNotes") + .HasColumnType("TEXT"); + + b.Property("ExteriorFoundationGood") + .HasColumnType("INTEGER"); + + b.Property("ExteriorFoundationNotes") + .HasColumnType("TEXT"); + + b.Property("ExteriorGuttersGood") + .HasColumnType("INTEGER"); + + b.Property("ExteriorGuttersNotes") + .HasColumnType("TEXT"); + + b.Property("ExteriorRoofGood") + .HasColumnType("INTEGER"); + + b.Property("ExteriorRoofNotes") + .HasColumnType("TEXT"); + + b.Property("ExteriorSidingGood") + .HasColumnType("INTEGER"); + + b.Property("ExteriorSidingNotes") + .HasColumnType("TEXT"); + + b.Property("ExteriorWindowsGood") + .HasColumnType("INTEGER"); + + b.Property("ExteriorWindowsNotes") + .HasColumnType("TEXT"); + + b.Property("GeneralNotes") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("HvacSystemGood") + .HasColumnType("INTEGER"); + + b.Property("HvacSystemNotes") + .HasColumnType("TEXT"); + + b.Property("InspectedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("InspectionType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("InteriorCeilingsGood") + .HasColumnType("INTEGER"); + + b.Property("InteriorCeilingsNotes") + .HasColumnType("TEXT"); + + b.Property("InteriorDoorsGood") + .HasColumnType("INTEGER"); + + b.Property("InteriorDoorsNotes") + .HasColumnType("TEXT"); + + b.Property("InteriorFloorsGood") + .HasColumnType("INTEGER"); + + b.Property("InteriorFloorsNotes") + .HasColumnType("TEXT"); + + b.Property("InteriorWallsGood") + .HasColumnType("INTEGER"); + + b.Property("InteriorWallsNotes") + .HasColumnType("TEXT"); + + b.Property("InteriorWindowsGood") + .HasColumnType("INTEGER"); + + b.Property("InteriorWindowsNotes") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsSampleData") + .HasColumnType("INTEGER"); + + b.Property("KitchenAppliancesGood") + .HasColumnType("INTEGER"); + + b.Property("KitchenAppliancesNotes") + .HasColumnType("TEXT"); + + b.Property("KitchenCabinetsGood") + .HasColumnType("INTEGER"); + + b.Property("KitchenCabinetsNotes") + .HasColumnType("TEXT"); + + b.Property("KitchenCountersGood") + .HasColumnType("INTEGER"); + + b.Property("KitchenCountersNotes") + .HasColumnType("TEXT"); + + b.Property("KitchenSinkPlumbingGood") + .HasColumnType("INTEGER"); + + b.Property("KitchenSinkPlumbingNotes") + .HasColumnType("TEXT"); + + b.Property("LandscapingGood") + .HasColumnType("INTEGER"); + + b.Property("LandscapingNotes") + .HasColumnType("TEXT"); + + b.Property("LastModifiedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastModifiedOn") + .HasColumnType("TEXT"); + + b.Property("LeaseId") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("OverallCondition") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("PlumbingSystemGood") + .HasColumnType("INTEGER"); + + b.Property("PlumbingSystemNotes") + .HasColumnType("TEXT"); + + b.Property("PropertyId") + .HasColumnType("TEXT"); + + b.Property("SmokeDetectorsGood") + .HasColumnType("INTEGER"); + + b.Property("SmokeDetectorsNotes") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CompletedOn"); + + b.HasIndex("DocumentId"); + + b.HasIndex("LeaseId"); + + b.HasIndex("PropertyId"); + + b.ToTable("Inspections"); + }); + + modelBuilder.Entity("Nine.Core.Entities.Invoice", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("AmountPaid") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DocumentId") + .HasColumnType("TEXT"); + + b.Property("DueOn") + .HasColumnType("TEXT"); + + b.Property("InvoiceNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("InvoicedOn") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsSampleData") + .HasColumnType("INTEGER"); + + b.Property("LastModifiedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastModifiedOn") + .HasColumnType("TEXT"); + + b.Property("LateFeeAmount") + .HasColumnType("decimal(18,2)"); + + b.Property("LateFeeApplied") + .HasColumnType("INTEGER"); + + b.Property("LateFeeAppliedOn") + .HasColumnType("TEXT"); + + b.Property("LeaseId") + .HasColumnType("TEXT"); + + b.Property("Notes") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PaidOn") + .HasColumnType("TEXT"); + + b.Property("ReminderSent") + .HasColumnType("INTEGER"); + + b.Property("ReminderSentOn") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId"); + + b.HasIndex("LeaseId"); + + b.HasIndex("OrganizationId", "InvoiceNumber") + .IsUnique() + .HasDatabaseName("IX_Invoice_OrgId_InvoiceNumber"); + + b.ToTable("Invoices"); + }); + + modelBuilder.Entity("Nine.Core.Entities.Lease", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActualMoveOutDate") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("DeclinedOn") + .HasColumnType("TEXT"); + + b.Property("DocumentId") + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("ExpectedMoveOutDate") + .HasColumnType("TEXT"); + + b.Property("ExpiresOn") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsSampleData") + .HasColumnType("INTEGER"); + + b.Property("LastModifiedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastModifiedOn") + .HasColumnType("TEXT"); + + b.Property("LeaseOfferId") + .HasColumnType("TEXT"); + + b.Property("MonthlyRent") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("Notes") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("OfferedOn") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PreviousLeaseId") + .HasColumnType("TEXT"); + + b.Property("PropertyId") + .HasColumnType("TEXT"); + + b.Property("ProposedRenewalRent") + .HasColumnType("decimal(18,2)"); + + b.Property("RenewalNotes") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("RenewalNotificationSent") + .HasColumnType("INTEGER"); + + b.Property("RenewalNotificationSentOn") + .HasColumnType("TEXT"); + + b.Property("RenewalNumber") + .HasColumnType("INTEGER"); + + b.Property("RenewalOfferedOn") + .HasColumnType("TEXT"); + + b.Property("RenewalReminderSentOn") + .HasColumnType("TEXT"); + + b.Property("RenewalResponseOn") + .HasColumnType("TEXT"); + + b.Property("RenewalStatus") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("SecurityDeposit") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("SignedOn") + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.Property("TerminationNoticedOn") + .HasColumnType("TEXT"); + + b.Property("TerminationReason") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Terms") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("PropertyId"); + + b.HasIndex("TenantId"); + + b.ToTable("Leases"); + }); + + modelBuilder.Entity("Nine.Core.Entities.LeaseOffer", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConvertedLeaseId") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("ExpiresOn") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsSampleData") + .HasColumnType("INTEGER"); + + b.Property("LastModifiedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastModifiedOn") + .HasColumnType("TEXT"); + + b.Property("MonthlyRent") + .HasColumnType("decimal(18,2)"); + + b.Property("Notes") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("OfferedOn") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PropertyId") + .HasColumnType("TEXT"); + + b.Property("ProspectiveTenantId") + .HasColumnType("TEXT"); + + b.Property("RentalApplicationId") + .HasColumnType("TEXT"); + + b.Property("RespondedOn") + .HasColumnType("TEXT"); + + b.Property("ResponseNotes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SecurityDeposit") + .HasColumnType("decimal(18,2)"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Terms") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PropertyId"); + + b.HasIndex("ProspectiveTenantId"); + + b.HasIndex("RentalApplicationId"); + + b.ToTable("LeaseOffers"); + }); + + modelBuilder.Entity("Nine.Core.Entities.MaintenanceRequest", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActualCost") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("AssignedTo") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CalendarEventId") + .HasColumnType("TEXT"); + + b.Property("CompletedOn") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EstimatedCost") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsSampleData") + .HasColumnType("INTEGER"); + + b.Property("LastModifiedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastModifiedOn") + .HasColumnType("TEXT"); + + b.Property("LeaseId") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Priority") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("PropertyId") + .HasColumnType("TEXT"); + + b.Property("RequestType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("RequestedBy") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("RequestedByEmail") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("RequestedByPhone") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("RequestedOn") + .HasColumnType("TEXT"); + + b.Property("ResolutionNotes") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ScheduledOn") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LeaseId"); + + b.HasIndex("Priority"); + + b.HasIndex("PropertyId"); + + b.HasIndex("RequestedOn"); + + b.HasIndex("Status"); + + b.ToTable("MaintenanceRequests"); + }); + + modelBuilder.Entity("Nine.Core.Entities.Note", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(5000) + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("TEXT"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsSampleData") + .HasColumnType("INTEGER"); + + b.Property("LastModifiedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastModifiedOn") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("UserFullName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Notes"); + }); + + modelBuilder.Entity("Nine.Core.Entities.NotificationPreferences", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("DailyDigestTime") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("EmailApplicationStatusChange") + .HasColumnType("INTEGER"); + + b.Property("EmailInspectionScheduled") + .HasColumnType("INTEGER"); + + b.Property("EmailLeaseExpiring") + .HasColumnType("INTEGER"); + + b.Property("EmailMaintenanceUpdate") + .HasColumnType("INTEGER"); + + b.Property("EmailPaymentDue") + .HasColumnType("INTEGER"); + + b.Property("EmailPaymentReceived") + .HasColumnType("INTEGER"); + + b.Property("EnableDailyDigest") + .HasColumnType("INTEGER"); + + b.Property("EnableEmailNotifications") + .HasColumnType("INTEGER"); + + b.Property("EnableInAppNotifications") + .HasColumnType("INTEGER"); + + b.Property("EnableSMSNotifications") + .HasColumnType("INTEGER"); + + b.Property("EnableWeeklyDigest") + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsSampleData") + .HasColumnType("INTEGER"); + + b.Property("LastModifiedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastModifiedOn") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("SMSLeaseExpiringUrgent") + .HasColumnType("INTEGER"); + + b.Property("SMSMaintenanceEmergency") + .HasColumnType("INTEGER"); + + b.Property("SMSPaymentDue") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WeeklyDigestDay") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "OrganizationId") + .IsUnique(); + + b.ToTable("NotificationPreferences"); + }); + + modelBuilder.Entity("Nine.Core.Entities.Organization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("LastModifiedBy") + .HasColumnType("TEXT"); + + b.Property("LastModifiedOn") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("State") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IsActive"); + + b.HasIndex("OwnerId"); + + b.ToTable("Organizations"); + }); + + modelBuilder.Entity("Nine.Core.Entities.OrganizationEmailSettings", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("DailyCountResetOn") + .HasColumnType("TEXT"); + + b.Property("DailyLimit") + .HasColumnType("INTEGER"); + + b.Property("EmailsSentThisMonth") + .HasColumnType("INTEGER"); + + b.Property("EmailsSentToday") + .HasColumnType("INTEGER"); + + b.Property("EnableSsl") + .HasColumnType("INTEGER"); + + b.Property("FromEmail") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("FromName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsEmailEnabled") + .HasColumnType("INTEGER"); + + b.Property("IsSampleData") + .HasColumnType("INTEGER"); + + b.Property("IsVerified") + .HasColumnType("INTEGER"); + + b.Property("LastEmailSentOn") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastErrorOn") + .HasColumnType("TEXT"); + + b.Property("LastModifiedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastModifiedOn") + .HasColumnType("TEXT"); + + b.Property("LastVerifiedOn") + .HasColumnType("TEXT"); + + b.Property("MonthlyCountResetOn") + .HasColumnType("TEXT"); + + b.Property("MonthlyLimit") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Password") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PlanType") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ProviderName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SendGridApiKeyEncrypted") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("SmtpPort") + .HasColumnType("INTEGER"); + + b.Property("SmtpServer") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StatsLastUpdatedOn") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .IsUnique(); + + b.ToTable("OrganizationEmailSettings"); + }); + + modelBuilder.Entity("Nine.Core.Entities.OrganizationSMSSettings", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccountBalance") + .HasPrecision(18, 2) + .HasColumnType("TEXT"); + + b.Property("AccountType") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CostPerSMS") + .HasPrecision(18, 4) + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("DailyCountResetOn") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsSMSEnabled") + .HasColumnType("INTEGER"); + + b.Property("IsSampleData") + .HasColumnType("INTEGER"); + + b.Property("IsVerified") + .HasColumnType("INTEGER"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastModifiedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastModifiedOn") + .HasColumnType("TEXT"); + + b.Property("LastSMSSentOn") + .HasColumnType("TEXT"); + + b.Property("LastVerifiedOn") + .HasColumnType("TEXT"); + + b.Property("MonthlyCountResetOn") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("ProviderName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SMSSentThisMonth") + .HasColumnType("INTEGER"); + + b.Property("SMSSentToday") + .HasColumnType("INTEGER"); + + b.Property("StatsLastUpdatedOn") + .HasColumnType("TEXT"); + + b.Property("TwilioAccountSidEncrypted") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("TwilioAuthTokenEncrypted") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("TwilioPhoneNumber") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .IsUnique(); + + b.ToTable("OrganizationSMSSettings"); + }); + + modelBuilder.Entity("Nine.Core.Entities.OrganizationSettings", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AllowTenantDividendChoice") + .HasColumnType("INTEGER"); + + b.Property("ApplicationExpirationDays") + .HasColumnType("INTEGER"); + + b.Property("ApplicationFeeEnabled") + .HasColumnType("INTEGER"); + + b.Property("AutoCalculateSecurityDeposit") + .HasColumnType("INTEGER"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("DefaultApplicationFee") + .HasPrecision(18, 2) + .HasColumnType("TEXT"); + + b.Property("DefaultDividendPaymentMethod") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("DividendDistributionMonth") + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsSampleData") + .HasColumnType("INTEGER"); + + b.Property("LastModifiedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastModifiedOn") + .HasColumnType("TEXT"); + + b.Property("LateFeeAutoApply") + .HasColumnType("INTEGER"); + + b.Property("LateFeeEnabled") + .HasColumnType("INTEGER"); + + b.Property("LateFeeGracePeriodDays") + .HasColumnType("INTEGER"); + + b.Property("LateFeePercentage") + .HasPrecision(5, 4) + .HasColumnType("TEXT"); + + b.Property("MaxLateFeeAmount") + .HasPrecision(18, 2) + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OrganizationSharePercentage") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("PaymentReminderDaysBefore") + .HasColumnType("INTEGER"); + + b.Property("PaymentReminderEnabled") + .HasColumnType("INTEGER"); + + b.Property("RefundProcessingDays") + .HasColumnType("INTEGER"); + + b.Property("SecurityDepositInvestmentEnabled") + .HasColumnType("INTEGER"); + + b.Property("SecurityDepositMultiplier") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("TourNoShowGracePeriodHours") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .IsUnique(); + + b.ToTable("OrganizationSettings"); + }); + + modelBuilder.Entity("Nine.Core.Entities.OrganizationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("GrantedBy") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("GrantedOn") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("LastModifiedBy") + .HasColumnType("TEXT"); + + b.Property("LastModifiedOn") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevokedOn") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IsActive"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("Role"); + + b.HasIndex("UserId", "OrganizationId") + .IsUnique(); + + b.ToTable("OrganizationUsers"); + }); + + modelBuilder.Entity("Nine.Core.Entities.Payment", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("DocumentId") + .HasColumnType("TEXT"); + + b.Property("InvoiceId") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsSampleData") + .HasColumnType("INTEGER"); + + b.Property("LastModifiedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastModifiedOn") + .HasColumnType("TEXT"); + + b.Property("Notes") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PaidOn") + .HasColumnType("TEXT"); + + b.Property("PaymentMethod") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PaymentNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId"); + + b.HasIndex("InvoiceId"); + + b.HasIndex("OrganizationId", "PaymentNumber") + .IsUnique() + .HasDatabaseName("IX_Payment_OrgId_PaymentNumber"); + + b.ToTable("Payments"); + }); + + modelBuilder.Entity("Nine.Core.Entities.Property", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Address") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Bathrooms") + .HasMaxLength(3) + .HasColumnType("decimal(3,1)"); + + b.Property("Bedrooms") + .HasMaxLength(3) + .HasColumnType("INTEGER"); + + b.Property("City") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsSampleData") + .HasColumnType("INTEGER"); + + b.Property("LastModifiedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastModifiedOn") + .HasColumnType("TEXT"); + + b.Property("LastRoutineInspectionDate") + .HasColumnType("TEXT"); + + b.Property("MonthlyRent") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("NextRoutineInspectionDueDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PropertyType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("RoutineInspectionIntervalMonths") + .HasColumnType("INTEGER"); + + b.Property("SquareFeet") + .HasMaxLength(7) + .HasColumnType("INTEGER"); + + b.Property("State") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UnitNumber") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ZipCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Address"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Properties"); + }); + + modelBuilder.Entity("Nine.Core.Entities.ProspectiveTenant", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("DateOfBirth") + .HasColumnType("TEXT"); + + b.Property("DesiredMoveInDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("FirstContactedOn") + .HasColumnType("TEXT"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IdentificationNumber") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IdentificationState") + .HasMaxLength(2) + .HasColumnType("TEXT"); + + b.Property("InterestedPropertyId") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsSampleData") + .HasColumnType("INTEGER"); + + b.Property("LastModifiedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastModifiedOn") + .HasColumnType("TEXT"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Phone") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Source") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("InterestedPropertyId"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("Status"); + + b.ToTable("ProspectiveTenants"); + }); + + modelBuilder.Entity("Nine.Core.Entities.RentalApplication", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApplicationFee") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ApplicationFeePaid") + .HasColumnType("INTEGER"); + + b.Property("ApplicationFeePaidOn") + .HasColumnType("TEXT"); + + b.Property("ApplicationFeePaymentMethod") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("AppliedOn") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("CurrentAddress") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("CurrentCity") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CurrentRent") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("TEXT"); + + b.Property("CurrentZipCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("DecidedOn") + .HasColumnType("TEXT"); + + b.Property("DecisionBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DenialReason") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("EmployerName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("EmploymentLengthMonths") + .HasColumnType("INTEGER"); + + b.Property("ExpiresOn") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsSampleData") + .HasColumnType("INTEGER"); + + b.Property("JobTitle") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LandlordName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("LandlordPhone") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("LastModifiedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastModifiedOn") + .HasColumnType("TEXT"); + + b.Property("MonthlyIncome") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PropertyId") + .HasColumnType("TEXT"); + + b.Property("ProspectiveTenantId") + .HasColumnType("TEXT"); + + b.Property("Reference1Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Reference1Phone") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Reference1Relationship") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Reference2Name") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Reference2Phone") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Reference2Relationship") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppliedOn"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("PropertyId"); + + b.HasIndex("ProspectiveTenantId"); + + b.HasIndex("Status"); + + b.ToTable("RentalApplications"); + }); + + modelBuilder.Entity("Nine.Core.Entities.Repair", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CompletedOn") + .HasColumnType("TEXT"); + + b.Property("ContactId") + .HasColumnType("TEXT"); + + b.Property("ContactPerson") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ContractorId") + .HasColumnType("TEXT"); + + b.Property("ContractorName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ContractorPhone") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Cost") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DurationMinutes") + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsSampleData") + .HasColumnType("INTEGER"); + + b.Property("LastModifiedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastModifiedOn") + .HasColumnType("TEXT"); + + b.Property("LeaseId") + .HasColumnType("TEXT"); + + b.Property("MaintenanceRequestId") + .HasColumnType("TEXT"); + + b.Property("Notes") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PartsReplaced") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PropertyId") + .HasColumnType("TEXT"); + + b.Property("RepairType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("WarrantyApplies") + .HasColumnType("INTEGER"); + + b.Property("WarrantyExpiresOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CompletedOn"); + + b.HasIndex("LeaseId"); + + b.HasIndex("MaintenanceRequestId"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("PropertyId"); + + b.HasIndex("RepairType"); + + b.ToTable("Repairs"); + }); + + modelBuilder.Entity("Nine.Core.Entities.SchemaVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppliedOn") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SchemaVersions"); + }); + + modelBuilder.Entity("Nine.Core.Entities.SecurityDeposit", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("DateReceived") + .HasColumnType("TEXT"); + + b.Property("DeductionsAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("DeductionsReason") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("InInvestmentPool") + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsSampleData") + .HasColumnType("INTEGER"); + + b.Property("LastModifiedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastModifiedOn") + .HasColumnType("TEXT"); + + b.Property("LeaseId") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PaymentMethod") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PoolEntryDate") + .HasColumnType("TEXT"); + + b.Property("PoolExitDate") + .HasColumnType("TEXT"); + + b.Property("RefundAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("RefundMethod") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("RefundProcessedDate") + .HasColumnType("TEXT"); + + b.Property("RefundReference") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.Property("TransactionReference") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("InInvestmentPool"); + + b.HasIndex("LeaseId") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("TenantId"); + + b.ToTable("SecurityDeposits"); + }); + + modelBuilder.Entity("Nine.Core.Entities.SecurityDepositDividend", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BaseDividendAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ChoiceMadeOn") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("DividendAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("InvestmentPoolId") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsSampleData") + .HasColumnType("INTEGER"); + + b.Property("LastModifiedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastModifiedOn") + .HasColumnType("TEXT"); + + b.Property("LeaseId") + .HasColumnType("TEXT"); + + b.Property("MailingAddress") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("MonthsInPool") + .HasColumnType("INTEGER"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PaymentMethod") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PaymentProcessedOn") + .HasColumnType("TEXT"); + + b.Property("PaymentReference") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ProrationFactor") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("SecurityDepositId") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("InvestmentPoolId"); + + b.HasIndex("LeaseId"); + + b.HasIndex("SecurityDepositId"); + + b.HasIndex("Status"); + + b.HasIndex("TenantId"); + + b.HasIndex("Year"); + + b.ToTable("SecurityDepositDividends"); + }); + + modelBuilder.Entity("Nine.Core.Entities.SecurityDepositInvestmentPool", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActiveLeaseCount") + .HasColumnType("INTEGER"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("DividendPerLease") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("DividendsCalculatedOn") + .HasColumnType("TEXT"); + + b.Property("DividendsDistributedOn") + .HasColumnType("TEXT"); + + b.Property("EndingBalance") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsSampleData") + .HasColumnType("INTEGER"); + + b.Property("LastModifiedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastModifiedOn") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("OrganizationShare") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("OrganizationSharePercentage") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("ReturnRate") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("StartingBalance") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("TenantShareTotal") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("TotalEarnings") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("Status"); + + b.HasIndex("Year") + .IsUnique(); + + b.ToTable("SecurityDepositInvestmentPools"); + }); + + modelBuilder.Entity("Nine.Core.Entities.Tenant", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("DateOfBirth") + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("EmergencyContactName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("EmergencyContactPhone") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IdentificationNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsSampleData") + .HasColumnType("INTEGER"); + + b.Property("LastModifiedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastModifiedOn") + .HasColumnType("TEXT"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Notes") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("ProspectiveTenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("IdentificationNumber") + .IsUnique(); + + b.HasIndex("OrganizationId"); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("Nine.Core.Entities.Tour", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CalendarEventId") + .HasColumnType("TEXT"); + + b.Property("ChecklistId") + .HasColumnType("TEXT"); + + b.Property("ConductedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("DurationMinutes") + .HasColumnType("INTEGER"); + + b.Property("Feedback") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("InterestLevel") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsSampleData") + .HasColumnType("INTEGER"); + + b.Property("LastModifiedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastModifiedOn") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PropertyId") + .HasColumnType("TEXT"); + + b.Property("ProspectiveTenantId") + .HasColumnType("TEXT"); + + b.Property("ScheduledOn") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChecklistId"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("PropertyId"); + + b.HasIndex("ProspectiveTenantId"); + + b.HasIndex("ScheduledOn"); + + b.HasIndex("Status"); + + b.ToTable("Tours"); + }); + + modelBuilder.Entity("Nine.Core.Entities.UserProfile", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActiveOrganizationId") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsSampleData") + .HasColumnType("INTEGER"); + + b.Property("LastModifiedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastModifiedOn") + .HasColumnType("TEXT"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ActiveOrganizationId"); + + b.HasIndex("Email"); + + b.HasIndex("IsDeleted"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("UserProfiles"); + }); + + modelBuilder.Entity("Nine.Core.Entities.WorkflowAuditLog", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Action") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("TEXT"); + + b.Property("EntityType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FromStatus") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsSampleData") + .HasColumnType("INTEGER"); + + b.Property("LastModifiedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastModifiedOn") + .HasColumnType("TEXT"); + + b.Property("Metadata") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PerformedBy") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PerformedOn") + .HasColumnType("TEXT"); + + b.Property("Reason") + .HasColumnType("TEXT"); + + b.Property("ToStatus") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("EntityId"); + + b.HasIndex("EntityType"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("PerformedBy"); + + b.HasIndex("PerformedOn"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("WorkflowAuditLogs"); + }); + + modelBuilder.Entity("Notification", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("EmailError") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("EmailSent") + .HasColumnType("INTEGER"); + + b.Property("EmailSentOn") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsRead") + .HasColumnType("INTEGER"); + + b.Property("IsSampleData") + .HasColumnType("INTEGER"); + + b.Property("LastModifiedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastModifiedOn") + .HasColumnType("TEXT"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("ReadOn") + .HasColumnType("TEXT"); + + b.Property("RecipientUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RelatedEntityId") + .HasColumnType("TEXT"); + + b.Property("RelatedEntityType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("SMSError") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SMSSent") + .HasColumnType("INTEGER"); + + b.Property("SMSSentOn") + .HasColumnType("TEXT"); + + b.Property("SendEmail") + .HasColumnType("INTEGER"); + + b.Property("SendInApp") + .HasColumnType("INTEGER"); + + b.Property("SendSMS") + .HasColumnType("INTEGER"); + + b.Property("SentOn") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Category"); + + b.HasIndex("IsRead"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("RecipientUserId"); + + b.HasIndex("SentOn"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("Nine.Core.Entities.ApplicationScreening", b => + { + b.HasOne("Nine.Core.Entities.RentalApplication", "RentalApplication") + .WithOne("Screening") + .HasForeignKey("Nine.Core.Entities.ApplicationScreening", "RentalApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("RentalApplication"); + }); + + modelBuilder.Entity("Nine.Core.Entities.CalendarEvent", b => + { + b.HasOne("Nine.Core.Entities.Property", "Property") + .WithMany() + .HasForeignKey("PropertyId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Property"); + }); + + modelBuilder.Entity("Nine.Core.Entities.Checklist", b => + { + b.HasOne("Nine.Core.Entities.ChecklistTemplate", "ChecklistTemplate") + .WithMany("Checklists") + .HasForeignKey("ChecklistTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Nine.Core.Entities.Document", "Document") + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Nine.Core.Entities.Lease", "Lease") + .WithMany() + .HasForeignKey("LeaseId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Nine.Core.Entities.Property", "Property") + .WithMany() + .HasForeignKey("PropertyId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("ChecklistTemplate"); + + b.Navigation("Document"); + + b.Navigation("Lease"); + + b.Navigation("Property"); + }); + + modelBuilder.Entity("Nine.Core.Entities.ChecklistItem", b => + { + b.HasOne("Nine.Core.Entities.Checklist", "Checklist") + .WithMany("Items") + .HasForeignKey("ChecklistId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Checklist"); + }); + + modelBuilder.Entity("Nine.Core.Entities.ChecklistTemplateItem", b => + { + b.HasOne("Nine.Core.Entities.ChecklistTemplate", "ChecklistTemplate") + .WithMany("Items") + .HasForeignKey("ChecklistTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ChecklistTemplate"); + }); + + modelBuilder.Entity("Nine.Core.Entities.Document", b => + { + b.HasOne("Nine.Core.Entities.Invoice", "Invoice") + .WithMany() + .HasForeignKey("InvoiceId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Nine.Core.Entities.Lease", "Lease") + .WithMany("Documents") + .HasForeignKey("LeaseId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Nine.Core.Entities.Organization", null) + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Nine.Core.Entities.Payment", "Payment") + .WithMany() + .HasForeignKey("PaymentId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Nine.Core.Entities.Property", "Property") + .WithMany("Documents") + .HasForeignKey("PropertyId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Nine.Core.Entities.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Invoice"); + + b.Navigation("Lease"); + + b.Navigation("Payment"); + + b.Navigation("Property"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Nine.Core.Entities.Inspection", b => + { + b.HasOne("Nine.Core.Entities.Document", "Document") + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Nine.Core.Entities.Lease", "Lease") + .WithMany() + .HasForeignKey("LeaseId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Nine.Core.Entities.Property", "Property") + .WithMany() + .HasForeignKey("PropertyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Document"); + + b.Navigation("Lease"); + + b.Navigation("Property"); + }); + + modelBuilder.Entity("Nine.Core.Entities.Invoice", b => + { + b.HasOne("Nine.Core.Entities.Document", "Document") + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Nine.Core.Entities.Lease", "Lease") + .WithMany("Invoices") + .HasForeignKey("LeaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Nine.Core.Entities.Organization", null) + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Document"); + + b.Navigation("Lease"); + }); + + modelBuilder.Entity("Nine.Core.Entities.Lease", b => + { + b.HasOne("Nine.Core.Entities.Document", "Document") + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Nine.Core.Entities.Organization", null) + .WithMany("Leases") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Nine.Core.Entities.Property", "Property") + .WithMany("Leases") + .HasForeignKey("PropertyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Nine.Core.Entities.Tenant", "Tenant") + .WithMany("Leases") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Document"); + + b.Navigation("Property"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Nine.Core.Entities.LeaseOffer", b => + { + b.HasOne("Nine.Core.Entities.Property", "Property") + .WithMany() + .HasForeignKey("PropertyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Nine.Core.Entities.ProspectiveTenant", "ProspectiveTenant") + .WithMany() + .HasForeignKey("ProspectiveTenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Nine.Core.Entities.RentalApplication", "RentalApplication") + .WithMany() + .HasForeignKey("RentalApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Property"); + + b.Navigation("ProspectiveTenant"); + + b.Navigation("RentalApplication"); + }); + + modelBuilder.Entity("Nine.Core.Entities.MaintenanceRequest", b => + { + b.HasOne("Nine.Core.Entities.Lease", "Lease") + .WithMany() + .HasForeignKey("LeaseId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Nine.Core.Entities.Property", "Property") + .WithMany("MaintenanceRequests") + .HasForeignKey("PropertyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Lease"); + + b.Navigation("Property"); + }); + + modelBuilder.Entity("Nine.Core.Entities.NotificationPreferences", b => + { + b.HasOne("Nine.Core.Entities.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Nine.Core.Entities.OrganizationEmailSettings", b => + { + b.HasOne("Nine.Core.Entities.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Nine.Core.Entities.OrganizationSMSSettings", b => + { + b.HasOne("Nine.Core.Entities.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Nine.Core.Entities.OrganizationUser", b => + { + b.HasOne("Nine.Core.Entities.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Nine.Core.Entities.Payment", b => + { + b.HasOne("Nine.Core.Entities.Document", "Document") + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Nine.Core.Entities.Invoice", "Invoice") + .WithMany("Payments") + .HasForeignKey("InvoiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Nine.Core.Entities.Organization", null) + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Document"); + + b.Navigation("Invoice"); + }); + + modelBuilder.Entity("Nine.Core.Entities.Property", b => + { + b.HasOne("Nine.Core.Entities.Organization", null) + .WithMany("Properties") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + }); + + modelBuilder.Entity("Nine.Core.Entities.ProspectiveTenant", b => + { + b.HasOne("Nine.Core.Entities.Property", "InterestedProperty") + .WithMany() + .HasForeignKey("InterestedPropertyId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("InterestedProperty"); + }); + + modelBuilder.Entity("Nine.Core.Entities.RentalApplication", b => + { + b.HasOne("Nine.Core.Entities.Property", "Property") + .WithMany() + .HasForeignKey("PropertyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Nine.Core.Entities.ProspectiveTenant", "ProspectiveTenant") + .WithMany("Applications") + .HasForeignKey("ProspectiveTenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Property"); + + b.Navigation("ProspectiveTenant"); + }); + + modelBuilder.Entity("Nine.Core.Entities.Repair", b => + { + b.HasOne("Nine.Core.Entities.Lease", "Lease") + .WithMany() + .HasForeignKey("LeaseId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Nine.Core.Entities.MaintenanceRequest", "MaintenanceRequest") + .WithMany("Repairs") + .HasForeignKey("MaintenanceRequestId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Nine.Core.Entities.Property", "Property") + .WithMany("Repairs") + .HasForeignKey("PropertyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Lease"); + + b.Navigation("MaintenanceRequest"); + + b.Navigation("Property"); + }); + + modelBuilder.Entity("Nine.Core.Entities.SecurityDeposit", b => + { + b.HasOne("Nine.Core.Entities.Lease", "Lease") + .WithMany() + .HasForeignKey("LeaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Nine.Core.Entities.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Lease"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Nine.Core.Entities.SecurityDepositDividend", b => + { + b.HasOne("Nine.Core.Entities.SecurityDepositInvestmentPool", "InvestmentPool") + .WithMany("Dividends") + .HasForeignKey("InvestmentPoolId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Nine.Core.Entities.Lease", "Lease") + .WithMany() + .HasForeignKey("LeaseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Nine.Core.Entities.SecurityDeposit", "SecurityDeposit") + .WithMany("Dividends") + .HasForeignKey("SecurityDepositId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Nine.Core.Entities.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("InvestmentPool"); + + b.Navigation("Lease"); + + b.Navigation("SecurityDeposit"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Nine.Core.Entities.Tenant", b => + { + b.HasOne("Nine.Core.Entities.Organization", null) + .WithMany("Tenants") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + }); + + modelBuilder.Entity("Nine.Core.Entities.Tour", b => + { + b.HasOne("Nine.Core.Entities.Checklist", "Checklist") + .WithMany() + .HasForeignKey("ChecklistId"); + + b.HasOne("Nine.Core.Entities.Property", "Property") + .WithMany() + .HasForeignKey("PropertyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Nine.Core.Entities.ProspectiveTenant", "ProspectiveTenant") + .WithMany("Tours") + .HasForeignKey("ProspectiveTenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Checklist"); + + b.Navigation("Property"); + + b.Navigation("ProspectiveTenant"); + }); + + modelBuilder.Entity("Notification", b => + { + b.HasOne("Nine.Core.Entities.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Nine.Core.Entities.Checklist", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Nine.Core.Entities.ChecklistTemplate", b => + { + b.Navigation("Checklists"); + + b.Navigation("Items"); + }); + + modelBuilder.Entity("Nine.Core.Entities.Invoice", b => + { + b.Navigation("Payments"); + }); + + modelBuilder.Entity("Nine.Core.Entities.Lease", b => + { + b.Navigation("Documents"); + + b.Navigation("Invoices"); + }); + + modelBuilder.Entity("Nine.Core.Entities.MaintenanceRequest", b => + { + b.Navigation("Repairs"); + }); + + modelBuilder.Entity("Nine.Core.Entities.Organization", b => + { + b.Navigation("Leases"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Properties"); + + b.Navigation("Tenants"); + }); + + modelBuilder.Entity("Nine.Core.Entities.Property", b => + { + b.Navigation("Documents"); + + b.Navigation("Leases"); + + b.Navigation("MaintenanceRequests"); + + b.Navigation("Repairs"); + }); + + modelBuilder.Entity("Nine.Core.Entities.ProspectiveTenant", b => + { + b.Navigation("Applications"); + + b.Navigation("Tours"); + }); + + modelBuilder.Entity("Nine.Core.Entities.RentalApplication", b => + { + b.Navigation("Screening"); + }); + + modelBuilder.Entity("Nine.Core.Entities.SecurityDeposit", b => + { + b.Navigation("Dividends"); + }); + + modelBuilder.Entity("Nine.Core.Entities.SecurityDepositInvestmentPool", b => + { + b.Navigation("Dividends"); + }); + + modelBuilder.Entity("Nine.Core.Entities.Tenant", b => + { + b.Navigation("Leases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/1-Nine.Infrastructure/Data/Migrations/20260313122831_RenameIsAvailableToIsActive.cs b/1-Nine.Infrastructure/Data/Migrations/20260313122831_RenameIsAvailableToIsActive.cs new file mode 100644 index 0000000..a121066 --- /dev/null +++ b/1-Nine.Infrastructure/Data/Migrations/20260313122831_RenameIsAvailableToIsActive.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Nine.Infrastructure.Migrations +{ + /// + public partial class RenameIsAvailableToIsActive : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // SQLite 3.25.0+ supports RENAME COLUMN directly. + // EF Core's RenameColumn abstraction triggers a full table-rebuild path + // that throws NotSupportedException on SQLite, so we use raw SQL instead. + migrationBuilder.Sql("ALTER TABLE \"Properties\" RENAME COLUMN \"IsAvailable\" TO \"IsActive\";"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql("ALTER TABLE \"Properties\" RENAME COLUMN \"IsActive\" TO \"IsAvailable\";"); + } + } +} diff --git a/1-Nine.Infrastructure/Data/Migrations/ApplicationDbContextModelSnapshot.cs b/1-Nine.Infrastructure/Data/Migrations/ApplicationDbContextModelSnapshot.cs index fc40246..35dcc2b 100644 --- a/1-Nine.Infrastructure/Data/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/1-Nine.Infrastructure/Data/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1,9 +1,9 @@ // using System; -using Nine.Infrastructure.Data; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Nine.Infrastructure.Data; #nullable disable @@ -2633,7 +2633,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(1000) .HasColumnType("TEXT"); - b.Property("IsAvailable") + b.Property("IsActive") .HasColumnType("INTEGER"); b.Property("IsDeleted") diff --git a/1-Nine.Infrastructure/Data/SqlCipherConnectionInterceptor.cs b/1-Nine.Infrastructure/Data/SqlCipherConnectionInterceptor.cs index 6f543b0..1f7b79e 100644 --- a/1-Nine.Infrastructure/Data/SqlCipherConnectionInterceptor.cs +++ b/1-Nine.Infrastructure/Data/SqlCipherConnectionInterceptor.cs @@ -61,6 +61,19 @@ public override void ConnectionOpened(DbConnection connection, ConnectionEndEven } } + // Always set busy_timeout and WAL mode regardless of encryption. + // busy_timeout: converts indefinite lock-wait hangs into a 5-second timeout error. + // journal_mode = WAL: allows concurrent readers during writes; must be a persistent DB + // setting so it is set on every connection open (idempotent — SQLite ignores it if + // already WAL). Must run AFTER PRAGMA key for encrypted databases. + using (var walCmd = connection.CreateCommand()) + { + walCmd.CommandText = "PRAGMA busy_timeout = 5000;"; + walCmd.ExecuteNonQuery(); + walCmd.CommandText = "PRAGMA journal_mode = WAL;"; + walCmd.ExecuteNonQuery(); + } + base.ConnectionOpened(connection, eventData); } @@ -101,6 +114,15 @@ public override async Task ConnectionOpenedAsync(DbConnection connection, Connec } } + // Always set busy_timeout and WAL mode regardless of encryption (see sync overload above). + using (var walCmd = connection.CreateCommand()) + { + walCmd.CommandText = "PRAGMA busy_timeout = 5000;"; + await walCmd.ExecuteNonQueryAsync(cancellationToken); + walCmd.CommandText = "PRAGMA journal_mode = WAL;"; + await walCmd.ExecuteNonQueryAsync(cancellationToken); + } + await base.ConnectionOpenedAsync(connection, eventData, cancellationToken); } } diff --git a/1-Nine.Infrastructure/Services/DatabaseUnlockState.cs b/1-Nine.Infrastructure/Services/DatabaseUnlockState.cs index 88808fe..360c901 100644 --- a/1-Nine.Infrastructure/Services/DatabaseUnlockState.cs +++ b/1-Nine.Infrastructure/Services/DatabaseUnlockState.cs @@ -8,7 +8,38 @@ public class DatabaseUnlockState public bool NeedsUnlock { get; set; } public string? DatabasePath { get; set; } public string? ConnectionString { get; set; } - + + /// + /// True when an encrypted sibling DB was found during the version-scan but the keychain + /// had no key at startup. After the user provides their password it is stored in the + /// keychain, but a full process restart is required for startup to re-run the version-scan + /// copy and run pending migrations. A forceLoad (Blazor circuit reconnect) is not enough. + /// + public bool RequiresRestartAfterUnlock { get; set; } + + /// + /// The file path of the encrypted sibling DB that was found during version-scan. + /// Set when is true. The unlock page must verify + /// the user's password against this path rather than because + /// opening the non-existent target path with a password would cause SQLite to silently + /// create a fresh empty database there, poisoning the version-scan copy on the next launch. + /// + public string? EncryptedSiblingPath { get; set; } + + /// + /// True when the database era is outside the supported upgrade window (more than two + /// generations behind). The database has already been backed up to + /// before this flag is set. The user must + /// start fresh or use the import workflow; they cannot simply unlock and continue. + /// + public bool IsUnsupportedSchema { get; set; } + + /// + /// Full path of the backup copy created before the unsupported-schema condition was + /// detected. Displayed in the UI so the user knows their data is safe. + /// + public string? UnsupportedSchemaBackupPath { get; set; } + // Event to notify when unlock succeeds public event Action? OnUnlockSuccess; diff --git a/1-Nine.Infrastructure/Services/LinuxKeychainService.cs b/1-Nine.Infrastructure/Services/LinuxKeychainService.cs index 5301fd5..503122c 100644 --- a/1-Nine.Infrastructure/Services/LinuxKeychainService.cs +++ b/1-Nine.Infrastructure/Services/LinuxKeychainService.cs @@ -10,7 +10,7 @@ namespace Nine.Infrastructure.Services; /// public class LinuxKeychainService : IKeychainService { - private const string Schema = "org.aquiis.database"; + private const string Schema = "co.nineapp.database"; private const string KeyAttribute = "key-type"; private readonly string _keyValue; @@ -102,7 +102,7 @@ public bool StoreKey(string keyHex, string label = "Nine Database Encryption Key process.WaitForExit(5000); Console.WriteLine($"[LinuxKeychainService] secret-tool exit code: {process.ExitCode}"); - Console.WriteLine($"[LinuxKeychainService] secret-tool output: '{output}'"); + Console.WriteLine($"[LinuxKeychainService] secret-tool output: [{(string.IsNullOrWhiteSpace(output) ? "empty" : "received")}]"); if (!string.IsNullOrWhiteSpace(error)) { Console.WriteLine($"[LinuxKeychainService] secret-tool error: {error}"); diff --git a/1-Nine.Infrastructure/Services/WindowsKeychainService.cs b/1-Nine.Infrastructure/Services/WindowsKeychainService.cs index 9ec9d5f..63110a5 100644 --- a/1-Nine.Infrastructure/Services/WindowsKeychainService.cs +++ b/1-Nine.Infrastructure/Services/WindowsKeychainService.cs @@ -23,12 +23,12 @@ public class WindowsKeychainService : IKeychainService public WindowsKeychainService(string appName = "Nine-Electron") { var appDataPath = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); - var aquiisDir = Path.Combine(appDataPath, "Nine"); - Directory.CreateDirectory(aquiisDir); + var nineDir = Path.Combine(appDataPath, "Nine"); + Directory.CreateDirectory(nineDir); // Sanitize appName for use as a filename component var safeAppName = new string(appName.Where(c => char.IsLetterOrDigit(c) || c == '-' || c == '_').ToArray()); - _keyFilePath = Path.Combine(aquiisDir, $"aquiis_{safeAppName}.key"); + _keyFilePath = Path.Combine(nineDir, $"aquiis_{safeAppName}.key"); Console.WriteLine($"[WindowsKeychainService] Initialized with key file: {_keyFilePath}"); } @@ -73,7 +73,7 @@ public bool StoreKey(string password, string label = "Nine Database Encryption K var encryptedBytes = File.ReadAllBytes(_keyFilePath); var plainBytes = ProtectedData.Unprotect(encryptedBytes, null, DataProtectionScope.CurrentUser); var password = Encoding.UTF8.GetString(plainBytes); - Console.WriteLine($"[WindowsKeychainService] Password retrieved successfully using DPAPI (length: {password.Length})"); + Console.WriteLine($"[WindowsKeychainService] Password retrieved successfully using DPAPI. Password status: {(string.IsNullOrEmpty(password) ? "empty" : "received")}"); return password; } catch (CryptographicException ex) diff --git a/2-Nine.Application/Models/DTOs/DatabasePreviewDTOs.cs b/2-Nine.Application/Models/DTOs/DatabasePreviewDTOs.cs index c4c685f..09c8575 100644 --- a/2-Nine.Application/Models/DTOs/DatabasePreviewDTOs.cs +++ b/2-Nine.Application/Models/DTOs/DatabasePreviewDTOs.cs @@ -10,10 +10,31 @@ public class DatabasePreviewData public int LeaseCount { get; set; } public int InvoiceCount { get; set; } public int PaymentCount { get; set; } - + public int MaintenanceCount { get; set; } + public int RepairCount { get; set; } + public int DocumentCount { get; set; } + public int CalendarEventCount { get; set; } + public int InspectionCount { get; set; } + public int ChecklistCount { get; set; } + public int ChecklistItemCount { get; set; } + public int SecurityDepositCount { get; set; } + public int NoteCount { get; set; } + public int NotificationCount { get; set; } + public List Properties { get; set; } = new(); public List Tenants { get; set; } = new(); public List Leases { get; set; } = new(); + public List Invoices { get; set; } = new(); + public List Payments { get; set; } = new(); + public List MaintenanceRequests { get; set; } = new(); + public List Repairs { get; set; } = new(); + public List CalendarEvents { get; set; } = new(); + public List Inspections { get; set; } = new(); + public List Checklists { get; set; } = new(); + public List ChecklistItems { get; set; } = new(); + public List SecurityDeposits { get; set; } = new(); + public List Notes { get; set; } = new(); + public List Notifications { get; set; } = new(); } /// @@ -60,6 +81,191 @@ public class LeasePreview public string Status { get; set; } = string.Empty; } +/// +/// DTO for invoice preview in read-only database view +/// +public class InvoicePreview +{ + public Guid Id { get; set; } + public string InvoiceNumber { get; set; } = string.Empty; + public string PropertyAddress { get; set; } = string.Empty; + public string TenantName { get; set; } = string.Empty; + public DateTime DueOn { get; set; } + public decimal Amount { get; set; } + public string Status { get; set; } = string.Empty; +} + +/// +/// DTO for payment preview in read-only database view +/// +public class PaymentPreview +{ + public Guid Id { get; set; } + public string PaymentNumber { get; set; } = string.Empty; + public string InvoiceNumber { get; set; } = string.Empty; + public DateTime PaidOn { get; set; } + public decimal Amount { get; set; } + public string PaymentMethod { get; set; } = string.Empty; +} + +/// +/// DTO for maintenance request preview in read-only database view +/// +public class MaintenancePreview +{ + public Guid Id { get; set; } + public string Title { get; set; } = string.Empty; + public string PropertyAddress { get; set; } = string.Empty; + public string RequestType { get; set; } = string.Empty; + public string Priority { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public DateTime RequestedOn { get; set; } +} + +/// +/// DTO for repair preview in read-only database view +/// +public class RepairPreview +{ + public Guid Id { get; set; } + public string PropertyAddress { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public string RepairType { get; set; } = string.Empty; + public DateTime? CompletedOn { get; set; } + public decimal Cost { get; set; } +} + +/// +/// DTO for calendar event preview in read-only database view +/// +public class CalendarEventPreview +{ + public Guid Id { get; set; } + public string Title { get; set; } = string.Empty; + public DateTime StartOn { get; set; } + public string EventType { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; +} + +/// +/// DTO for inspection preview in read-only database view +/// +public class InspectionPreview +{ + public Guid Id { get; set; } + public string PropertyAddress { get; set; } = string.Empty; + public DateTime CompletedOn { get; set; } + public string InspectionType { get; set; } = string.Empty; + public string? InspectedBy { get; set; } +} + +/// +/// DTO for checklist preview in read-only database view +/// +public class ChecklistPreview +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public string ChecklistType { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; +} + +/// +/// DTO for checklist item preview in read-only database view +/// +public class ChecklistItemPreview +{ + public Guid Id { get; set; } + public Guid ChecklistId { get; set; } + public string ItemText { get; set; } = string.Empty; + public int ItemOrder { get; set; } +} + +/// +/// DTO for security deposit preview in read-only database view +/// +public class SecurityDepositPreview +{ + public Guid Id { get; set; } + public string TenantName { get; set; } = string.Empty; + public decimal Amount { get; set; } + public DateTime DateReceived { get; set; } + public string PaymentMethod { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; +} + +/// +/// DTO for note preview in read-only database view +/// +public class NotePreview +{ + public Guid Id { get; set; } + public string EntityType { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; + public DateTime? CreatedOn { get; set; } +} + +/// +/// DTO for notification preview in read-only database view +/// +public class NotificationPreview +{ + public Guid Id { get; set; } + public string Title { get; set; } = string.Empty; + public string Type { get; set; } = string.Empty; + public string Category { get; set; } = string.Empty; + public DateTime SentOn { get; set; } +} + +/// +/// Represents a non-backup database file found in the data directory (e.g. an older versioned DB) +/// +public class OtherDatabaseFile +{ + public string FileName { get; set; } = string.Empty; + public string FilePath { get; set; } = string.Empty; + public long FileSizeBytes { get; set; } + public string FileSizeFormatted => FormatBytes(FileSizeBytes); + public DateTime LastModified { get; set; } + + private static string FormatBytes(long bytes) + { + if (bytes < 1024) return $"{bytes} B"; + if (bytes < 1_048_576) return $"{bytes / 1024.0:F1} KB"; + return $"{bytes / 1_048_576.0:F1} MB"; + } +} + +/// +/// Result of a data import operation from a preview database +/// +public class ImportResult +{ + public bool Success { get; set; } + public string Message { get; set; } = string.Empty; + public int PropertiesImported { get; set; } + public int TenantsImported { get; set; } + public int LeasesImported { get; set; } + public int InvoicesImported { get; set; } + public int PaymentsImported { get; set; } + public int MaintenanceRequestsImported { get; set; } + public int RepairsImported { get; set; } + public int DocumentsImported { get; set; } + public int CalendarEventsImported { get; set; } + public int InspectionsImported { get; set; } + public int ChecklistsImported { get; set; } + public int ChecklistItemsImported { get; set; } + public int SecurityDepositsImported { get; set; } + public int NotesImported { get; set; } + public int NotificationsImported { get; set; } + public List Errors { get; set; } = new(); + + public int TotalImported => PropertiesImported + TenantsImported + LeasesImported + + InvoicesImported + PaymentsImported + MaintenanceRequestsImported + RepairsImported + DocumentsImported + + CalendarEventsImported + InspectionsImported + ChecklistsImported + ChecklistItemsImported + + SecurityDepositsImported + NotesImported + NotificationsImported; +} + /// /// Result object for database operations /// @@ -67,10 +273,10 @@ public class DatabaseOperationResult { public bool Success { get; set; } public string Message { get; set; } = string.Empty; - + public static DatabaseOperationResult SuccessResult(string message = "Operation successful") => new() { Success = true, Message = message }; - + public static DatabaseOperationResult FailureResult(string message) => new() { Success = false, Message = message }; } diff --git a/2-Nine.Application/Services/BaseService.cs b/2-Nine.Application/Services/BaseService.cs index ecc713e..0e52c0a 100644 --- a/2-Nine.Application/Services/BaseService.cs +++ b/2-Nine.Application/Services/BaseService.cs @@ -133,7 +133,10 @@ public virtual async Task CreateAsync(TEntity entity) var userId = await _userContext.GetUserIdAsync(); if (string.IsNullOrEmpty(userId)) { - throw new UnauthorizedAccessException("User is not authenticated."); + if (entity.CreatedBy == ApplicationConstants.SystemUser.Id) + userId = entity.CreatedBy; // Allow system-created records to specify "System" as creator + else + throw new UnauthorizedAccessException("User is not authenticated."); } var organizationId = await _userContext.GetActiveOrganizationIdAsync(); @@ -188,7 +191,10 @@ public virtual async Task UpdateAsync(TEntity entity) var userId = await _userContext.GetUserIdAsync(); if (string.IsNullOrEmpty(userId)) { - throw new UnauthorizedAccessException("User is not authenticated."); + if (entity.LastModifiedBy == ApplicationConstants.SystemUser.Id) + userId = ApplicationConstants.SystemUser.Id; + else + throw new UnauthorizedAccessException("User is not authenticated."); } var organizationId = await _userContext.GetActiveOrganizationIdAsync(); diff --git a/2-Nine.Application/Services/DatabasePreviewService.cs b/2-Nine.Application/Services/DatabasePreviewService.cs index e8eda46..41c128b 100644 --- a/2-Nine.Application/Services/DatabasePreviewService.cs +++ b/2-Nine.Application/Services/DatabasePreviewService.cs @@ -1,5 +1,7 @@ using Nine.Application.Models.DTOs; -using Nine.Core.Entities; +using Nine.Core.Constants; +using Nine.Core.Interfaces; +using Nine.Core.Interfaces.Services; using Nine.Infrastructure.Data; using Nine.Infrastructure.Interfaces; using Microsoft.Data.Sqlite; @@ -9,79 +11,103 @@ namespace Nine.Application.Services; /// -/// Service for previewing backup databases in read-only mode. -/// Allows viewing database contents without overwriting active database. +/// Service for previewing backup databases in read-only mode and importing data from them. +/// Allows viewing database contents without overwriting the active database. /// public class DatabasePreviewService { + private readonly IPathService _pathService; private readonly IKeychainService _keychain; + private readonly ApplicationDbContext _activeContext; + private readonly IUserContextService _userContext; private readonly ILogger _logger; - private readonly string _backupDirectory; public DatabasePreviewService( + IPathService pathService, IKeychainService keychain, + ApplicationDbContext activeContext, + IUserContextService userContext, ILogger logger) { + _pathService = pathService; _keychain = keychain; + _activeContext = activeContext; + _userContext = userContext; _logger = logger; - - // Determine backup directory - use standard Data folder - var dataPath = Path.Combine(Directory.GetCurrentDirectory(), "Data"); - _backupDirectory = Path.Combine(dataPath, "Backups"); } + // ------------------------------------------------------------------------- + // Path helpers + // ------------------------------------------------------------------------- + + private async Task GetBackupDirectoryAsync() + { + var dbPath = await _pathService.GetDatabasePathAsync(); + return Path.Combine(Path.GetDirectoryName(dbPath)!, "Backups"); + } + + private async Task GetDataDirectoryAsync() + { + var dbPath = await _pathService.GetDatabasePathAsync(); + return Path.GetDirectoryName(dbPath)!; + } + + private async Task GetBackupFilePathAsync(string backupFileName) + { + // Security: prevent path traversal + var safeFileName = Path.GetFileName(backupFileName); + var backupPath = Path.Combine(await GetBackupDirectoryAsync(), safeFileName); + if (File.Exists(backupPath)) + return backupPath; + + // Fall back to data directory (e.g. app_v1.0.0.db living alongside the active DB) + var dataPath = Path.Combine(await GetDataDirectoryAsync(), safeFileName); + return dataPath; + } + + // ------------------------------------------------------------------------- + // Encryption helpers + // ------------------------------------------------------------------------- + /// /// Check if a backup database file is encrypted /// public async Task IsDatabaseEncryptedAsync(string backupFileName) { - var backupPath = GetBackupFilePath(backupFileName); - + var backupPath = await GetBackupFilePathAsync(backupFileName); + if (!File.Exists(backupPath)) { - _logger.LogWarning($"Backup file not found: {backupPath}"); + _logger.LogWarning("Backup file not found: {Path}", backupPath); return false; } try { - // Try to open without password using var conn = new SqliteConnection($"Data Source={backupPath}"); await conn.OpenAsync(); using var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT count(*) FROM sqlite_master;"; await cmd.ExecuteScalarAsync(); - return false; // Opened successfully = not encrypted + return false; } - catch (SqliteException ex) + catch (SqliteException ex) when ( + ex.Message.Contains("file is not a database") || + ex.Message.Contains("file is encrypted") || + ex.SqliteErrorCode == 26) { - // SQLCipher error codes indicate encryption - if (ex.Message.Contains("file is not a database") || - ex.Message.Contains("file is encrypted") || - ex.SqliteErrorCode == 26) // SQLITE_NOTADB - { - _logger.LogInformation($"Backup database {backupFileName} is encrypted"); - return true; - } - - // Some other error - _logger.LogError(ex, $"Error checking encryption status: {ex.Message}"); - throw; + _logger.LogInformation("Backup database {FileName} is encrypted", backupFileName); + return true; } } /// - /// Try to get password from keychain (Linux only) + /// Try to get the database password from the keychain /// public async Task TryGetKeychainPasswordAsync() { - await Task.CompletedTask; // Keep method async - var key = _keychain.RetrieveKey(); - if (key != null) - { - _logger.LogInformation("Retrieved encryption key from keychain"); - } - return key; + await Task.CompletedTask; + return _keychain.RetrieveKey(); } /// @@ -89,172 +115,1104 @@ public async Task IsDatabaseEncryptedAsync(string backupFileName) /// public async Task VerifyPasswordAsync(string backupFileName, string password) { - var backupPath = GetBackupFilePath(backupFileName); + var backupPath = await GetBackupFilePathAsync(backupFileName); try { using var conn = new SqliteConnection($"Data Source={backupPath}"); await conn.OpenAsync(); - - // Apply encryption key using (var cmd = conn.CreateCommand()) { cmd.CommandText = $"PRAGMA key = '{password}';"; await cmd.ExecuteNonQueryAsync(); } - - // Test if we can read the database using (var cmd = conn.CreateCommand()) { cmd.CommandText = "SELECT count(*) FROM sqlite_master;"; await cmd.ExecuteScalarAsync(); } - return DatabaseOperationResult.SuccessResult("Password verified successfully"); } - catch (SqliteException ex) + catch (SqliteException) { - _logger.LogWarning($"Password verification failed: {ex.Message}"); return DatabaseOperationResult.FailureResult("Incorrect password"); } catch (Exception ex) { - _logger.LogError(ex, $"Error verifying password: {ex.Message}"); + _logger.LogError(ex, "Error verifying password for {FileName}", backupFileName); return DatabaseOperationResult.FailureResult($"Error: {ex.Message}"); } } /// - /// Save password to keychain (overwrites existing) + /// Save database password to the OS keychain /// public async Task SavePasswordToKeychainAsync(string password) { - if (!OperatingSystem.IsLinux()) + await Task.CompletedTask; + _keychain.StoreKey(password, "Nine Database Password"); + _logger.LogInformation("Password saved to keychain"); + } + + // ------------------------------------------------------------------------- + // Other DB file discovery + // ------------------------------------------------------------------------- + + /// + /// Returns database files found in the data directory that are NOT backups and NOT the active DB. + /// These are typically versioned databases from previous app versions (e.g. app_v1.0.0.db). + /// + public async Task> GetOtherDatabaseFilesAsync() + { + var results = new List(); + var dataDir = await GetDataDirectoryAsync(); + var activeDbPath = await _pathService.GetDatabasePathAsync(); + + if (!Directory.Exists(dataDir)) + return results; + + foreach (var filePath in Directory.GetFiles(dataDir, "*.db")) { - _logger.LogWarning("Keychain storage only supported on Linux"); - return; + // Skip the active database + if (string.Equals(filePath, activeDbPath, StringComparison.OrdinalIgnoreCase)) + continue; + + var info = new FileInfo(filePath); + results.Add(new OtherDatabaseFile + { + FileName = info.Name, + FilePath = filePath, + FileSizeBytes = info.Length, + LastModified = info.LastWriteTime + }); } - await Task.CompletedTask; // Make method async - _keychain.StoreKey(password, "Nine Database Password"); - _logger.LogInformation("Password saved to keychain"); + return results.OrderByDescending(f => f.LastModified).ToList(); } /// - /// Get preview data from backup database + /// Copies a database file from the data directory into the Backups folder so it can be + /// previewed and imported via the standard backup workflow. /// - public async Task GetPreviewDataAsync(string backupFileName, string? password) + public async Task AddToBackupsAsync(string sourceFilePath) { - var backupPath = GetBackupFilePath(backupFileName); - - if (!File.Exists(backupPath)) + try { - throw new FileNotFoundException($"Backup file not found: {backupFileName}"); + var safeFileName = Path.GetFileName(sourceFilePath); + if (!File.Exists(sourceFilePath)) + return DatabaseOperationResult.FailureResult($"File not found: {safeFileName}"); + + var backupDir = await GetBackupDirectoryAsync(); + Directory.CreateDirectory(backupDir); + + var destPath = Path.Combine(backupDir, safeFileName); + if (File.Exists(destPath)) + return DatabaseOperationResult.SuccessResult($"{safeFileName} is already in backups."); + + File.Copy(sourceFilePath, destPath); + _logger.LogInformation("Added {FileName} to backups", safeFileName); + return DatabaseOperationResult.SuccessResult($"{safeFileName} added to backups."); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error adding file to backups"); + return DatabaseOperationResult.FailureResult($"Error: {ex.Message}"); } + } - // Build connection string - var connectionString = string.IsNullOrEmpty(password) - ? $"Data Source={backupPath}" - : $"Data Source={backupPath}"; + /// + /// Deletes a database file from disk. Works for both backup files and other database files. + /// Refuses to delete the currently active database. + /// + public async Task DeleteDatabaseFileAsync(string filePath) + { + try + { + var safeFileName = Path.GetFileName(filePath); + if (!File.Exists(filePath)) + return DatabaseOperationResult.FailureResult($"File not found: {safeFileName}"); - var options = new DbContextOptionsBuilder() - .UseSqlite(connectionString, sqliteOptions => - { - // Read-only mode - sqliteOptions.CommandTimeout(30); - }) - .Options; - - using var previewContext = new ApplicationDbContext(options); - - // Apply encryption key if password provided + // Safety: never delete the active database + var activeConnString = _activeContext.Database.GetConnectionString() ?? ""; + var activeDbPathRaw = activeConnString + .Replace("DataSource=", "", StringComparison.OrdinalIgnoreCase) + .Replace("Data Source=", "", StringComparison.OrdinalIgnoreCase) + .Split(';')[0] + .Trim(); + var activeDbPath = Path.GetFullPath(activeDbPathRaw); + + if (string.Equals(Path.GetFullPath(filePath), activeDbPath, StringComparison.OrdinalIgnoreCase)) + return DatabaseOperationResult.FailureResult("Cannot delete the active database."); + + File.Delete(filePath); + _logger.LogInformation("Deleted database file {FileName}", safeFileName); + return DatabaseOperationResult.SuccessResult($"{safeFileName} deleted."); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting database file"); + return DatabaseOperationResult.FailureResult($"Error: {ex.Message}"); + } + } + + // ------------------------------------------------------------------------- + // Schema compatibility - required columns per entity + // ------------------------------------------------------------------------- + // Required: if ANY of these is absent from the backup table, that entity is + // skipped entirely (counts as 0 imported, notes the gap). + // Tracking: CreatedOn/CreatedBy/LastModifiedOn/LastModifiedBy are preserved + // when present — so import history is maintained. + // Additive: columns added in newer schema versions (e.g. IsSampleData) are + // not in the intersection so they receive the active DB default. + // ------------------------------------------------------------------------- + + private static readonly HashSet PropertyRequiredCols = new(StringComparer.OrdinalIgnoreCase) + { "Id", "OrganizationId", "Address", "City", "State", "ZipCode", "PropertyType", "Status" }; + + private static readonly HashSet TenantRequiredCols = new(StringComparer.OrdinalIgnoreCase) + { "Id", "OrganizationId", "FirstName", "LastName", "Email" }; + + private static readonly HashSet LeaseRequiredCols = new(StringComparer.OrdinalIgnoreCase) + { "Id", "OrganizationId", "PropertyId", "TenantId", "StartDate", "EndDate", "MonthlyRent", "Status" }; + + private static readonly HashSet InvoiceRequiredCols = new(StringComparer.OrdinalIgnoreCase) + { "Id", "OrganizationId", "LeaseId", "InvoiceNumber", "DueOn", "Amount", "Status" }; + + private static readonly HashSet PaymentRequiredCols = new(StringComparer.OrdinalIgnoreCase) + { "Id", "OrganizationId", "InvoiceId", "PaymentNumber", "PaidOn", "Amount", "PaymentMethod" }; + + private static readonly HashSet MaintenanceRequiredCols = new(StringComparer.OrdinalIgnoreCase) + { "Id", "OrganizationId", "PropertyId", "Title", "RequestType", "Priority", "Status", "RequestedOn" }; + + private static readonly HashSet RepairRequiredCols = new(StringComparer.OrdinalIgnoreCase) + { "Id", "OrganizationId", "PropertyId", "Description" }; + + private static readonly HashSet DocumentRequiredCols = new(StringComparer.OrdinalIgnoreCase) + { "Id", "OrganizationId", "FileName", "FileData" }; + + private static readonly HashSet CalendarEventRequiredCols = new(StringComparer.OrdinalIgnoreCase) + { "Id", "OrganizationId", "Title", "StartOn", "EventType", "Status" }; + + private static readonly HashSet InspectionRequiredCols = new(StringComparer.OrdinalIgnoreCase) + { "Id", "OrganizationId", "PropertyId", "CompletedOn", "InspectionType" }; + + private static readonly HashSet ChecklistRequiredCols = new(StringComparer.OrdinalIgnoreCase) + { "Id", "OrganizationId", "Name", "ChecklistType", "Status", "ChecklistTemplateId" }; + + private static readonly HashSet ChecklistItemRequiredCols = new(StringComparer.OrdinalIgnoreCase) + { "Id", "OrganizationId", "ChecklistId", "ItemText", "ItemOrder" }; + + private static readonly HashSet SecurityDepositRequiredCols = new(StringComparer.OrdinalIgnoreCase) + { "Id", "OrganizationId", "LeaseId", "TenantId", "Amount", "DateReceived", "PaymentMethod", "Status" }; + + private static readonly HashSet NoteRequiredCols = new(StringComparer.OrdinalIgnoreCase) + { "Id", "OrganizationId", "Content", "EntityType", "EntityId" }; + + private static readonly HashSet NotificationRequiredCols = new(StringComparer.OrdinalIgnoreCase) + { "Id", "OrganizationId", "Title", "Message", "Type", "Category", "RecipientUserId", "SentOn" }; + + /// + /// Maps backup column names to their renamed equivalents in the current active schema. + /// Add an entry here whenever a column is renamed between eras. + /// Key = backup (old) name, Value = active (new) name. + /// + private static readonly Dictionary ColumnAliases = new(StringComparer.OrdinalIgnoreCase) + { + { "IsAvailable", "IsActive" } // Properties.IsAvailable renamed to IsActive in RenameIsAvailableToIsActive + }; + + // ------------------------------------------------------------------------- + // Schema helpers + // ------------------------------------------------------------------------- + + /// + /// Returns the set of column names for a table in the given connection. + /// + private static async Task> GetTableColumnsAsync( + SqliteConnection conn, string tableName, string schema = "main") + { + var cols = new HashSet(StringComparer.OrdinalIgnoreCase); + try + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = $"PRAGMA {schema}.table_info([{tableName}])"; + using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + cols.Add(r.GetString(1)); // column index 1 = name + } + catch { /* table absent in this version */ } + return cols; + } + + private static async Task CountTableAsync( + SqliteConnection conn, string table, HashSet cols) + { + try + { + using var cmd = conn.CreateCommand(); + var where = cols.Contains("IsDeleted") ? " WHERE IsDeleted = 0" : ""; + cmd.CommandText = $"SELECT COUNT(*) FROM [{table}]{where}"; + return Convert.ToInt32(await cmd.ExecuteScalarAsync()); + } + catch { return 0; } + } + + private static SqliteConnection OpenBackupConnection(string path, string? password) + { + var conn = new SqliteConnection($"Data Source={path}"); + conn.Open(); if (!string.IsNullOrEmpty(password)) { - using var conn = previewContext.Database.GetDbConnection(); - if (conn.State != System.Data.ConnectionState.Open) - await conn.OpenAsync(); - using var cmd = conn.CreateCommand(); cmd.CommandText = $"PRAGMA key = '{password}';"; - await cmd.ExecuteNonQueryAsync(); + cmd.ExecuteNonQuery(); } + return conn; + } + + // Reader helpers — raw SQLite stores Guids/dates as TEXT + + private static Guid ReadGuid(SqliteDataReader r, int i) + => Guid.TryParse(r.IsDBNull(i) ? null : r.GetString(i), out var g) ? g : Guid.Empty; + + private static DateTime ReadDateTime(SqliteDataReader r, int i) + => r.IsDBNull(i) ? DateTime.MinValue : DateTime.Parse(r.GetString(i)); + + private static string ReadStr(SqliteDataReader r, int i) + => r.IsDBNull(i) ? string.Empty : r.GetString(i); + + private static decimal ReadDecimal(SqliteDataReader r, int i) + => r.IsDBNull(i) ? 0m : r.GetDecimal(i); - // Load preview data - var previewData = new DatabasePreviewData + // ------------------------------------------------------------------------- + // Preview — raw SQL against backup, no EF model, schema-version tolerant + // ------------------------------------------------------------------------- + + /// + /// Loads a read-only preview from a backup database. + /// Uses raw SQL so it works across schema versions — only accesses columns + /// that are known to exist, falling back to defaults when columns are absent. + /// + public async Task GetPreviewDataAsync(string backupFileName, string? password) + { + var backupPath = await GetBackupFilePathAsync(backupFileName); + if (!File.Exists(backupPath)) + throw new FileNotFoundException($"Backup file not found: {backupFileName}"); + + using var conn = OpenBackupConnection(backupPath, password); + + // Discover what columns actually exist in this backup version + var propCols = await GetTableColumnsAsync(conn, "Properties"); + var tenCols = await GetTableColumnsAsync(conn, "Tenants"); + var leaseCols = await GetTableColumnsAsync(conn, "Leases"); + var invCols = await GetTableColumnsAsync(conn, "Invoices"); + var payCols = await GetTableColumnsAsync(conn, "Payments"); + var mntCols = await GetTableColumnsAsync(conn, "MaintenanceRequests"); + var repCols = await GetTableColumnsAsync(conn, "Repairs"); + var docCols = await GetTableColumnsAsync(conn, "Documents"); + var calCols = await GetTableColumnsAsync(conn, "CalendarEvents"); + var insCols = await GetTableColumnsAsync(conn, "Inspections"); + var clCols = await GetTableColumnsAsync(conn, "Checklists"); + var cliCols = await GetTableColumnsAsync(conn, "ChecklistItems"); + var sdCols = await GetTableColumnsAsync(conn, "SecurityDeposits"); + var noteCols = await GetTableColumnsAsync(conn, "Notes"); + var notiCols = await GetTableColumnsAsync(conn, "Notifications"); + + var data = new DatabasePreviewData { - PropertyCount = await previewContext.Properties.CountAsync(p => !p.IsDeleted), - TenantCount = await previewContext.Tenants.CountAsync(t => !t.IsDeleted), - LeaseCount = await previewContext.Leases.CountAsync(l => !l.IsDeleted), - InvoiceCount = await previewContext.Invoices.CountAsync(i => !i.IsDeleted), - PaymentCount = await previewContext.Payments.CountAsync(p => !p.IsDeleted) + PropertyCount = await CountTableAsync(conn, "Properties", propCols), + TenantCount = await CountTableAsync(conn, "Tenants", tenCols), + LeaseCount = await CountTableAsync(conn, "Leases", leaseCols), + InvoiceCount = await CountTableAsync(conn, "Invoices", invCols), + PaymentCount = await CountTableAsync(conn, "Payments", payCols), + MaintenanceCount = await CountTableAsync(conn, "MaintenanceRequests", mntCols), + RepairCount = await CountTableAsync(conn, "Repairs", repCols), + DocumentCount = await CountTableAsync(conn, "Documents", docCols), + CalendarEventCount = await CountTableAsync(conn, "CalendarEvents", calCols), + InspectionCount = await CountTableAsync(conn, "Inspections", insCols), + ChecklistCount = await CountTableAsync(conn, "Checklists", clCols), + ChecklistItemCount = await CountTableAsync(conn, "ChecklistItems", cliCols), + SecurityDepositCount = await CountTableAsync(conn, "SecurityDeposits", sdCols), + NoteCount = await CountTableAsync(conn, "Notes", noteCols), + NotificationCount = await CountTableAsync(conn, "Notifications", notiCols), }; - // Load detailed property data - previewData.Properties = await previewContext.Properties - .Where(p => !p.IsDeleted) - .OrderBy(p => p.Address) - .Take(100) // Limit to first 100 for performance - .Select(p => new PropertyPreview + if (propCols.IsSupersetOf(PropertyRequiredCols)) + data.Properties = await ReadPropertiesPreviewAsync(conn, propCols); + + if (tenCols.IsSupersetOf(TenantRequiredCols)) + data.Tenants = await ReadTenantsPreviewAsync(conn, tenCols); + + if (leaseCols.IsSupersetOf(LeaseRequiredCols)) + data.Leases = await ReadLeasesPreviewAsync(conn, leaseCols); + + if (invCols.IsSupersetOf(InvoiceRequiredCols)) + data.Invoices = await ReadInvoicesPreviewAsync(conn, invCols); + + if (payCols.IsSupersetOf(PaymentRequiredCols)) + data.Payments = await ReadPaymentsPreviewAsync(conn, payCols); + + if (mntCols.IsSupersetOf(MaintenanceRequiredCols)) + data.MaintenanceRequests = await ReadMaintenancePreviewAsync(conn, mntCols); + + if (repCols.IsSupersetOf(RepairRequiredCols)) + data.Repairs = await ReadRepairsPreviewAsync(conn, repCols); + + if (calCols.IsSupersetOf(CalendarEventRequiredCols)) + data.CalendarEvents = await ReadCalendarEventsPreviewAsync(conn, calCols); + + if (insCols.IsSupersetOf(InspectionRequiredCols)) + data.Inspections = await ReadInspectionsPreviewAsync(conn, insCols); + + if (clCols.IsSupersetOf(ChecklistRequiredCols)) + data.Checklists = await ReadChecklistsPreviewAsync(conn, clCols); + + if (cliCols.IsSupersetOf(ChecklistItemRequiredCols)) + data.ChecklistItems = await ReadChecklistItemsPreviewAsync(conn, cliCols); + + if (sdCols.IsSupersetOf(SecurityDepositRequiredCols)) + data.SecurityDeposits = await ReadSecurityDepositsPreviewAsync(conn, sdCols); + + if (noteCols.IsSupersetOf(NoteRequiredCols)) + data.Notes = await ReadNotesPreviewAsync(conn, noteCols); + + if (notiCols.IsSupersetOf(NotificationRequiredCols)) + data.Notifications = await ReadNotificationsPreviewAsync(conn, notiCols); + + _logger.LogInformation( + "Preview loaded from {File}: {P} props, {T} tenants, {L} leases, {I} invoices, {Pay} payments, {M} maintenance, {R} repairs, {Cal} calendar, {Ins} inspections, {Cl} checklists, {Sd} deposits, {N} notes, {Not} notifications", + backupFileName, + data.PropertyCount, data.TenantCount, data.LeaseCount, + data.InvoiceCount, data.PaymentCount, data.MaintenanceCount, data.RepairCount, + data.CalendarEventCount, data.InspectionCount, data.ChecklistCount, + data.SecurityDepositCount, data.NoteCount, data.NotificationCount); + + return data; + } + + private static async Task> ReadPropertiesPreviewAsync( + SqliteConnection conn, HashSet cols) + { + var list = new List(); + var del = cols.Contains("IsDeleted") ? " WHERE IsDeleted = 0" : ""; + var rent = cols.Contains("MonthlyRent") ? ", MonthlyRent" : ""; + using var cmd = conn.CreateCommand(); + cmd.CommandText = $"SELECT Id, Address, City, State, ZipCode, PropertyType, Status{rent} FROM [Properties]{del} ORDER BY Address LIMIT 100"; + using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + list.Add(new PropertyPreview + { + Id = ReadGuid(r, 0), + Address = ReadStr(r, 1), + City = ReadStr(r, 2), + State = ReadStr(r, 3), + ZipCode = ReadStr(r, 4), + PropertyType = ReadStr(r, 5), + Status = ReadStr(r, 6), + MonthlyRent = cols.Contains("MonthlyRent") ? ReadDecimal(r, 7) : 0m + }); + return list; + } + + private static async Task> ReadTenantsPreviewAsync( + SqliteConnection conn, HashSet cols) + { + var list = new List(); + var del = cols.Contains("IsDeleted") ? " WHERE IsDeleted = 0" : ""; + var phone = cols.Contains("PhoneNumber") ? ", PhoneNumber" : ""; + var created = cols.Contains("CreatedOn") ? ", CreatedOn" : ""; + using var cmd = conn.CreateCommand(); + cmd.CommandText = $"SELECT Id, FirstName, LastName, Email{phone}{created} FROM [Tenants]{del} ORDER BY LastName, FirstName LIMIT 100"; + using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + { + int idx = 4; + list.Add(new TenantPreview + { + Id = ReadGuid(r, 0), + FirstName = ReadStr(r, 1), + LastName = ReadStr(r, 2), + Email = ReadStr(r, 3), + Phone = cols.Contains("PhoneNumber") ? ReadStr(r, idx++) : string.Empty, + CreatedOn = cols.Contains("CreatedOn") ? ReadDateTime(r, idx++) : DateTime.MinValue + }); + } + return list; + } + + private static async Task> ReadLeasesPreviewAsync( + SqliteConnection conn, HashSet cols) + { + var list = new List(); + var del = cols.Contains("IsDeleted") ? " WHERE l.IsDeleted = 0" : ""; + using var cmd = conn.CreateCommand(); + // LEFT JOIN for display names — COALESCE guards against missing related rows + cmd.CommandText = $@" + SELECT l.Id, l.StartDate, l.EndDate, l.MonthlyRent, l.Status, + COALESCE(p.Address, 'Unknown') AS PropertyAddress, + COALESCE(t.FirstName || ' ' || t.LastName, 'Unknown') AS TenantName + FROM [Leases] l + LEFT JOIN [Properties] p ON l.PropertyId = p.Id + LEFT JOIN [Tenants] t ON l.TenantId = t.Id + {del} + ORDER BY l.StartDate DESC LIMIT 100"; + using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + list.Add(new LeasePreview + { + Id = ReadGuid(r, 0), + StartDate = ReadDateTime(r, 1), + EndDate = ReadDateTime(r, 2), + MonthlyRent = ReadDecimal(r, 3), + Status = ReadStr(r, 4), + PropertyAddress = ReadStr(r, 5), + TenantName = ReadStr(r, 6) + }); + return list; + } + + private static async Task> ReadInvoicesPreviewAsync( + SqliteConnection conn, HashSet cols) + { + var list = new List(); + var del = cols.Contains("IsDeleted") ? " WHERE i.IsDeleted = 0" : ""; + using var cmd = conn.CreateCommand(); + cmd.CommandText = $@" + SELECT i.Id, i.InvoiceNumber, i.DueOn, i.Amount, i.Status, + COALESCE(p.Address, 'Unknown') AS PropertyAddress, + COALESCE(t.FirstName || ' ' || t.LastName, 'Unknown') AS TenantName + FROM [Invoices] i + LEFT JOIN [Leases] l ON i.LeaseId = l.Id + LEFT JOIN [Properties] p ON l.PropertyId = p.Id + LEFT JOIN [Tenants] t ON l.TenantId = t.Id + {del} + ORDER BY i.DueOn DESC LIMIT 100"; + using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + list.Add(new InvoicePreview + { + Id = ReadGuid(r, 0), + InvoiceNumber = ReadStr(r, 1), + DueOn = ReadDateTime(r, 2), + Amount = ReadDecimal(r, 3), + Status = ReadStr(r, 4), + PropertyAddress = ReadStr(r, 5), + TenantName = ReadStr(r, 6) + }); + return list; + } + + private static async Task> ReadPaymentsPreviewAsync( + SqliteConnection conn, HashSet cols) + { + var list = new List(); + var del = cols.Contains("IsDeleted") ? " WHERE p.IsDeleted = 0" : ""; + using var cmd = conn.CreateCommand(); + cmd.CommandText = $@" + SELECT p.Id, p.PaymentNumber, p.PaidOn, p.Amount, p.PaymentMethod, + COALESCE(i.InvoiceNumber, 'Unknown') AS InvoiceNumber + FROM [Payments] p + LEFT JOIN [Invoices] i ON p.InvoiceId = i.Id + {del} + ORDER BY p.PaidOn DESC LIMIT 100"; + using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + list.Add(new PaymentPreview + { + Id = ReadGuid(r, 0), + PaymentNumber = ReadStr(r, 1), + PaidOn = ReadDateTime(r, 2), + Amount = ReadDecimal(r, 3), + PaymentMethod = ReadStr(r, 4), + InvoiceNumber = ReadStr(r, 5) + }); + return list; + } + + private static async Task> ReadMaintenancePreviewAsync( + SqliteConnection conn, HashSet cols) + { + var list = new List(); + var del = cols.Contains("IsDeleted") ? " WHERE m.IsDeleted = 0" : ""; + using var cmd = conn.CreateCommand(); + cmd.CommandText = $@" + SELECT m.Id, m.Title, m.RequestType, m.Priority, m.Status, m.RequestedOn, + COALESCE(p.Address, 'Unknown') AS PropertyAddress + FROM [MaintenanceRequests] m + LEFT JOIN [Properties] p ON m.PropertyId = p.Id + {del} + ORDER BY m.RequestedOn DESC LIMIT 100"; + using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + list.Add(new MaintenancePreview + { + Id = ReadGuid(r, 0), + Title = ReadStr(r, 1), + RequestType = ReadStr(r, 2), + Priority = ReadStr(r, 3), + Status = ReadStr(r, 4), + RequestedOn = ReadDateTime(r, 5), + PropertyAddress = ReadStr(r, 6) + }); + return list; + } + + private static async Task> ReadRepairsPreviewAsync( + SqliteConnection conn, HashSet cols) + { + var list = new List(); + var del = cols.Contains("IsDeleted") ? " WHERE r.IsDeleted = 0" : ""; + var repType = cols.Contains("RepairType") ? ", r.RepairType" : ""; + var completed = cols.Contains("CompletedOn") ? ", r.CompletedOn" : ""; + var cost = cols.Contains("Cost") ? ", r.Cost" : ""; + using var cmd = conn.CreateCommand(); + cmd.CommandText = $@" + SELECT r.Id, r.Description{repType}{completed}{cost}, + COALESCE(p.Address, 'Unknown') AS PropertyAddress + FROM [Repairs] r + LEFT JOIN [Properties] p ON r.PropertyId = p.Id + {del} + ORDER BY r.Id DESC LIMIT 100"; + using var r2 = await cmd.ExecuteReaderAsync(); + while (await r2.ReadAsync()) + { + int idx = 1; + var preview = new RepairPreview { - Id = p.Id, - Address = p.Address, - City = p.City, - State = p.State, - ZipCode = p.ZipCode, - PropertyType = p.PropertyType, - Status = p.Status, - Units = null, - MonthlyRent = p.MonthlyRent - }) - .ToListAsync(); - - // Load detailed tenant data - previewData.Tenants = await previewContext.Tenants - .Where(t => !t.IsDeleted) - .OrderBy(t => t.LastName) - .Take(100) // Limit to first 100 for performance - .Select(t => new TenantPreview + Id = ReadGuid(r2, 0), + Description = ReadStr(r2, idx++), + RepairType = cols.Contains("RepairType") ? ReadStr(r2, idx++) : string.Empty, + CompletedOn = cols.Contains("CompletedOn") ? (DateTime?)ReadDateTime(r2, idx++) : null, + Cost = cols.Contains("Cost") ? ReadDecimal(r2, idx++) : 0m, + PropertyAddress = ReadStr(r2, idx) + }; + list.Add(preview); + } + return list; + } + + private static async Task> ReadCalendarEventsPreviewAsync( + SqliteConnection conn, HashSet cols) + { + var list = new List(); + var del = cols.Contains("IsDeleted") ? " WHERE IsDeleted = 0" : ""; + using var cmd = conn.CreateCommand(); + cmd.CommandText = $"SELECT Id, Title, StartOn, EventType, Status FROM [CalendarEvents]{del} ORDER BY StartOn DESC LIMIT 100"; + using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + list.Add(new CalendarEventPreview { - Id = t.Id, - FirstName = t.FirstName, - LastName = t.LastName, - Email = t.Email, - Phone = t.PhoneNumber, - CreatedOn = t.CreatedOn - }) - .ToListAsync(); - - // Load detailed lease data with related entities - previewData.Leases = await previewContext.Leases - .Where(l => !l.IsDeleted) - .Include(l => l.Property) - .Include(l => l.Tenant) - .OrderByDescending(l => l.StartDate) - .Take(100) // Limit to first 100 for performance - .Select(l => new LeasePreview + Id = ReadGuid(r, 0), + Title = ReadStr(r, 1), + StartOn = ReadDateTime(r, 2), + EventType = ReadStr(r, 3), + Status = ReadStr(r, 4) + }); + return list; + } + + private static async Task> ReadInspectionsPreviewAsync( + SqliteConnection conn, HashSet cols) + { + var list = new List(); + var del = cols.Contains("IsDeleted") ? " WHERE i.IsDeleted = 0" : ""; + var inspBy = cols.Contains("InspectedBy") ? ", i.InspectedBy" : ""; + using var cmd = conn.CreateCommand(); + cmd.CommandText = $@" + SELECT i.Id, i.CompletedOn, i.InspectionType{inspBy}, + COALESCE(p.Address, 'Unknown') AS PropertyAddress + FROM [Inspections] i + LEFT JOIN [Properties] p ON i.PropertyId = p.Id + {del} + ORDER BY i.CompletedOn DESC LIMIT 100"; + using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + { + int idx = 3; + list.Add(new InspectionPreview { - Id = l.Id, - PropertyAddress = l.Property != null ? l.Property.Address : "Unknown", - TenantName = l.Tenant != null ? $"{l.Tenant.FirstName} {l.Tenant.LastName}" : "Unknown", - StartDate = l.StartDate, - EndDate = l.EndDate, - MonthlyRent = l.MonthlyRent, - Status = l.Status - }) - .ToListAsync(); + Id = ReadGuid(r, 0), + CompletedOn = ReadDateTime(r, 1), + InspectionType = ReadStr(r, 2), + InspectedBy = cols.Contains("InspectedBy") ? ReadStr(r, idx++) : null, + PropertyAddress = ReadStr(r, idx) + }); + } + return list; + } - _logger.LogInformation($"Loaded preview data: {previewData.PropertyCount} properties, {previewData.TenantCount} tenants, {previewData.LeaseCount} leases"); + private static async Task> ReadChecklistsPreviewAsync( + SqliteConnection conn, HashSet cols) + { + var list = new List(); + var del = cols.Contains("IsDeleted") ? " WHERE IsDeleted = 0" : ""; + using var cmd = conn.CreateCommand(); + cmd.CommandText = $"SELECT Id, Name, ChecklistType, Status FROM [Checklists]{del} ORDER BY Name LIMIT 100"; + using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + list.Add(new ChecklistPreview + { + Id = ReadGuid(r, 0), + Name = ReadStr(r, 1), + ChecklistType = ReadStr(r, 2), + Status = ReadStr(r, 3) + }); + return list; + } - return previewData; + private static async Task> ReadChecklistItemsPreviewAsync( + SqliteConnection conn, HashSet cols) + { + var list = new List(); + var del = cols.Contains("IsDeleted") ? " WHERE IsDeleted = 0" : ""; + using var cmd = conn.CreateCommand(); + cmd.CommandText = $"SELECT Id, ChecklistId, ItemText, ItemOrder FROM [ChecklistItems]{del} ORDER BY ItemOrder LIMIT 100"; + using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + list.Add(new ChecklistItemPreview + { + Id = ReadGuid(r, 0), + ChecklistId = ReadGuid(r, 1), + ItemText = ReadStr(r, 2), + ItemOrder = r.IsDBNull(3) ? 0 : r.GetInt32(3) + }); + return list; } + private static async Task> ReadSecurityDepositsPreviewAsync( + SqliteConnection conn, HashSet cols) + { + var list = new List(); + var del = cols.Contains("IsDeleted") ? " WHERE sd.IsDeleted = 0" : ""; + using var cmd = conn.CreateCommand(); + cmd.CommandText = $@" + SELECT sd.Id, sd.Amount, sd.DateReceived, sd.PaymentMethod, sd.Status, + COALESCE(t.FirstName || ' ' || t.LastName, 'Unknown') AS TenantName + FROM [SecurityDeposits] sd + LEFT JOIN [Tenants] t ON sd.TenantId = t.Id + {del} + ORDER BY sd.DateReceived DESC LIMIT 100"; + using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + list.Add(new SecurityDepositPreview + { + Id = ReadGuid(r, 0), + Amount = ReadDecimal(r, 1), + DateReceived = ReadDateTime(r, 2), + PaymentMethod = ReadStr(r, 3), + Status = ReadStr(r, 4), + TenantName = ReadStr(r, 5) + }); + return list; + } + + private static async Task> ReadNotesPreviewAsync( + SqliteConnection conn, HashSet cols) + { + var list = new List(); + var del = cols.Contains("IsDeleted") ? " WHERE IsDeleted = 0" : ""; + var created = cols.Contains("CreatedOn") ? ", CreatedOn" : ""; + using var cmd = conn.CreateCommand(); + cmd.CommandText = $"SELECT Id, EntityType, SUBSTR(Content, 1, 120){created} FROM [Notes]{del} ORDER BY Id DESC LIMIT 100"; + using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + { + int idx = 3; + list.Add(new NotePreview + { + Id = ReadGuid(r, 0), + EntityType = ReadStr(r, 1), + Content = ReadStr(r, 2), + CreatedOn = cols.Contains("CreatedOn") ? (DateTime?)ReadDateTime(r, idx) : null + }); + } + return list; + } + + private static async Task> ReadNotificationsPreviewAsync( + SqliteConnection conn, HashSet cols) + { + var list = new List(); + var del = cols.Contains("IsDeleted") ? " WHERE IsDeleted = 0" : ""; + using var cmd = conn.CreateCommand(); + cmd.CommandText = $"SELECT Id, Title, Type, Category, SentOn FROM [Notifications]{del} ORDER BY SentOn DESC LIMIT 100"; + using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) + list.Add(new NotificationPreview + { + Id = ReadGuid(r, 0), + Title = ReadStr(r, 1), + Type = ReadStr(r, 2), + Category = ReadStr(r, 3), + SentOn = ReadDateTime(r, 4) + }); + return list; + } + + // ------------------------------------------------------------------------- + // Import — two separate connections (backup=read, active=write) + // ------------------------------------------------------------------------- + /// - /// Get full path to backup file + /// Imports records from a backup into the active database. + /// Opens a separate read connection to the backup (same as preview) to avoid + /// ATTACH complications with WAL/shared-cache active connections. + /// Only inserts rows that don't already exist (matched by Id). + /// Only columns present in BOTH the backup AND the current schema are copied; + /// new columns (e.g. IsSampleData) receive their active-DB defaults. + /// OrganizationId is always overridden to the active organization. /// - private string GetBackupFilePath(string backupFileName) + public async Task ImportFromPreviewAsync(string backupFileName, string? password) { - // Security: Prevent path traversal attacks - var safeFileName = Path.GetFileName(backupFileName); - return Path.Combine(_backupDirectory, safeFileName); + var result = new ImportResult(); + try + { + var backupPath = await GetBackupFilePathAsync(backupFileName); + if (!File.Exists(backupPath)) + { + result.Message = $"Backup file not found: {backupFileName}"; + return result; + } + + var orgIdGuid = await _userContext.GetActiveOrganizationIdAsync(); + if (orgIdGuid == null) + { + result.Message = "No active organization found. Please ensure you are logged in."; + return result; + } + var orgId = orgIdGuid.Value.ToString("D").ToUpperInvariant(); + var userId = await _userContext.GetUserIdAsync() ?? string.Empty; + + // Open backup with a separate connection — same as GetPreviewDataAsync + using var backupConn = OpenBackupConnection(backupPath, password); + + var activeConn = (SqliteConnection)_activeContext.Database.GetDbConnection(); + if (activeConn.State != System.Data.ConnectionState.Open) + await activeConn.OpenAsync(); + + // Disable FK constraints for the duration of the import so that + // cross-version backups with slightly different schemas and + // OrganizationId overrides don't trigger FK violations. + using (var fkOff = activeConn.CreateCommand()) + { + fkOff.CommandText = "PRAGMA foreign_keys = OFF;"; + await fkOff.ExecuteNonQueryAsync(); + } + + using var tx = activeConn.BeginTransaction(); + try + { + // Import in FK dependency order + result.PropertiesImported = await ImportTableAsync(backupConn, activeConn, "Properties", PropertyRequiredCols, orgId, userId, result.Errors); + result.TenantsImported = await ImportTableAsync(backupConn, activeConn, "Tenants", TenantRequiredCols, orgId, userId, result.Errors); + result.LeasesImported = await ImportTableAsync(backupConn, activeConn, "Leases", LeaseRequiredCols, orgId, userId, result.Errors); + result.InvoicesImported = await ImportTableAsync(backupConn, activeConn, "Invoices", InvoiceRequiredCols, orgId, userId, result.Errors); + result.PaymentsImported = await ImportTableAsync(backupConn, activeConn, "Payments", PaymentRequiredCols, orgId, userId, result.Errors); + result.MaintenanceRequestsImported = await ImportTableAsync(backupConn, activeConn, "MaintenanceRequests", MaintenanceRequiredCols, orgId, userId, result.Errors); + result.RepairsImported = await ImportTableAsync(backupConn, activeConn, "Repairs", RepairRequiredCols, orgId, userId, result.Errors); + result.DocumentsImported = await ImportTableAsync(backupConn, activeConn, "Documents", DocumentRequiredCols, orgId, userId, result.Errors); + result.CalendarEventsImported = await ImportTableAsync(backupConn, activeConn, "CalendarEvents", CalendarEventRequiredCols, orgId, userId, result.Errors); + result.InspectionsImported = await ImportTableAsync(backupConn, activeConn, "Inspections", InspectionRequiredCols, orgId, userId, result.Errors); + result.ChecklistsImported = await ImportTableAsync(backupConn, activeConn, "Checklists", ChecklistRequiredCols, orgId, userId, result.Errors); + result.ChecklistItemsImported = await ImportTableAsync(backupConn, activeConn, "ChecklistItems", ChecklistItemRequiredCols, orgId, userId, result.Errors); + result.SecurityDepositsImported = await ImportTableAsync(backupConn, activeConn, "SecurityDeposits", SecurityDepositRequiredCols, orgId, userId, result.Errors); + result.NotesImported = await ImportTableAsync(backupConn, activeConn, "Notes", NoteRequiredCols, orgId, userId, result.Errors); + result.NotificationsImported = await ImportNotificationsAsync(backupConn, activeConn, NotificationRequiredCols, orgId, userId, result.Errors); + + tx.Commit(); + } + catch + { + tx.Rollback(); + throw; + } + finally + { + // Re-enable FK constraints + using var fkOn = activeConn.CreateCommand(); + fkOn.CommandText = "PRAGMA foreign_keys = ON;"; + await fkOn.ExecuteNonQueryAsync(); + } + + result.Success = true; + result.Message = $"Import complete. {result.TotalImported} records imported."; + _logger.LogInformation("Import from {File} complete: {Total} records", backupFileName, result.TotalImported); + } + catch (Exception ex) + { + result.Success = false; + result.Message = $"Import failed: {ex.Message}"; + result.Errors.Add(ex.ToString()); + _logger.LogError(ex, "Import from {File} failed", backupFileName); + } + + return result; + } + + /// + /// Copies rows from backupConn into activeConn for the given table. + /// Applies so renamed columns (e.g. + /// IsAvailableIsActive) are correctly mapped. + /// Only columns resolvable in both schemas are copied; columns present only + /// in the active schema are omitted from the INSERT (SQLite will apply the + /// column DEFAULT, or the row is skipped via INSERT OR IGNORE if a NOT NULL + /// constraint cannot be satisfied without a default). + /// Required columns trigger a warning but never block the import. + /// OrganizationId is always substituted with the active org. + /// Returns the number of rows actually inserted. + /// + + /// + /// Imports Notifications by fanning each source row out to every real user + /// (all AspNetUsers except the system user) so all users receive the notification. + /// Each fan-out row gets a freshly generated Id to avoid PK collisions. + /// + private static async Task ImportNotificationsAsync( + SqliteConnection backupConn, + SqliteConnection activeConn, + HashSet requiredCols, + string orgId, + string currentUserId, + List errors) + { + try + { + // Collect all real user IDs from the active database + var realUserIds = new List(); + using (var userCmd = activeConn.CreateCommand()) + { + userCmd.CommandText = $"SELECT Id FROM AspNetUsers WHERE Id != '{ApplicationConstants.SystemUser.Id}'"; + using var userReader = await userCmd.ExecuteReaderAsync(); + while (await userReader.ReadAsync()) + realUserIds.Add(userReader.GetString(0)); + } + + if (realUserIds.Count == 0) + { + errors.Add("Notifications: no real users found in active database — skipping."); + return 0; + } + + var backupCols = await GetTableColumnsAsync(backupConn, "Notifications"); + var activeCols = await GetTableColumnsAsync(activeConn, "Notifications"); + + var missingRequired = requiredCols + .Where(rc => + !backupCols.Contains(rc) && + !ColumnAliases.Any(kv => + kv.Value.Equals(rc, StringComparison.OrdinalIgnoreCase) && + backupCols.Contains(kv.Key))) + .ToList(); + if (missingRequired.Count > 0) + errors.Add($"Notifications: warning — required columns not found in backup: {string.Join(", ", missingRequired)}"); + + // Always-overridden cols (RecipientUserId handled separately per fan-out row) + var overrideCols = new HashSet(StringComparer.OrdinalIgnoreCase) + { "Id", "OrganizationId", "CreatedBy", "LastModifiedBy", "RecipientUserId" }; + + var colMapping = new List<(string BackupCol, string ActiveCol)>(); + foreach (var bc in backupCols) + { + if (overrideCols.Contains(bc)) continue; + var ac = ColumnAliases.TryGetValue(bc, out var aliased) ? aliased : bc; + if (activeCols.Contains(ac) && !overrideCols.Contains(ac)) + colMapping.Add((bc, ac)); + } + colMapping = colMapping.OrderBy(x => x.ActiveCol, StringComparer.OrdinalIgnoreCase).ToList(); + + var readCols = colMapping.Select(x => x.BackupCol).ToArray(); + var insertDataCols = colMapping.Select(x => x.ActiveCol).ToArray(); + var selectColList = string.Join(", ", readCols.Select(c => $"[{c}]")); + var delFilter = backupCols.Contains("IsDeleted") ? " WHERE IsDeleted = 0" : ""; + + // Read source rows + var rows = new List(); + using (var readCmd = backupConn.CreateCommand()) + { + readCmd.CommandText = selectColList.Length > 0 + ? $"SELECT {selectColList} FROM [Notifications]{delFilter}" + : $"SELECT 1 FROM [Notifications]{delFilter}"; + using var reader = await readCmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var row = new object?[readCols.Length]; + for (int i = 0; i < readCols.Length; i++) + row[i] = reader.IsDBNull(i) ? null : reader.GetValue(i); + rows.Add(row); + } + } + + if (rows.Count == 0) return 0; + + // Build INSERT: Id, RecipientUserId, OrganizationId, CreatedBy, LastModifiedBy, then data cols + var allInsertCols = new[] { "Id", "RecipientUserId", "OrganizationId", "CreatedBy", "LastModifiedBy" } + .Concat(insertDataCols) + .Where(c => activeCols.Contains(c)) + .ToArray(); + var colList = string.Join(", ", allInsertCols.Select(c => $"[{c}]")); + var paramList = string.Join(", ", allInsertCols.Select(c => + c.Equals("Id", StringComparison.OrdinalIgnoreCase) ? "@newId" : + c.Equals("RecipientUserId", StringComparison.OrdinalIgnoreCase) ? "@recipientId" : + c.Equals("OrganizationId", StringComparison.OrdinalIgnoreCase) ? "@orgId" : + c.Equals("CreatedBy", StringComparison.OrdinalIgnoreCase) ? "@currentUser" : + c.Equals("LastModifiedBy", StringComparison.OrdinalIgnoreCase) ? "@currentUser" : + $"@c_{c}")); + + using var writeCmd = activeConn.CreateCommand(); + writeCmd.CommandText = $"INSERT OR IGNORE INTO [Notifications] ({colList}) VALUES ({paramList})"; + writeCmd.Parameters.Add(new SqliteParameter("@newId", DBNull.Value)); + writeCmd.Parameters.Add(new SqliteParameter("@recipientId",DBNull.Value)); + writeCmd.Parameters.AddWithValue("@orgId", orgId); + writeCmd.Parameters.AddWithValue("@currentUser", currentUserId); + foreach (var ac in insertDataCols) + writeCmd.Parameters.Add(new SqliteParameter($"@c_{ac}", DBNull.Value)); + + int count = 0; + foreach (var row in rows) + { + // Set data column values once (shared across all user fan-outs) + for (int i = 0; i < insertDataCols.Length; i++) + { + var val = row[i]; + if (val is string s && Guid.TryParse(s, out var g)) + val = g.ToString("D").ToUpperInvariant(); + writeCmd.Parameters[$"@c_{insertDataCols[i]}"].Value = val ?? DBNull.Value; + } + + // Fan out: insert one row per real user + foreach (var uid in realUserIds) + { + writeCmd.Parameters["@newId"].Value = Guid.NewGuid().ToString("D").ToUpperInvariant(); + writeCmd.Parameters["@recipientId"].Value = uid; + count += await writeCmd.ExecuteNonQueryAsync(); + } + } + + return count; + } + catch (Exception ex) + { + errors.Add($"Notifications: {ex.Message}"); + return 0; + } + } + private static async Task ImportTableAsync( + SqliteConnection backupConn, + SqliteConnection activeConn, + string tableName, + HashSet requiredCols, + string orgId, + string currentUserId, + List errors) + { + try + { + var backupCols = await GetTableColumnsAsync(backupConn, tableName); + var activeCols = await GetTableColumnsAsync(activeConn, tableName); + + // Warn about required columns absent from the backup (checking aliases too) + // but never skip the table — import whatever is available. + var missingRequired = requiredCols + .Where(rc => + !backupCols.Contains(rc) && + !ColumnAliases.Any(kv => + kv.Value.Equals(rc, StringComparison.OrdinalIgnoreCase) && + backupCols.Contains(kv.Key))) + .ToList(); + if (missingRequired.Count > 0) + errors.Add($"{tableName}: warning — required columns not found in backup: {string.Join(", ", missingRequired)}"); + + // Columns always overridden — never read from backup + var overrideCols = new HashSet(StringComparer.OrdinalIgnoreCase) + { "OrganizationId", "CreatedBy", "LastModifiedBy" }; + + // Build backup→active column mapping, applying ColumnAliases for renamed columns. + // Only include cols that resolve to a name present in the active schema. + var colMapping = new List<(string BackupCol, string ActiveCol)>(); + foreach (var bc in backupCols) + { + if (bc.Equals("Id", StringComparison.OrdinalIgnoreCase)) continue; + if (overrideCols.Contains(bc)) continue; + var ac = ColumnAliases.TryGetValue(bc, out var aliased) ? aliased : bc; + if (activeCols.Contains(ac) && !overrideCols.Contains(ac)) + colMapping.Add((bc, ac)); + } + colMapping = colMapping.OrderBy(x => x.ActiveCol, StringComparer.OrdinalIgnoreCase).ToList(); + + var readBackupCols = colMapping.Select(x => x.BackupCol).ToArray(); + var insertActiveCols = colMapping.Select(x => x.ActiveCol).ToArray(); + + // Columns to insert: Id first, then context overrides, then data cols + var insertOverrides = new List { "OrganizationId" }; + if (activeCols.Contains("CreatedBy")) insertOverrides.Add("CreatedBy"); + if (activeCols.Contains("LastModifiedBy")) insertOverrides.Add("LastModifiedBy"); + + var allInsertCols = new[] { "Id" }.Concat(insertOverrides).Concat(insertActiveCols).ToArray(); + var colList = string.Join(", ", allInsertCols.Select(c => $"[{c}]")); + var paramList = string.Join(", ", allInsertCols.Select(c => + c.Equals("OrganizationId", StringComparison.OrdinalIgnoreCase) ? "@orgId" : + c.Equals("CreatedBy", StringComparison.OrdinalIgnoreCase) ? "@currentUser" : + c.Equals("LastModifiedBy", StringComparison.OrdinalIgnoreCase) ? "@currentUser" : + c.Equals("Id", StringComparison.OrdinalIgnoreCase) ? "@c_Id" : + $"@c_{c}")); + + // SELECT uses backup col names; readCols[0]=Id, readCols[1..]=backup data cols + var readCols = new[] { "Id" }.Concat(readBackupCols).ToArray(); + var selectColList = string.Join(", ", readCols.Select(c => $"[{c}]")); + var delFilter = backupCols.Contains("IsDeleted") ? " WHERE IsDeleted = 0" : ""; + + // Read all rows from backup + var rows = new List(); + using (var readCmd = backupConn.CreateCommand()) + { + readCmd.CommandText = $"SELECT {selectColList} FROM [{tableName}]{delFilter}"; + using var reader = await readCmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var row = new object?[readCols.Length]; + for (int i = 0; i < readCols.Length; i++) + row[i] = reader.IsDBNull(i) ? null : reader.GetValue(i); + rows.Add(row); + } + } + + if (rows.Count == 0) + return 0; + + // Build parameterized INSERT — parameters: @c_Id and @c_{activeColName} + using var writeCmd = activeConn.CreateCommand(); + writeCmd.CommandText = $"INSERT OR IGNORE INTO [{tableName}] ({colList}) VALUES ({paramList})"; + writeCmd.Parameters.AddWithValue("@orgId", orgId); + writeCmd.Parameters.AddWithValue("@currentUser", currentUserId); + writeCmd.Parameters.Add(new SqliteParameter("@c_Id", DBNull.Value)); + foreach (var ac in insertActiveCols) + writeCmd.Parameters.Add(new SqliteParameter($"@c_{ac}", DBNull.Value)); + + int count = 0; + foreach (var row in rows) + { + var idVal = row[0]; + if (idVal is string s0 && Guid.TryParse(s0, out var g0)) + idVal = g0.ToString("D").ToUpperInvariant(); + writeCmd.Parameters["@c_Id"].Value = idVal ?? DBNull.Value; + + // row[1..] maps to insertActiveCols by index + for (int i = 0; i < insertActiveCols.Length; i++) + { + var val = row[i + 1]; + if (val is string s && Guid.TryParse(s, out var g)) + val = g.ToString("D").ToUpperInvariant(); + writeCmd.Parameters[$"@c_{insertActiveCols[i]}"].Value = val ?? DBNull.Value; + } + count += await writeCmd.ExecuteNonQueryAsync(); + } + + return count; + } + catch (Exception ex) + { + errors.Add($"{tableName}: {ex.Message}"); + return 0; + } } } diff --git a/2-Nine.Application/Services/DatabaseService.cs b/2-Nine.Application/Services/DatabaseService.cs index 7a2527a..2a34aa8 100644 --- a/2-Nine.Application/Services/DatabaseService.cs +++ b/2-Nine.Application/Services/DatabaseService.cs @@ -2,6 +2,7 @@ using Nine.Infrastructure.Data; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using System.Data.Common; namespace Nine.Application.Services; @@ -27,34 +28,62 @@ public DatabaseService( public async Task InitializeAsync() { - _logger.LogInformation("Checking for pending migrations..."); - - // Check and apply identity migrations first - var identityPending = await _identityContext.Database.GetPendingMigrationsAsync(); - if (identityPending.Any()) - { - _logger.LogInformation($"Applying {identityPending.Count()} identity migrations..."); - await _identityContext.Database.MigrateAsync(); - _logger.LogInformation("Identity migrations applied successfully."); - } - else - { - _logger.LogInformation("No pending identity migrations."); - } - - // Then check and apply business migrations - var businessPending = await _businessContext.Database.GetPendingMigrationsAsync(); - if (businessPending.Any()) - { - _logger.LogInformation($"Applying {businessPending.Count()} business migrations..."); - await _businessContext.Database.MigrateAsync(); - _logger.LogInformation("Business migrations applied successfully."); - } - else + _logger.LogInformation("Starting database initialization..."); + + // EF Core's SqliteMigrationLock retries SQLITE_BUSY infinitely (no timeout, no exception). + // Any open EF RelationalConnection — including ones left "checked out" by GetPendingMigrationsAsync + // earlier in the same DI scope — blocks BEGIN EXCLUSIVE forever. + // CloseConnection() forces EF to release its held connection back to the pool. + // ClearAllPools() then closes the now-pooled connections so SQLite fully releases its file lock. + _identityContext.Database.CloseConnection(); + _businessContext.Database.CloseConnection(); + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + + // ── Era detection and bridge ────────────────────────────────────── + // Determines how far behind the current schema the on-disk database is + // and applies the appropriate bridge, or throws if it is too old to bridge. + await _businessContext.Database.OpenConnectionAsync(); + var era = await DetectEraAsync(_businessContext.Database.GetDbConnection()); + _businessContext.Database.CloseConnection(); + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + + switch (era) { - _logger.LogInformation("No pending business migrations."); + case Era.NotSupported: + { + string backupPath = await BackupUnsupportedDatabaseAsync(); + _logger.LogError( + "Database schema is outside the supported upgrade window and cannot be bridged. " + + "Backed up to {BackupPath}.", backupPath); + throw new Nine.Core.Exceptions.DatabaseExceptions.SchemaNotSupportedException(backupPath); + } + + case Era.SecondAncestor: + _logger.LogWarning("Second-ancestor era database detected. Applying bridge..."); + await ApplySecondAncestorBridgeAsync(); + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + break; + + case Era.FirstAncestor: + _logger.LogWarning("First-ancestor era database detected. Applying bridge..."); + await ApplyFirstAncestorBridgeAsync(); + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + break; + + // Era.Fresh and Era.Current: no bridge needed — MigrateAsync handles it. } - + + _logger.LogInformation("Applying identity migrations..."); + await _identityContext.Database.MigrateAsync(); + _logger.LogInformation("Identity migrations applied."); + + // Close identity connection before migrating the business DB — they may share the same file. + _identityContext.Database.CloseConnection(); + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + + _logger.LogInformation("Applying business migrations..."); + await _businessContext.Database.MigrateAsync(); + _logger.LogInformation("Database initialization complete."); } @@ -88,12 +117,275 @@ public async Task GetIdentityPendingMigrationsCountAsync() return pending.Count(); } + // ────────────────────────────────────────────────────────────────────── + // Era bridges + // Supports the current era and up to two generations back. + // Databases more than two generations old cannot be bridged — the user is + // directed to import their data instead. + // + // At each major release that squashes migrations, update: + // • CurrentEraMarker → the first migration ID in the new chain + // • FirstAncestorMarker → what was CurrentEraMarker in the previous release + // • SecondAncestorMarker → what was FirstAncestorMarker in the previous release + // • ApplyFirstAncestorBridgeAsync() → new bridge SQL (previous era → current) + // • ApplySecondAncestorBridgeAsync() → previous ApplyFirstAncestorBridgeAsync SQL + // ────────────────────────────────────────────────────────────────────── + + private enum Era { Fresh, Current, FirstAncestor, SecondAncestor, NotSupported } + + /// + /// Inspects __EFMigrationsHistory and classifies the on-disk database + /// into a known era. The connection must already be open (opened via EF so + /// the SqlCipherConnectionInterceptor has applied PRAGMA key if needed). + /// + private async Task DetectEraAsync(DbConnection conn) + { + // Known era fingerprints — update at each major squash release. + const string CurrentEraMarker = "20260128153724_v1_0_0_InitialCreate"; // Nine v1.x + const string FirstAncestorMarker = "20260106195859_InitialCreate"; // Aquiis v1.0.0+ + const string SecondAncestorMarker = ""; // TODO: set at next major squash + + // Fresh install — migrations table does not exist yet. + long tableExists; + using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = + "SELECT COUNT(*) FROM sqlite_master " + + "WHERE type='table' AND name='__EFMigrationsHistory'"; + tableExists = (long)(await cmd.ExecuteScalarAsync() ?? 0L); + } + + if (tableExists == 0) + return Era.Fresh; + + // Empty table is treated as fresh (should not occur in practice). + long rowCount; + using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\""; + rowCount = (long)(await cmd.ExecuteScalarAsync() ?? 0L); + } + + if (rowCount == 0) + return Era.Fresh; + + async Task HasMarker(string id) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = + "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = @id"; + var p = cmd.CreateParameter(); + p.ParameterName = "@id"; + p.Value = id; + cmd.Parameters.Add(p); + return (long)(await cmd.ExecuteScalarAsync() ?? 0L) > 0; + } + + if (!string.IsNullOrEmpty(SecondAncestorMarker) && await HasMarker(SecondAncestorMarker)) + return Era.SecondAncestor; + + if (await HasMarker(FirstAncestorMarker)) + return Era.FirstAncestor; + + if (await HasMarker(CurrentEraMarker)) + return Era.Current; + + // History exists with rows but no known marker — not a supported schema version. + return Era.NotSupported; + } + + /// + /// Bridges a first-ancestor era (Aquiis v0.3.0 / v1.0.0 / v1.1.0) database + /// directly to the current Nine era so that EF Core's + /// can complete normally. Called only after confirms + /// the database is in the first-ancestor era. + /// + /// UPDATE THIS METHOD at the next major squash: move its SQL into + /// and replace with the new + /// first-ancestor → current era SQL. + /// + /// The entire bridge runs in a single SQLite transaction — either every change + /// commits or the database is rolled back so the next startup can retry. + /// + private async Task ApplyFirstAncestorBridgeAsync() + { + // Open through EF so the SqlCipherConnectionInterceptor (if registered) + // applies PRAGMA key before any raw SQL executes on this connection. + await _businessContext.Database.OpenConnectionAsync(); + var conn = _businessContext.Database.GetDbConnection(); + + // All bridge statements execute inside a single transaction. + // If anything fails the database is rolled back so the next startup + // can retry cleanly. + var bridgeSql = new[] + { + // ── Step 1: Repair migration history ──────────────────────────── + // Remove the old pre-squash identity marker. + "DELETE FROM \"__EFMigrationsHistory\" " + + "WHERE \"MigrationId\" = '20260106195859_InitialCreate'", + + // Register the current-era identity InitialCreate. + // The identity tables already exist in this single-file DB; we only + // need the history entry so EF sees zero pending identity migrations. + "INSERT INTO \"__EFMigrationsHistory\" (\"MigrationId\", \"ProductVersion\") " + + "VALUES ('20260104205913_InitialCreate', '10.0.1')", + + // ── Step 2: AddIsSampleDataFlag – fix Invoice/Payment indexes ─── + // Drop the v0.3.0-era single-column Invoice unique index and the + // bare OrganizationId indexes; replace them with composite ones + // that are multi-tenant–safe. + "DROP INDEX IF EXISTS \"IX_Invoices_InvoiceNumber\"", + "DROP INDEX IF EXISTS \"IX_Invoices_OrganizationId\"", + "DROP INDEX IF EXISTS \"IX_Payments_OrganizationId\"", + "CREATE UNIQUE INDEX \"IX_Invoice_OrgId_InvoiceNumber\" " + + "ON \"Invoices\" (\"OrganizationId\", \"InvoiceNumber\")", + "CREATE UNIQUE INDEX \"IX_Payment_OrgId_PaymentNumber\" " + + "ON \"Payments\" (\"OrganizationId\", \"PaymentNumber\")", + + // ── Step 3: AddIsSampleDataFlag – add IsSampleData column ─────── + "ALTER TABLE \"ApplicationScreenings\" ADD COLUMN \"IsSampleData\" INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE \"CalendarEvents\" ADD COLUMN \"IsSampleData\" INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE \"CalendarSettings\" ADD COLUMN \"IsSampleData\" INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE \"ChecklistItems\" ADD COLUMN \"IsSampleData\" INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE \"Checklists\" ADD COLUMN \"IsSampleData\" INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE \"ChecklistTemplateItems\" ADD COLUMN \"IsSampleData\" INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE \"ChecklistTemplates\" ADD COLUMN \"IsSampleData\" INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE \"Documents\" ADD COLUMN \"IsSampleData\" INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE \"Inspections\" ADD COLUMN \"IsSampleData\" INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE \"Invoices\" ADD COLUMN \"IsSampleData\" INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE \"LeaseOffers\" ADD COLUMN \"IsSampleData\" INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE \"Leases\" ADD COLUMN \"IsSampleData\" INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE \"MaintenanceRequests\" ADD COLUMN \"IsSampleData\" INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE \"Notes\" ADD COLUMN \"IsSampleData\" INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE \"NotificationPreferences\" ADD COLUMN \"IsSampleData\" INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE \"Notifications\" ADD COLUMN \"IsSampleData\" INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE \"OrganizationEmailSettings\" ADD COLUMN \"IsSampleData\" INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE \"OrganizationSettings\" ADD COLUMN \"IsSampleData\" INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE \"OrganizationSMSSettings\" ADD COLUMN \"IsSampleData\" INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE \"Payments\" ADD COLUMN \"IsSampleData\" INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE \"Properties\" ADD COLUMN \"IsSampleData\" INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE \"ProspectiveTenants\" ADD COLUMN \"IsSampleData\" INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE \"RentalApplications\" ADD COLUMN \"IsSampleData\" INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE \"Repairs\" ADD COLUMN \"IsSampleData\" INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE \"SecurityDepositDividends\" ADD COLUMN \"IsSampleData\" INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE \"SecurityDepositInvestmentPools\" ADD COLUMN \"IsSampleData\" INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE \"SecurityDeposits\" ADD COLUMN \"IsSampleData\" INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE \"Tenants\" ADD COLUMN \"IsSampleData\" INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE \"Tours\" ADD COLUMN \"IsSampleData\" INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE \"UserProfiles\" ADD COLUMN \"IsSampleData\" INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE \"WorkflowAuditLogs\" ADD COLUMN \"IsSampleData\" INTEGER NOT NULL DEFAULT 0", + + // ── Step 4: UpdateExistingSampleDataFlag – tag system-seeded rows ─ + "UPDATE \"Properties\" SET \"IsSampleData\" = 1 WHERE \"CreatedBy\" = '00000000-0000-0000-0000-000000000001'", + "UPDATE \"Tenants\" SET \"IsSampleData\" = 1 WHERE \"CreatedBy\" = '00000000-0000-0000-0000-000000000001'", + "UPDATE \"Leases\" SET \"IsSampleData\" = 1 WHERE \"CreatedBy\" = '00000000-0000-0000-0000-000000000001'", + "UPDATE \"Invoices\" SET \"IsSampleData\" = 1 WHERE \"CreatedBy\" = '00000000-0000-0000-0000-000000000001'", + "UPDATE \"Payments\" SET \"IsSampleData\" = 1 WHERE \"CreatedBy\" = '00000000-0000-0000-0000-000000000001'", + + // ── Step 5: ConsolidateOrganizationIdToBaseModel — no schema change ─ + // (code-only refactor; nothing to execute) + + // ── Step 6: RenameIsAvailableToIsActive ────────────────────────── + "ALTER TABLE \"Properties\" RENAME COLUMN \"IsAvailable\" TO \"IsActive\"", + + // ── Step 7: Record all just-applied migrations ─────────────────── + "INSERT INTO \"__EFMigrationsHistory\" (\"MigrationId\", \"ProductVersion\") " + + "VALUES ('20260212163628_AddIsSampleDataFlag', '10.0.1')", + "INSERT INTO \"__EFMigrationsHistory\" (\"MigrationId\", \"ProductVersion\") " + + "VALUES ('20260212165047_UpdateExistingSampleDataFlag', '10.0.1')", + "INSERT INTO \"__EFMigrationsHistory\" (\"MigrationId\", \"ProductVersion\") " + + "VALUES ('20260216205819_ConsolidateOrganizationIdToBaseModel', '10.0.1')", + "INSERT INTO \"__EFMigrationsHistory\" (\"MigrationId\", \"ProductVersion\") " + + "VALUES ('20260313122831_RenameIsAvailableToIsActive', '10.0.1')", + }; + + using var transaction = conn.BeginTransaction(); + try + { + foreach (var sql in bridgeSql) + { + using var cmd = conn.CreateCommand(); + cmd.Transaction = transaction; + cmd.CommandText = sql; + await cmd.ExecuteNonQueryAsync(); + } + + transaction.Commit(); + _logger.LogInformation( + "First-ancestor era bridge applied. Database is now on the current era."); + } + catch (Exception ex) + { + _logger.LogError(ex, "First-ancestor bridge failed — rolling back to preserve database integrity."); + transaction.Rollback(); + throw; + } + finally + { + _businessContext.Database.CloseConnection(); + } + } + + /// + /// Bridges a second-ancestor era database directly to the current era. + /// Called only after confirms the database is + /// in the second-ancestor era. + /// + /// NOT YET IMPLEMENTED — no second-ancestor era exists at this release. + /// Implement at the next major squash by moving the current + /// SQL here and writing new + /// first-ancestor → current bridge SQL above. + /// + private Task ApplySecondAncestorBridgeAsync() + { + // SecondAncestorMarker in DetectEraAsync is intentionally empty so this + // method is never reachable in practice until the next major squash. + throw new NotImplementedException( + "ApplySecondAncestorBridgeAsync is not yet implemented. " + + "Populate SecondAncestorMarker in DetectEraAsync and add bridge SQL here " + + "at the next major squash release."); + } + + /// + /// Copies the business database file to the Backups folder before a + /// is thrown. + /// Returns the full path of the backup file, or an empty string if the + /// source file could not be located. + /// + private Task BackupUnsupportedDatabaseAsync() + { + var connStr = _businessContext.Database.GetConnectionString() ?? string.Empty; + var builder = new Microsoft.Data.Sqlite.SqliteConnectionStringBuilder(connStr); + var dbPath = builder.DataSource; + + if (string.IsNullOrEmpty(dbPath) || !File.Exists(dbPath)) + { + _logger.LogWarning("Could not locate database file for backup (path: {DbPath}).", dbPath); + return Task.FromResult(string.Empty); + } + + var dbDir = Path.GetDirectoryName(Path.GetFullPath(dbPath)) ?? "."; + var backupDir = Path.GetFullPath(Path.Combine(dbDir, "Backups")); + Directory.CreateDirectory(backupDir); + + var timestamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss"); + var backupName = + $"unsupported_{Path.GetFileNameWithoutExtension(dbPath)}_{timestamp}{Path.GetExtension(dbPath)}"; + var backupPath = Path.Combine(backupDir, backupName); + + File.Copy(dbPath, backupPath, overwrite: false); + _logger.LogInformation("Database backed up to {BackupPath}.", backupPath); + return Task.FromResult(backupPath); + } + + // ────────────────────────────────────────────────────────────────────── + /// /// Gets the database settings (creates default if not exists) /// public async Task GetDatabaseSettingsAsync() { - var settings = await _businessContext.DatabaseSettings.FirstOrDefaultAsync(); + var settings = await _businessContext.DatabaseSettings.OrderBy(s => s.Id).FirstOrDefaultAsync(); if (settings == null) { diff --git a/2-Nine.Application/Services/LeaseNotificationService.cs b/2-Nine.Application/Services/LeaseNotificationService.cs index 6b55dff..fda19b4 100644 --- a/2-Nine.Application/Services/LeaseNotificationService.cs +++ b/2-Nine.Application/Services/LeaseNotificationService.cs @@ -147,6 +147,9 @@ await _notificationService.NotifyAllUsersAsync( lease.Tenant?.FullName ?? "Unknown", lease.EndDate.ToString("MMM dd, yyyy")); + lease.LastModifiedOn = DateTime.UtcNow; + lease.LastModifiedBy = ApplicationConstants.SystemUser.Id; // Automated task + addresses += lease.Property?.Address + "\n"; } diff --git a/2-Nine.Application/Services/LeaseService.cs b/2-Nine.Application/Services/LeaseService.cs index 5e042cf..b6878c2 100644 --- a/2-Nine.Application/Services/LeaseService.cs +++ b/2-Nine.Application/Services/LeaseService.cs @@ -111,7 +111,7 @@ public override async Task CreateAsync(Lease entity) if (property != null) { property.Status = ApplicationConstants.PropertyStatuses.Occupied; - property.IsAvailable = false; + property.IsActive = false; property.LastModifiedOn = DateTime.UtcNow; property.LastModifiedBy = await _userContext.GetUserIdAsync(); _context.Properties.Update(property); @@ -142,7 +142,7 @@ public override async Task UpdateAsync(Lease entity) if (property != null) { property.Status = ApplicationConstants.PropertyStatuses.Occupied; - property.IsAvailable = false; + property.IsActive = false; property.LastModifiedOn = DateTime.UtcNow; property.LastModifiedBy = await _userContext.GetUserIdAsync(); _context.Properties.Update(property); @@ -179,7 +179,7 @@ public override async Task DeleteAsync(Guid id) if (!hasOtherActiveLeases) { - property.IsAvailable = true; + property.IsActive = true; property.LastModifiedOn = DateTime.UtcNow; property.LastModifiedBy = await _userContext.GetUserIdAsync(); _context.Properties.Update(property); @@ -486,7 +486,7 @@ public async Task UpdateLeaseStatusAsync(Guid leaseId, string newStatus) { if (newStatus == ApplicationConstants.LeaseStatuses.Active) { - property.IsAvailable = false; + property.IsActive = false; } else if (newStatus == ApplicationConstants.LeaseStatuses.Terminated || newStatus == ApplicationConstants.LeaseStatuses.Expired) @@ -501,7 +501,8 @@ public async Task UpdateLeaseStatusAsync(Guid leaseId, string newStatus) if (!hasOtherActiveLeases) { - property.IsAvailable = true; + property.IsActive = true; + property.Status = ApplicationConstants.PropertyStatuses.Available; } } diff --git a/2-Nine.Application/Services/NotificationService.cs b/2-Nine.Application/Services/NotificationService.cs index e459ecb..383330d 100644 --- a/2-Nine.Application/Services/NotificationService.cs +++ b/2-Nine.Application/Services/NotificationService.cs @@ -40,12 +40,29 @@ public async Task SendNotificationAsync( string type, string category, Guid? relatedEntityId = null, - string? relatedEntityType = null) + string? relatedEntityType = null, + Guid? organizationId = null, + string? senderUserId = null) { - var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + organizationId ??= await _userContext.GetActiveOrganizationIdAsync(); // Get user preferences - var preferences = await GetNotificationPreferencesAsync(recipientUserId); + var preferences = await GetNotificationPreferencesAsync(recipientUserId, organizationId); + + // Resolve the sender: explicit caller-provided ID (e.g. SystemUser for background jobs), + // otherwise require an authenticated user. + string resolvedSenderUserId; + if (!string.IsNullOrEmpty(senderUserId)) + { + resolvedSenderUserId = senderUserId; + } + else + { + var authenticatedUserId = await _userContext.GetUserIdAsync(); + if (string.IsNullOrEmpty(authenticatedUserId)) + throw new UnauthorizedAccessException("User is not authenticated."); + resolvedSenderUserId = authenticatedUserId; + } var notification = new Notification { @@ -62,7 +79,8 @@ public async Task SendNotificationAsync( IsRead = false, SendInApp = preferences.EnableInAppNotifications, SendEmail = preferences.EnableEmailNotifications && ShouldSendEmail(category, preferences), - SendSMS = preferences.EnableSMSNotifications && ShouldSendSMS(category, preferences) + SendSMS = preferences.EnableSMSNotifications && ShouldSendSMS(category, preferences), + CreatedBy = resolvedSenderUserId }; // Save in-app notification @@ -107,6 +125,7 @@ await _smsService.SendSMSAsync( } } + notification.LastModifiedBy = resolvedSenderUserId; await UpdateAsync(notification); // Broadcast new notification via SignalR @@ -141,7 +160,9 @@ public async Task NotifyAllUsersAsync( type, category, relatedEntityId, - relatedEntityType); + relatedEntityType, + organizationId, + senderUserId: ApplicationConstants.SystemUser.Id); } return lastNotification!; @@ -281,9 +302,9 @@ public async Task UpdateUserPreferencesAsync(Notificati /// /// Get or create notification preferences for user /// - private async Task GetNotificationPreferencesAsync(string userId) + private async Task GetNotificationPreferencesAsync(string userId, Guid? organizationId = null) { - var organizationId = await _userContext.GetActiveOrganizationIdAsync(); + organizationId ??= await _userContext.GetActiveOrganizationIdAsync(); var preferences = await _context.NotificationPreferences .FirstOrDefaultAsync(p => p.OrganizationId == organizationId diff --git a/2-Nine.Application/Services/PropertyManagementService.cs b/2-Nine.Application/Services/PropertyManagementService.cs index c8da688..2487dd0 100644 --- a/2-Nine.Application/Services/PropertyManagementService.cs +++ b/2-Nine.Application/Services/PropertyManagementService.cs @@ -588,7 +588,7 @@ public async Task> GetLeasesByTenantIdAsync(Guid tenantId) await _dbContext.Leases.AddAsync(lease); - property.IsAvailable = false; + property.IsActive = false; property.LastModifiedOn = DateTime.UtcNow; property.LastModifiedBy = _userId; diff --git a/2-Nine.Application/Services/PropertyService.cs b/2-Nine.Application/Services/PropertyService.cs index e562e40..e723647 100644 --- a/2-Nine.Application/Services/PropertyService.cs +++ b/2-Nine.Application/Services/PropertyService.cs @@ -207,7 +207,7 @@ public async Task> GetVacantPropertiesAsync() return await _context.Properties .Where(p => !p.IsDeleted && - p.IsAvailable && + p.IsActive && p.OrganizationId == organizationId) .Where(p => !_context.Leases.Any(l => l.PropertyId == p.Id && diff --git a/2-Nine.Application/Services/ScheduledTaskService.cs b/2-Nine.Application/Services/ScheduledTaskService.cs index d3e3370..22a0778 100644 --- a/2-Nine.Application/Services/ScheduledTaskService.cs +++ b/2-Nine.Application/Services/ScheduledTaskService.cs @@ -79,7 +79,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) timeUntilMonday, TimeSpan.FromDays(7)); - _logger.LogInformation("Scheduled Task Service started. Daily tasks will run at midnight, hourly tasks every hour, weekly tasks every Monday at 6 AM."); + _logger.LogInformation("Scheduled Task Service started. Daily tasks will on each application start-up or when explicityly triggered in Application"); // Keep the service running while (!stoppingToken.IsCancellationRequested) @@ -568,7 +568,7 @@ private async Task ExpireOldLeaseOffers(IServiceScope scope) { try { - var result = await workflowService.ExpireLeaseOfferAsync(offer.Id); + var result = await workflowService.ExpireLeaseOfferAsync(offer.Id, offer.OrganizationId); if (result.Success) { diff --git a/2-Nine.Application/Services/Workflows/ApplicationWorkflowService.cs b/2-Nine.Application/Services/Workflows/ApplicationWorkflowService.cs index 0fa2246..981701a 100644 --- a/2-Nine.Application/Services/Workflows/ApplicationWorkflowService.cs +++ b/2-Nine.Application/Services/Workflows/ApplicationWorkflowService.cs @@ -1059,12 +1059,13 @@ await _notificationService.NotifyAllUsersAsync( /// Expires a lease offer (called by scheduled task). /// Similar to decline but automated. /// - public async Task ExpireLeaseOfferAsync(Guid leaseOfferId) + public async Task ExpireLeaseOfferAsync(Guid leaseOfferId, Guid? organizationId = null) { return await ExecuteWorkflowAsync(async () => { - var orgId = await GetActiveOrganizationIdAsync(); + var orgId = organizationId ?? await GetActiveOrganizationIdAsync(); var userId = await GetCurrentUserIdAsync(); + if (string.IsNullOrEmpty(userId)) userId = "System"; var leaseOffer = await _context.LeaseOffers .Include(lo => lo.RentalApplication) @@ -1118,7 +1119,8 @@ await LogTransitionAsync( "Pending", "Expired", "ExpireLeaseOffer", - "Offer expired after 30 days"); + "Offer expired after 30 days", + organizationId: orgId); // send notification to leasing agents await _notificationService.SendNotificationAsync( diff --git a/2-Nine.Application/Services/Workflows/BaseWorkflowService.cs b/2-Nine.Application/Services/Workflows/BaseWorkflowService.cs index 3088126..3379590 100644 --- a/2-Nine.Application/Services/Workflows/BaseWorkflowService.cs +++ b/2-Nine.Application/Services/Workflows/BaseWorkflowService.cs @@ -119,6 +119,8 @@ protected async Task ExecuteWorkflowAsync( /// /// Logs a workflow state transition to the audit log. + /// Pass when calling from background services where the + /// user context has no Blazor circuit (e.g., ScheduledTaskService). /// protected async Task LogTransitionAsync( string entityType, @@ -127,10 +129,11 @@ protected async Task LogTransitionAsync( string toStatus, string action, string? reason = null, - Dictionary? metadata = null) + Dictionary? metadata = null, + Guid? organizationId = null) { var userId = await _userContext.GetUserIdAsync() ?? string.Empty; - var activeOrgId = await _userContext.GetActiveOrganizationIdAsync(); + var activeOrgId = organizationId ?? await _userContext.GetActiveOrganizationIdAsync(); var auditLog = new WorkflowAuditLog { @@ -141,12 +144,12 @@ protected async Task LogTransitionAsync( ToStatus = toStatus, Action = action, Reason = reason, - PerformedBy = userId, + PerformedBy = string.IsNullOrEmpty(userId) ? "System" : userId, PerformedOn = DateTime.UtcNow, OrganizationId = activeOrgId.HasValue ? activeOrgId.Value : Guid.Empty, Metadata = metadata != null ? JsonSerializer.Serialize(metadata) : null, CreatedOn = DateTime.UtcNow, - CreatedBy = userId + CreatedBy = string.IsNullOrEmpty(userId) ? "System" : userId }; _context.WorkflowAuditLogs.Add(auditLog); diff --git a/2-Nine.Application/Services/Workflows/LeaseWorkflowService.cs b/2-Nine.Application/Services/Workflows/LeaseWorkflowService.cs index 3421c17..5a1cdbf 100644 --- a/2-Nine.Application/Services/Workflows/LeaseWorkflowService.cs +++ b/2-Nine.Application/Services/Workflows/LeaseWorkflowService.cs @@ -588,6 +588,7 @@ public async Task> ExpireOverdueLeaseAsync(Guid organization { return await ExecuteWorkflowAsync(async () => { + // Called from background service — no Blazor circuit, use "System" as the actor. var userId = await _userContext.GetUserIdAsync() ?? "System"; // Find active leases past their end date @@ -606,30 +607,54 @@ public async Task> ExpireOverdueLeaseAsync(Guid organization { var oldStatus = lease.Status; lease.Status = ApplicationConstants.LeaseStatuses.Expired; + lease.RenewalStatus = "Expired"; lease.LastModifiedBy = userId; lease.LastModifiedOn = DateTime.UtcNow; + // Update property status if no other active leases exist for it + if (lease.Property != null) + { + var hasOtherActiveLeases = await _context.Leases + .AnyAsync(l => l.PropertyId == lease.PropertyId + && l.Id != lease.Id + && !l.IsDeleted + && (l.Status == ApplicationConstants.LeaseStatuses.Active + || l.Status == ApplicationConstants.LeaseStatuses.Pending)); + + if (!hasOtherActiveLeases) + { + lease.Property.Status = ApplicationConstants.PropertyStatuses.Available; + lease.Property.IsActive = true; + lease.Property.LastModifiedBy = userId; + lease.Property.LastModifiedOn = DateTime.UtcNow; + } + } + await LogTransitionAsync( "Lease", lease.Id, oldStatus, lease.Status, "AutoExpire", - "Lease end date passed without renewal"); + "Lease end date passed without renewal", + organizationId: organizationId); addresses += $"- {lease.Property?.Address} (Tenant: {lease.Tenant?.FullName})\n"; count++; } - await _notificationService.NotifyAllUsersAsync( - organizationId, - "Expired Lease Notification", - $"{count} lease(s) have been automatically expired as of today.\n\n{addresses}", - NotificationConstants.Types.Info, - NotificationConstants.Categories.Lease, - null, - ApplicationConstants.EntityTypes.Lease); + if (count > 0) + { + await _notificationService.NotifyAllUsersAsync( + organizationId, + "Expired Lease Notification", + $"{count} lease(s) have been automatically expired as of today.\n\n{addresses}", + NotificationConstants.Types.Info, + NotificationConstants.Categories.Lease, + null, + ApplicationConstants.EntityTypes.Lease); + } return WorkflowResult.Ok(count, $"{count} lease(s) expired"); }); diff --git a/2-Nine.Application/Services/Workflows/SampleDataWorkflowService.cs b/2-Nine.Application/Services/Workflows/SampleDataWorkflowService.cs index 997553e..75e1cf6 100644 --- a/2-Nine.Application/Services/Workflows/SampleDataWorkflowService.cs +++ b/2-Nine.Application/Services/Workflows/SampleDataWorkflowService.cs @@ -272,7 +272,7 @@ private async Task> GeneratePropertiesAsync(Guid organizationId, SquareFeet = data.SqFt, MonthlyRent = data.Rent, Status = data.Status, - IsAvailable = data.Status == ApplicationConstants.PropertyStatuses.Available, + IsActive = data.Status == ApplicationConstants.PropertyStatuses.Available, Description = $"Beautiful {data.Beds} bedroom, {data.Baths} bath {data.Type.ToLower()} in {data.City}. " + $"{data.SqFt} square feet with modern amenities and convenient location.", RoutineInspectionIntervalMonths = 12, @@ -399,7 +399,7 @@ private async Task> GenerateLeasesAsync( // Update property status to Occupied property.Status = ApplicationConstants.PropertyStatuses.Occupied; - property.IsAvailable = false; + property.IsActive = false; } // Create 2 new leases expiring soon (properties 6 and 7, tenants 3 and 4) @@ -429,7 +429,7 @@ private async Task> GenerateLeasesAsync( _context.Leases.Add(lease30Days); leases.Add(lease30Days); properties[6].Status = ApplicationConstants.PropertyStatuses.Occupied; - properties[6].IsAvailable = false; + properties[6].IsActive = false; // Lease 2: Expires in 60 days from current date var endDate60 = currentDate.AddDays(60); @@ -457,7 +457,7 @@ private async Task> GenerateLeasesAsync( _context.Leases.Add(lease60Days); leases.Add(lease60Days); properties[7].Status = ApplicationConstants.PropertyStatuses.Occupied; - properties[7].IsAvailable = false; + properties[7].IsActive = false; await _context.SaveChangesAsync(); _logger.LogInformation($"Generated {leases.Count} leases"); diff --git a/3-Nine.Shared.UI/Components/Entities/Leases/LeaseRenewalList.razor b/3-Nine.Shared.UI/Components/Entities/Leases/LeaseRenewalList.razor index c5d1b36..0bc33f5 100644 --- a/3-Nine.Shared.UI/Components/Entities/Leases/LeaseRenewalList.razor +++ b/3-Nine.Shared.UI/Components/Entities/Leases/LeaseRenewalList.razor @@ -135,15 +135,24 @@ var today = DateTime.Today; leases30Days = Leases - .Where(l => l.EndDate <= today.AddDays(30)) + .Where(l => l.Status != "Expired" + && l.Status != "Terminated" + && l.EndDate >= today + && l.EndDate <= today.AddDays(30)) .ToList(); leases60Days = Leases - .Where(l => l.EndDate <= today.AddDays(60)) + .Where(l => l.Status != "Expired" + && l.Status != "Terminated" + && l.EndDate >= today + && l.EndDate <= today.AddDays(60)) .ToList(); leases90Days = Leases - .Where(l => l.EndDate <= today.AddDays(90)) + .Where(l => l.Status != "Expired" + && l.Status != "Terminated" + && l.EndDate >= today + && l.EndDate <= today.AddDays(90)) .ToList(); } catch (Exception ex) diff --git a/3-Nine.Shared.UI/Components/Entities/Properties/PropertyForm.razor b/3-Nine.Shared.UI/Components/Entities/Properties/PropertyForm.razor index 8593812..2a36e29 100644 --- a/3-Nine.Shared.UI/Components/Entities/Properties/PropertyForm.razor +++ b/3-Nine.Shared.UI/Components/Entities/Properties/PropertyForm.razor @@ -122,7 +122,7 @@
- + diff --git a/3-Nine.Shared.UI/Components/Entities/Properties/PropertyFormModel.cs b/3-Nine.Shared.UI/Components/Entities/Properties/PropertyFormModel.cs index 84d5f99..c7c9369 100644 --- a/3-Nine.Shared.UI/Components/Entities/Properties/PropertyFormModel.cs +++ b/3-Nine.Shared.UI/Components/Entities/Properties/PropertyFormModel.cs @@ -50,7 +50,7 @@ public class PropertyFormModel [StringLength(50, ErrorMessage = "Status cannot exceed 50 characters")] public string Status { get; set; } = ApplicationConstants.PropertyStatuses.Available; - public bool IsAvailable { get; set; } = true; + public bool IsActive { get; set; } = true; public bool IsSampleData { get; set; } = false; } diff --git a/3-Nine.Shared.UI/Components/Notifications/NotificationCenter.razor b/3-Nine.Shared.UI/Components/Notifications/NotificationCenter.razor index 4d7f242..5964cc9 100644 --- a/3-Nine.Shared.UI/Components/Notifications/NotificationCenter.razor +++ b/3-Nine.Shared.UI/Components/Notifications/NotificationCenter.razor @@ -440,8 +440,10 @@ hubConnection.Closed += async error => { Logger.LogError($"SignalR connection closed. Error: {error?.Message}"); + if (_disposed) return; await Task.Delay(5000); - await StartConnectionAsync(); + if (!_disposed) + await StartConnectionAsync(); }; await StartConnectionAsync(); @@ -468,8 +470,11 @@ } } + private bool _disposed = false; + public async ValueTask DisposeAsync() { + _disposed = true; if (hubConnection is not null) { await hubConnection.DisposeAsync(); diff --git a/3-Nine.Shared.UI/Features/PropertyManagement/Leases/CreateForm.razor b/3-Nine.Shared.UI/Features/PropertyManagement/Leases/CreateForm.razor index 14c5360..1a4c6ef 100644 --- a/3-Nine.Shared.UI/Features/PropertyManagement/Leases/CreateForm.razor +++ b/3-Nine.Shared.UI/Features/PropertyManagement/Leases/CreateForm.razor @@ -213,7 +213,7 @@ List? allProperties = await PropertyService.GetAllAsync(); availableProperties = allProperties - .Where(p => p.IsAvailable) + .Where(p => p.IsActive) .ToList() ?? new List(); // Load user's tenants @@ -304,7 +304,7 @@ // Mark property as unavailable if lease is active if (leaseModel.Status == ApplicationConstants.LeaseStatuses.Active) { - property.IsAvailable = false; + property.IsActive = false; } await PropertyService.UpdateAsync(property); diff --git a/3-Nine.Shared.UI/Features/PropertyManagement/Leases/EditForm.razor b/3-Nine.Shared.UI/Features/PropertyManagement/Leases/EditForm.razor index 9c165ab..9d5cae0 100644 --- a/3-Nine.Shared.UI/Features/PropertyManagement/Leases/EditForm.razor +++ b/3-Nine.Shared.UI/Features/PropertyManagement/Leases/EditForm.razor @@ -242,7 +242,7 @@ else { if (leaseModel.Status == "Active") { - lease.Property.IsAvailable = false; + lease.Property.IsActive = false; } else if (oldStatus == "Active" && leaseModel.Status != "Active") { @@ -252,7 +252,7 @@ else if (!otherActiveLeases) { - lease.Property.IsAvailable = true; + lease.Property.IsActive = true; } } } @@ -306,7 +306,7 @@ else if (!otherActiveLeasesExist) { - lease.Property.IsAvailable = true; + lease.Property.IsActive = true; } } diff --git a/3-Nine.Shared.UI/Features/PropertyManagement/Properties/PropertyCreateForm.razor b/3-Nine.Shared.UI/Features/PropertyManagement/Properties/PropertyCreateForm.razor index ef64bc3..b4f8e87 100644 --- a/3-Nine.Shared.UI/Features/PropertyManagement/Properties/PropertyCreateForm.razor +++ b/3-Nine.Shared.UI/Features/PropertyManagement/Properties/PropertyCreateForm.razor @@ -49,7 +49,7 @@ SquareFeet = model.SquareFeet, Description = model.Description, Status = model.Status, - IsAvailable = model.IsAvailable, + IsActive = model.IsActive, }; await PropertyService.CreateAsync(property); diff --git a/3-Nine.Shared.UI/Features/PropertyManagement/Properties/PropertyEditForm.razor b/3-Nine.Shared.UI/Features/PropertyManagement/Properties/PropertyEditForm.razor index 0bd1d4b..9e304a2 100644 --- a/3-Nine.Shared.UI/Features/PropertyManagement/Properties/PropertyEditForm.razor +++ b/3-Nine.Shared.UI/Features/PropertyManagement/Properties/PropertyEditForm.razor @@ -117,7 +117,7 @@ else SquareFeet = property.SquareFeet, Description = property.Description, Status = property.Status, - IsAvailable = property.IsAvailable, + IsActive = property.IsActive, IsSampleData = property.IsSampleData }; } @@ -161,7 +161,7 @@ else property.SquareFeet = model.SquareFeet; property.Description = model.Description; property.Status = model.Status; - property.IsAvailable = model.IsAvailable; + property.IsActive = model.IsActive; property.IsSampleData = model.IsSampleData; await PropertyService.UpdateAsync(property); diff --git a/3-Nine.Shared.UI/Features/PropertyManagement/Properties/PropertyViewForm.razor b/3-Nine.Shared.UI/Features/PropertyManagement/Properties/PropertyViewForm.razor index 65a5479..e0e3ebd 100644 --- a/3-Nine.Shared.UI/Features/PropertyManagement/Properties/PropertyViewForm.razor +++ b/3-Nine.Shared.UI/Features/PropertyManagement/Properties/PropertyViewForm.razor @@ -47,8 +47,8 @@ else
Property Information
- - @(property.IsAvailable ? "Available" : "Occupied") + + @(property.IsActive ? "Available" : "Occupied") @if (property.IsSampleData) { @@ -249,7 +249,7 @@ else - @if (property.IsAvailable) + @if (property.IsActive) {
} else if (isUnlocked && previewData != null) { - -
+ +

Database Preview

-

@DecodedFileName

-
+

@DecodedFileName

+
- Read-Only Mode - This is a preview. No changes will be made to your active database. + Read-Only Preview — No changes are made to your active database unless you use Import below. @if (!string.IsNullOrEmpty(sessionPassword)) { @@ -94,204 +94,987 @@
-
-
-
-
- -

@previewData.PropertyCount

-

Properties

+
+
+
+
+ +

@previewData.PropertyCount

+ Properties
-
-
-
- -

@previewData.TenantCount

-

Tenants

+
+
+
+ +

@previewData.TenantCount

+ Tenants
-
-
-
- -

@previewData.LeaseCount

-

Leases

+
+
+
+ +

@previewData.LeaseCount

+ Leases
-
-
-
- -

@previewData.InvoiceCount

-

Invoices

+
+
+
+ +

@previewData.InvoiceCount

+ Invoices +
+
+
+
+
+
+ +

@previewData.PaymentCount

+ Payments +
+
+
+
+
+
+ +

@previewData.MaintenanceCount

+ Maintenance +
+
+
+
+
+
+ +

@previewData.RepairCount

+ Repairs +
+
+
+
+ + +
+
+
+
+ +

@previewData.CalendarEventCount

+ Calendar Events +
+
+
+
+
+
+ +

@previewData.InspectionCount

+ Inspections +
+
+
+
+
+
+ +

@previewData.ChecklistCount

+ Checklists +
+
+
+
+
+
+ +

@previewData.ChecklistItemCount

+ Checklist Items +
+
+
+
+
+
+ +

@previewData.SecurityDepositCount

+ Security Deposits +
+
+
+
+
+
+ +

@previewData.NoteCount

+ Notes +
+
+
+
+
+
+ +

@previewData.NotificationCount

+ Notifications
- -
+ +
-
-
- - @if (activeTab == "properties") +
+ + @if (activeTab == "properties") + { + @if (previewData.Properties?.Any() == true) { -
- @if (previewData.Properties?.Any() == true) +
+ + + + + + + + + + + + + @foreach (var property in previewData.Properties) + { + + + + + + + + + } + +
AddressCityStateTypeStatusMonthly Rent
@property.Address@property.City@property.State@property.PropertyType@property.Status@(property.MonthlyRent?.ToString("C") ?? "—")
+
+ @if (previewData.PropertyCount > 100) { -
- - +
+ Showing first 100 of @previewData.PropertyCount properties. All records will be imported. +
+ } + } + else + { +

No properties found

+ } + } + + + @if (activeTab == "tenants") + { + @if (previewData.Tenants?.Any() == true) + { +
+
+ + + + + + + + + + @foreach (var tenant in previewData.Tenants) + { - - - - - + + + + - - - @foreach (var property in previewData.Properties) - { - - - - - - - - } - -
NameEmailPhoneAdded
AddressCityStateTypeStatus@tenant.FullName@tenant.Email@tenant.Phone@tenant.CreatedOn.ToString("d")
@property.Address@property.City@property.State@property.PropertyType - @property.Status -
+ } + + +
+ @if (previewData.TenantCount > 100) + { +
+ Showing first 100 of @previewData.TenantCount tenants. All records will be imported.
} - else + } + else + { +

No tenants found

+ } + } + + + @if (activeTab == "leases") + { + @if (previewData.Leases?.Any() == true) + { +
+ + + + + + + + + + + + + @foreach (var lease in previewData.Leases) + { + + + + + + + + + } + +
PropertyTenantStart DateEnd DateMonthly RentStatus
@lease.PropertyAddress@lease.TenantName@lease.StartDate.ToString("d")@lease.EndDate.ToString("d")@lease.MonthlyRent.ToString("C") + @lease.Status +
+
+ } + else + { +

No leases found

+ } + } + + + @if (activeTab == "invoices") + { + @if (previewData.Invoices?.Any() == true) + { +
+ + + + + + + + + + + + + @foreach (var invoice in previewData.Invoices) + { + + + + + + + + + } + +
Invoice #PropertyTenantDue DateAmountStatus
@invoice.InvoiceNumber@invoice.PropertyAddress@invoice.TenantName@invoice.DueOn.ToString("d")@invoice.Amount.ToString("C")@invoice.Status
+
+ @if (previewData.InvoiceCount > 100) { -

No properties found

+
+ Showing first 100 of @previewData.InvoiceCount invoices. All records will be imported. +
} -
} + else + { +

No invoices found

+ } + } - - @if (activeTab == "tenants") + + @if (activeTab == "payments") + { + @if (previewData.Payments?.Any() == true) { -
- @if (previewData.Tenants?.Any() == true) +
+ + + + + + + + + + + + @foreach (var payment in previewData.Payments) + { + + + + + + + + } + +
Payment #Invoice #Paid OnAmountMethod
@payment.PaymentNumber@payment.InvoiceNumber@payment.PaidOn.ToString("d")@payment.Amount.ToString("C")@payment.PaymentMethod
+
+ @if (previewData.PaymentCount > 100) { -
- - +
+ Showing first 100 of @previewData.PaymentCount payments. All records will be imported. +
+ } + } + else + { +

No payments found

+ } + } + + + @if (activeTab == "maintenance") + { + @if (previewData.MaintenanceRequests?.Any() == true) + { +
+
+ + + + + + + + + + + + @foreach (var request in previewData.MaintenanceRequests) + { - - - - + + + + + + - - - @foreach (var tenant in previewData.Tenants) - { - - - - - - - } - -
TitlePropertyTypePriorityStatusRequested
NameEmailPhoneCreated@request.Title@request.PropertyAddress@request.RequestType + @request.Priority + @request.Status@request.RequestedOn.ToString("d")
@tenant.FullName@tenant.Email@tenant.Phone@tenant.CreatedOn.ToString("d")
+ } + + +
+ @if (previewData.MaintenanceCount > 100) + { +
+ Showing first 100 of @previewData.MaintenanceCount requests. All records will be imported.
} - else + } + else + { +

No maintenance requests found

+ } + } + + + @if (activeTab == "repairs") + { + @if (previewData.Repairs?.Any() == true) + { +
+ + + + + + + + + + + + @foreach (var repair in previewData.Repairs) + { + + + + + + + + } + +
DescriptionPropertyTypeCostCompleted
@repair.Description@repair.PropertyAddress@repair.RepairType@repair.Cost.ToString("C")@(repair.CompletedOn.HasValue ? repair.CompletedOn.Value.ToString("d") : "In Progress")
+
+ @if (previewData.RepairCount > 100) { -

No tenants found

+
+ Showing first 100 of @previewData.RepairCount repairs. All records will be imported. +
} -
} + else + { +

No repairs found

+ } + } - - @if (activeTab == "leases") + + @if (activeTab == "calendarevents") + { + @if (previewData.CalendarEvents?.Any() == true) { -
- @if (previewData.Leases?.Any() == true) +
+ + + + + + + + + + + @foreach (var ev in previewData.CalendarEvents) + { + + + + + + + } + +
TitleTypeStatusStarts On
@ev.Title@ev.EventType@ev.Status@ev.StartOn.ToString("g")
+
+ @if (previewData.CalendarEventCount > 100) { -
- - +
+ Showing first 100 of @previewData.CalendarEventCount events. All records will be imported. +
+ } + } + else + { +

No calendar events found

+ } + } + + + @if (activeTab == "inspections") + { + @if (previewData.Inspections?.Any() == true) + { +
+
+ + + + + + + + + + @foreach (var ins in previewData.Inspections) + { - - - - - - + + + + - - - @foreach (var lease in previewData.Leases) - { - - - - - - - - - } - -
PropertyTypeInspected ByCompleted On
PropertyTenantStart DateEnd DateMonthly RentStatus@ins.PropertyAddress@ins.InspectionType@(ins.InspectedBy ?? "—")@ins.CompletedOn.ToString("d")
@lease.PropertyAddress@lease.TenantName@lease.StartDate.ToString("d")@lease.EndDate.ToString("d")@lease.MonthlyRent.ToString("C") - - @lease.Status - -
+ } + + +
+ @if (previewData.InspectionCount > 100) + { +
+ Showing first 100 of @previewData.InspectionCount inspections. All records will be imported.
} - else + } + else + { +

No inspections found

+ } + } + + + @if (activeTab == "checklists") + { + @if (previewData.Checklists?.Any() == true) + { +
+ + + + + + + + + + @foreach (var cl in previewData.Checklists) + { + + + + + + } + +
NameTypeStatus
@cl.Name@cl.ChecklistType@cl.Status
+
+ @if (previewData.ChecklistCount > 100) { -

No leases found

+
+ Showing first 100 of @previewData.ChecklistCount checklists. All records will be imported. +
} -
} -
+ else + { +

No checklists found

+ } + } + + + @if (activeTab == "checklistitems") + { + @if (previewData.ChecklistItems?.Any() == true) + { +
+ + + + + + + + + @foreach (var item in previewData.ChecklistItems) + { + + + + + } + +
#Item
@item.ItemOrder@item.ItemText
+
+ @if (previewData.ChecklistItemCount > 100) + { +
+ Showing first 100 of @previewData.ChecklistItemCount items. All records will be imported. +
+ } + } + else + { +

No checklist items found

+ } + } + + + @if (activeTab == "securitydeposits") + { + @if (previewData.SecurityDeposits?.Any() == true) + { +
+ + + + + + + + + + + + @foreach (var sd in previewData.SecurityDeposits) + { + + + + + + + + } + +
TenantAmountMethodStatusReceived
@sd.TenantName@sd.Amount.ToString("C")@sd.PaymentMethod@sd.Status@sd.DateReceived.ToString("d")
+
+ @if (previewData.SecurityDepositCount > 100) + { +
+ Showing first 100 of @previewData.SecurityDepositCount deposits. All records will be imported. +
+ } + } + else + { +

No security deposits found

+ } + } + + + @if (activeTab == "notes") + { + @if (previewData.Notes?.Any() == true) + { +
+ + + + + + + + + + @foreach (var note in previewData.Notes) + { + + + + + + } + +
Entity TypeContentDate
@note.EntityType@note.Content@(note.Content.Length >= 120 ? "…" : "")@(note.CreatedOn.HasValue ? note.CreatedOn.Value.ToString("d") : "—")
+
+ @if (previewData.NoteCount > 100) + { +
+ Showing first 100 of @previewData.NoteCount notes. All records will be imported. +
+ } + } + else + { +

No notes found

+ } + } + + + @if (activeTab == "notifications") + { + @if (previewData.Notifications?.Any() == true) + { +
+ + + + + + + + + + + @foreach (var noti in previewData.Notifications) + { + + + + + + + } + +
TitleTypeCategorySent On
@noti.Title@noti.Type@noti.Category@noti.SentOn.ToString("g")
+
+ @if (previewData.NotificationCount > 100) + { +
+ Showing first 100 of @previewData.NotificationCount notifications. All records will be imported. +
+ } + } + else + { +

No notifications found

+ } + }
- -
- - Coming Soon: Selective data import feature will allow you to import specific properties, tenants, or leases from this backup into your active database. + +
+
+
Import Data
+
+
+ @if (importResult != null) + { + @if (importResult.Success) + { + var alertClass = importResult.TotalImported > 0 ? "alert-success" : "alert-warning"; + var icon = importResult.TotalImported > 0 ? "bi-check-circle-fill" : "bi-info-circle-fill"; +
+
Import Complete
+

@importResult.Message

+ @if (importResult.TotalImported > 0) + { +
    + @if (importResult.PropertiesImported > 0) + { +
  • @importResult.PropertiesImported properties
  • + } + @if (importResult.TenantsImported > 0) + { +
  • @importResult.TenantsImported tenants
  • + } + @if (importResult.LeasesImported > 0) + { +
  • @importResult.LeasesImported leases
  • + } + @if (importResult.InvoicesImported > 0) + { +
  • @importResult.InvoicesImported invoices
  • + } + @if (importResult.PaymentsImported > 0) + { +
  • @importResult.PaymentsImported payments
  • + } + @if (importResult.MaintenanceRequestsImported > 0) + { +
  • @importResult.MaintenanceRequestsImported maintenance requests
  • + } + @if (importResult.RepairsImported > 0) + { +
  • @importResult.RepairsImported repairs
  • + } + @if (importResult.DocumentsImported > 0) + { +
  • @importResult.DocumentsImported documents
  • + } + @if (importResult.CalendarEventsImported > 0) + { +
  • @importResult.CalendarEventsImported calendar events
  • + } + @if (importResult.InspectionsImported > 0) + { +
  • @importResult.InspectionsImported inspections
  • + } + @if (importResult.ChecklistsImported > 0) + { +
  • @importResult.ChecklistsImported checklists
  • + } + @if (importResult.ChecklistItemsImported > 0) + { +
  • @importResult.ChecklistItemsImported checklist items
  • + } + @if (importResult.SecurityDepositsImported > 0) + { +
  • @importResult.SecurityDepositsImported security deposits
  • + } + @if (importResult.NotesImported > 0) + { +
  • @importResult.NotesImported notes
  • + } + @if (importResult.NotificationsImported > 0) + { +
  • @importResult.NotificationsImported notifications
  • + } +
+ } + else + { +

All records from this backup already exist in the active database (matched by ID). No new records were added.

+ } +
+ } + else + { +
+
Import Failed
+

@importResult.Message

+
+ } + @if (importResult.Errors.Count > 0) + { +
+
Warnings / Skipped Tables
+
    + @foreach (var err in importResult.Errors) + { +
  • @err
  • + } +
+
+ } + } + else + { +

+ Import all data from this backup into your active database. Records are matched by ID — existing records are skipped to avoid duplicates. +

+
+ + Tip: Back up your current database first from + Database Settings. +
+ } + @if (!showImportConfirm && importResult == null) + { + + } + @if (showImportConfirm && importResult == null) + { +
+ + This will import @previewData.PropertyCount properties, @previewData.TenantCount tenants, + @previewData.LeaseCount leases and more. Existing records are skipped. + + + +
+ } +
+
+ } + else if (!string.IsNullOrEmpty(errorMessage)) + { +
+
+
+
Error Loading Preview
+

@errorMessage

+
+ +
}
@@ -301,46 +1084,45 @@ public string BackupFileName { get; set; } = string.Empty; private string DecodedFileName => Uri.UnescapeDataString(BackupFileName); - private string activeTab = "properties"; // Track active tab state - + private string activeTab = "properties"; + private bool needsPassword = false; private bool isUnlocking = false; private bool isLoading = false; private bool isUnlocked = false; + private bool isImporting = false; + private bool showImportConfirm = false; private string enteredPassword = string.Empty; private string? errorMessage; - private string? sessionPassword; // Temporary password not saved to keychain - + private string? sessionPassword; + private string loadingMessage = "Loading database preview..."; + private DatabasePreviewData? previewData; + private ImportResult? importResult; protected override async Task OnInitializedAsync() { isLoading = true; - + try { - // Check if database is encrypted needsPassword = await PreviewService.IsDatabaseEncryptedAsync(DecodedFileName); - + if (!needsPassword) { - // Load preview data immediately for unencrypted databases await LoadPreviewData(password: null); } else { - // For encrypted databases, try keychain password first var keychainPassword = await PreviewService.TryGetKeychainPasswordAsync(); if (!string.IsNullOrEmpty(keychainPassword)) { var result = await PreviewService.VerifyPasswordAsync(DecodedFileName, keychainPassword); if (result.Success) { - // Keychain password works await LoadPreviewData(keychainPassword); needsPassword = false; } - // else: Wrong password, show prompt } } } @@ -364,16 +1146,14 @@ isUnlocking = true; errorMessage = null; - + try { var result = await PreviewService.VerifyPasswordAsync(DecodedFileName, enteredPassword); - + if (result.Success) { - // Store as session password (never save to keychain for preview) sessionPassword = enteredPassword; - await LoadPreviewData(enteredPassword); isUnlocked = true; needsPassword = false; @@ -396,7 +1176,8 @@ private async Task LoadPreviewData(string? password) { isLoading = true; - + loadingMessage = "Loading database preview..."; + try { previewData = await PreviewService.GetPreviewDataAsync(DecodedFileName, password); @@ -412,6 +1193,32 @@ } } + private async Task RunImport() + { + isImporting = true; + isLoading = true; + loadingMessage = "Importing data — this may take a moment..."; + + try + { + importResult = await PreviewService.ImportFromPreviewAsync(DecodedFileName, sessionPassword); + showImportConfirm = false; + } + catch (Exception ex) + { + importResult = new ImportResult + { + Success = false, + Message = $"Unexpected error: {ex.Message}" + }; + } + finally + { + isImporting = false; + isLoading = false; + } + } + private void BackToSettings() { Navigation.NavigateTo("/administration/settings/database"); @@ -420,24 +1227,27 @@ private async Task HandlePasswordKeyPress(KeyboardEventArgs e) { if (e.Key == "Enter") - { await UnlockDatabase(); - } - } - - private string GetLeaseStatusClass(string status) - { - return status switch - { - "Active" => "bg-success", - "Expired" => "bg-danger", - "Terminated" => "bg-warning", - _ => "bg-secondary" - }; } private void SwitchTab(string tab) { activeTab = tab; } + + private static string GetLeaseStatusClass(string status) => status switch + { + "Active" => "bg-success", + "Expired" => "bg-danger", + "Terminated" => "bg-warning text-dark", + _ => "bg-secondary" + }; + + private static string GetPriorityClass(string priority) => priority switch + { + "Critical" or "Emergency" => "bg-danger", + "High" => "bg-warning text-dark", + "Medium" => "bg-info text-dark", + _ => "bg-secondary" + }; } diff --git a/4-Nine/Features/Administration/Settings/Pages/DatabaseSettings.razor b/4-Nine/Features/Administration/Settings/Pages/DatabaseSettings.razor index 99d86c6..c4c949c 100644 --- a/4-Nine/Features/Administration/Settings/Pages/DatabaseSettings.razor +++ b/4-Nine/Features/Administration/Settings/Pages/DatabaseSettings.razor @@ -18,6 +18,8 @@ @inject IHostApplicationLifetime AppLifetime @inject IDatabaseService DatabaseService @inject DatabaseEncryptionService EncryptionService +@inject DatabasePreviewService PreviewService +@using Nine.Application.Models.DTOs @inject PasswordDerivationService PasswordDerivation @inject UserContextService UserContext @attribute [OrganizationAuthorize("Owner", "Administrator")] @@ -347,6 +349,65 @@
+ + + @if (otherDatabases.Any()) + { +
+
+
+
+
Previous Database Versions Found
+
+
+

+ The following database files were found in your data directory. These are typically from a previous version of Nine. + You can preview them or add them to the backup list to import their data. +

+
+ + + + + + + + + + + @foreach (var db in otherDatabases) + { + + + + + + + } + +
File NameLast ModifiedSizeActions
@db.FileName@db.LastModified.ToString("g")@db.FileSizeFormatted + + + +
+
+
+
+
+
+ } +
@@ -371,6 +432,7 @@ @code { private List backups = new(); + private List otherDatabases = new(); private string? successMessage; private string? errorMessage; private bool isLoadingBackups = false; @@ -519,6 +581,7 @@ try { backups = await BackupService.GetAvailableBackupsAsync(); + otherDatabases = await PreviewService.GetOtherDatabaseFilesAsync(); } catch (Exception ex) { @@ -648,18 +711,15 @@ try { - // Delete the backup file - if (File.Exists(backup.FilePath)) + var result = await PreviewService.DeleteDatabaseFileAsync(backup.FilePath); + if (result.Success) { - File.Delete(backup.FilePath); successMessage = $"Backup '{backup.FileName}' deleted successfully."; - - // Refresh the backup list await LoadBackups(); } else { - errorMessage = $"Backup file not found: {backup.FileName}"; + errorMessage = result.Message; } } catch (Exception ex) @@ -674,11 +734,71 @@ private void PreviewDatabase(BackupInfo backup) { - // Encode filename for URL var encodedFileName = Uri.EscapeDataString(backup.FileName); Navigation.NavigateTo($"/administration/database/preview/{encodedFileName}"); } + private void PreviewOtherDatabase(OtherDatabaseFile db) + { + var encodedFileName = Uri.EscapeDataString(db.FileName); + Navigation.NavigateTo($"/administration/database/preview/{encodedFileName}"); + } + + private async Task AddOtherDbToBackups(OtherDatabaseFile db) + { + isLoadingBackups = true; + try + { + var result = await PreviewService.AddToBackupsAsync(db.FilePath); + if (result.Success) + await LoadBackups(); + else + errorMessage = result.Message; + } + catch (Exception ex) + { + errorMessage = $"Failed to add database: {ex.Message}"; + } + finally + { + isLoadingBackups = false; + } + } + + private async Task DeleteOtherDatabase(OtherDatabaseFile db) + { + var confirmed = await JSRuntime.InvokeAsync("confirm", + $"Are you sure you want to delete '{db.FileName}'?\n\nThis cannot be undone."); + + if (!confirmed) return; + + isDeleting = true; + errorMessage = null; + successMessage = null; + + try + { + var result = await PreviewService.DeleteDatabaseFileAsync(db.FilePath); + if (result.Success) + { + successMessage = $"'{db.FileName}' deleted successfully."; + otherDatabases = await PreviewService.GetOtherDatabaseFilesAsync(); + } + else + { + errorMessage = result.Message; + } + } + catch (Exception ex) + { + errorMessage = $"Error deleting file: {ex.Message}"; + } + finally + { + isDeleting = false; + } + } + private async Task AttemptAutoRecovery() { var confirmed = await JSRuntime.InvokeAsync("confirm", diff --git a/4-Nine/Features/DatabaseUnlock/Index.razor b/4-Nine/Features/DatabaseUnlock/Index.razor index 8e7c19d..829e5c4 100644 --- a/4-Nine/Features/DatabaseUnlock/Index.razor +++ b/4-Nine/Features/DatabaseUnlock/Index.razor @@ -4,13 +4,47 @@ @using ElectronNET.API @inject DatabaseUnlockState UnlockState @inject DatabaseUnlockService UnlockService -@inject NavigationManager Navigation @implements IDisposable @rendermode InteractiveServer
- @if (!unlockSuccessful) + @if (UnlockState.IsUnsupportedSchema) + { + +
+ +
+ +

Database Cannot Be Upgraded

+

+ Your database is from an older version that cannot be automatically upgraded to Nine. + Your data has been backed up and is safe. +

+ + @if (!string.IsNullOrEmpty(UnlockState.UnsupportedSchemaBackupPath)) + { + + } + + + + + } + else if (!unlockSuccessful) {
@@ -93,33 +127,53 @@ } else { - +
- -

Database Unlocked!

+ +

Password Saved!

- Your password has been verified and stored securely. + Your password has been verified and stored securely in the system keychain.

- - -
- - The application will restart to load your database. - -
+ @if (UnlockState.RequiresRestartAfterUnlock) + { + +
+ + Nine will quit and you can relaunch it to complete the database update. + +
+ } + else + { + +
+ + The application will reload to open your database. + +
+ } }
@@ -137,20 +191,42 @@