From f5dbb00de367c78920f410c4076c1cce76cb9ec4 Mon Sep 17 00:00:00 2001 From: CIS Guru Date: Wed, 11 Mar 2026 06:44:39 -0500 Subject: [PATCH 1/9] fix: AppImage packaging and branding corrections - electron-builder.json: restore entry wrapper with full desktop fields (Comment, Name, Icon, Type, Categories, Keywords, etc.) required by electron-builder 26.0.x schema; add co.nineapp paths for extraFiles - Rename com.nineapp.nine.appdata.xml -> co.nineapp.nine.appdata.xml; update internal and from com.nineapp to co.nineapp - nine.desktop: mark as reference template (electron-builder generates its own) - LinuxKeychainService: rename Schema constant from org.aquiis.database to co.nineapp.database - Remove desktop-config.bk scratch file --- .../Services/LinuxKeychainService.cs | 2 +- ...ppdata.xml => co.nineapp.nine.appdata.xml} | 4 +- 4-Nine/Assets/nine.desktop | 7 +- 4-Nine/Properties/electron-builder.json | 8 ++ 4-Nine/desktop-config.bk | 79 ------------------- 5 files changed, 17 insertions(+), 83 deletions(-) rename 4-Nine/Assets/{com.nineapp.nine.appdata.xml => co.nineapp.nine.appdata.xml} (98%) delete mode 100644 4-Nine/desktop-config.bk diff --git a/1-Nine.Infrastructure/Services/LinuxKeychainService.cs b/1-Nine.Infrastructure/Services/LinuxKeychainService.cs index 5301fd5..39e2b14 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; diff --git a/4-Nine/Assets/com.nineapp.nine.appdata.xml b/4-Nine/Assets/co.nineapp.nine.appdata.xml similarity index 98% rename from 4-Nine/Assets/com.nineapp.nine.appdata.xml rename to 4-Nine/Assets/co.nineapp.nine.appdata.xml index af063a8..28328c4 100644 --- a/4-Nine/Assets/com.nineapp.nine.appdata.xml +++ b/4-Nine/Assets/co.nineapp.nine.appdata.xml @@ -1,6 +1,6 @@ - com.nineapp.nine + co.nineapp.nine MIT MIT Nine @@ -29,7 +29,7 @@

- com.nineapp.nine.desktop + co.nineapp.nine.desktop diff --git a/4-Nine/Assets/nine.desktop b/4-Nine/Assets/nine.desktop index f18c570..19a7b30 100644 --- a/4-Nine/Assets/nine.desktop +++ b/4-Nine/Assets/nine.desktop @@ -1,8 +1,13 @@ +# This file serves as a reference template for the desktop entry fields +# configured in electron-builder.json under linux.desktop. +# electron-builder generates the actual embedded desktop file at build time; +# the values here should remain in sync with that configuration. + [Desktop Entry] Name=Nine Comment=Property management desktop application. Exec=Nine-1.0.0-x86_64.AppImage -Icon=nine +Icon=Nine Type=Application Categories=Office;Finance; Terminal=false diff --git a/4-Nine/Properties/electron-builder.json b/4-Nine/Properties/electron-builder.json index c3871a2..606366c 100644 --- a/4-Nine/Properties/electron-builder.json +++ b/4-Nine/Properties/electron-builder.json @@ -33,6 +33,14 @@ "artifactName": "${productName}-${version}-${arch}.${ext}", "desktop": { "entry": { + "Name": "Nine", + "Comment": "Property management desktop application.", + "Icon": "Nine", + "Type": "Application", + "Categories": "Office;Finance;", + "Terminal": "false", + "StartupWMClass": "Nine", + "Keywords": "property;management;landlord;rental;lease;tenant;invoice;", "X-AppImage-Payload-License": "MIT" } }, diff --git a/4-Nine/desktop-config.bk b/4-Nine/desktop-config.bk deleted file mode 100644 index 8fb2dc1..0000000 --- a/4-Nine/desktop-config.bk +++ /dev/null @@ -1,79 +0,0 @@ -{ - "executable": "Nine", - "splashscreen": { - "imageFile": "wwwroot/assets/splash.png" - }, - "electronCLIFlags": [ - "--enable-features=VaapiVideoDecoder", - "--disable-dev-shm-usage" - ], - "name": "nine", - "description": "Nine Property Management", - "author": "Nine", - "singleInstance": false, - "environment": "Production", - "aspCoreBackendPort": 8888, - "build": { - "appId": "com.nineapp.nine", - "productName": "Nine", - "copyright": "Copyright © 2026", - "buildVersion": "1.0.0", - "compression": "normal", - "directories": { - "output": "../../../bin/Desktop" - }, - "extraResources": [ - { - "from": "./bin", - "to": "bin", - "filter": ["**/*"] - } - ], - "files": [ - { - "from": "./ElectronHostHook/node_modules", - "to": "ElectronHostHook/node_modules", - "filter": ["**/*"] - }, - "**/*" - ], - "mac": { - "target": "dmg", - "icon": "bin/Assets/icon.icns", - "category": "public.app-category.business" - }, - "win": { - "target": ["nsis", "portable"], - "icon": "bin/Assets/icon.ico", - "artifactName": "${productName}-${version}-${arch}-Setup.${ext}" - }, - "nsis": { - "oneClick": false, - "perMachine": false, - "allowToChangeInstallationDirectory": true, - "createDesktopShortcut": true, - "createStartMenuShortcut": true, - "shortcutName": "Nine" - }, - "portable": { - "artifactName": "${productName}-${version}-${arch}-Portable.${ext}" - }, - "linux": { - "target": "AppImage", - "icon": "bin/Assets/icon.png", - "category": "Office", - "artifactName": "${productName}-${version}-${arch}.${ext}", - "desktop": { - "entry": { - "X-AppImage-Payload-License": "MIT" - } - }, - "extraFiles": [ - { - "from": "Assets/com.nineapp.nine.appdata.xml", - "to": "usr/share/metainfo/com.nineapp.nine.appdata.xml" - } - ] - } - } -} From 5dece452531e05ffa74b6f85c1f1bcda7b3c338e Mon Sep 17 00:00:00 2001 From: CIS Guru Date: Wed, 11 Mar 2026 11:47:51 -0500 Subject: [PATCH 2/9] Fix workflow crashes, auth guards, security issues, and logo switching - UserContextService: catch InvalidOperationException outside Blazor circuit - BaseWorkflowService: optional organizationId param in LogTransitionAsync - LeaseWorkflowService: pass organizationId to LogTransitionAsync in ExpireOverdueLeaseAsync - ApplicationWorkflowService: bypass user-context when organizationId provided in ExpireLeaseOfferAsync - ScheduledTaskService: pass offer.OrganizationId to ExpireLeaseOfferAsync - DatabaseService: add OrderBy to fix EF non-deterministic FirstOrDefault warning - NotificationService: explicit senderUserId param; require auth or SystemUser.Id; set CreatedBy/LastModifiedBy - BaseService: explicit opt-in system user guard in CreateAsync/UpdateAsync; no silent auth escalation - LinuxKeychainService: remove plaintext password from console output (security fix) - WindowsKeychainService: remove plaintext credential from console output (security fix) - NavMenu: switch logo based on brand theme + light/dark mode (Obsidian=always inverted; Bootstrap/Teal=inverted in dark only) - ThemeService/theme.js: related theme handling consistency fixes --- .../Services/LinuxKeychainService.cs | 2 +- .../Services/WindowsKeychainService.cs | 8 ++--- 2-Nine.Application/Services/BaseService.cs | 10 ++++-- .../Services/DatabaseService.cs | 2 +- .../Services/NotificationService.cs | 35 +++++++++++++++---- .../Services/ScheduledTaskService.cs | 4 +-- .../Workflows/ApplicationWorkflowService.cs | 8 +++-- .../Services/Workflows/BaseWorkflowService.cs | 11 +++--- .../Workflows/LeaseWorkflowService.cs | 4 ++- 3-Nine.Shared.UI/wwwroot/js/theme.js | 8 ++--- 4-Nine/Shared/Layout/NavMenu.razor | 11 ++++-- 4-Nine/Shared/Services/ThemeService.cs | 4 +-- 4-Nine/Shared/Services/UserContextService.cs | 27 ++++++++++++-- 4-Nine/wwwroot/assets/nine-logo.svg | 35 +------------------ 14 files changed, 98 insertions(+), 71 deletions(-) diff --git a/1-Nine.Infrastructure/Services/LinuxKeychainService.cs b/1-Nine.Infrastructure/Services/LinuxKeychainService.cs index 39e2b14..503122c 100644 --- a/1-Nine.Infrastructure/Services/LinuxKeychainService.cs +++ b/1-Nine.Infrastructure/Services/LinuxKeychainService.cs @@ -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/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/DatabaseService.cs b/2-Nine.Application/Services/DatabaseService.cs index 7a2527a..e92db66 100644 --- a/2-Nine.Application/Services/DatabaseService.cs +++ b/2-Nine.Application/Services/DatabaseService.cs @@ -93,7 +93,7 @@ public async Task GetIdentityPendingMigrationsCountAsync() /// 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/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/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..b871bb9 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 @@ -615,7 +616,8 @@ await LogTransitionAsync( 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"; diff --git a/3-Nine.Shared.UI/wwwroot/js/theme.js b/3-Nine.Shared.UI/wwwroot/js/theme.js index b496cc4..ecb64c9 100644 --- a/3-Nine.Shared.UI/wwwroot/js/theme.js +++ b/3-Nine.Shared.UI/wwwroot/js/theme.js @@ -47,7 +47,7 @@ window.themeManager = { }, getBrandTheme: function () { - const brandTheme = localStorage.getItem("brandTheme") || "bootstrap"; + const brandTheme = localStorage.getItem("brandTheme") || "obsidian"; return brandTheme; }, }; @@ -55,7 +55,7 @@ window.themeManager = { // Initialize theme IMMEDIATELY (before DOMContentLoaded) to prevent flash if (typeof localStorage !== "undefined") { const savedTheme = localStorage.getItem("theme") || "light"; - const savedBrandTheme = localStorage.getItem("brandTheme") || "bootstrap"; + const savedBrandTheme = localStorage.getItem("brandTheme") || "obsidian"; console.log("Initial theme load:", savedTheme, "Brand:", savedBrandTheme); document.documentElement.setAttribute("data-bs-theme", savedTheme); document.documentElement.setAttribute("data-brand-theme", savedBrandTheme); @@ -98,7 +98,7 @@ if (typeof localStorage !== "undefined") { } if (!currentBrandTheme) { - currentBrandTheme = localStorage.getItem("brandTheme") || "bootstrap"; + currentBrandTheme = localStorage.getItem("brandTheme") || "obsidian"; document.documentElement.setAttribute( "data-brand-theme", currentBrandTheme, @@ -131,7 +131,7 @@ if (typeof localStorage !== "undefined") { const currentBrandTheme = document.documentElement.getAttribute("data-brand-theme") || localStorage.getItem("brandTheme") || - "bootstrap"; + "obsidian"; document.documentElement.setAttribute("data-bs-theme", currentTheme); document.documentElement.setAttribute( diff --git a/4-Nine/Shared/Layout/NavMenu.razor b/4-Nine/Shared/Layout/NavMenu.razor index 20590a8..ae4cdf0 100644 --- a/4-Nine/Shared/Layout/NavMenu.razor +++ b/4-Nine/Shared/Layout/NavMenu.razor @@ -8,12 +8,12 @@ @@ -171,6 +171,11 @@ private string currentTheme = "light"; private bool showBrandThemeDropdown = false; + // Obsidian always uses the inverted logo. Bootstrap and Teal use inverted in dark mode only. + private string LogoSrc => (ThemeService.CurrentBrandTheme == "obsidian" || ThemeService.CurrentTheme == "dark") + ? "/assets/nine-logo-inverted.svg" + : "/assets/nine-logo.svg"; + private string OrganizationId = string.Empty; // Available brand themes - add new themes here diff --git a/4-Nine/Shared/Services/ThemeService.cs b/4-Nine/Shared/Services/ThemeService.cs index a4b1d4f..585ab6f 100644 --- a/4-Nine/Shared/Services/ThemeService.cs +++ b/4-Nine/Shared/Services/ThemeService.cs @@ -3,7 +3,7 @@ namespace Nine.Shared.Services; public class ThemeService { private string _currentTheme = "light"; - private string _currentBrandTheme = "bootstrap"; + private string _currentBrandTheme = "obsidian"; public event Action? OnThemeChanged; public event Action? OnBrandThemeChanged; @@ -35,8 +35,8 @@ public string GetNextTheme() // Valid brand themes - add new themes here when implementing them private readonly HashSet _validBrandThemes = new() { - "bootstrap", "obsidian", + "bootstrap", "teal" }; diff --git a/4-Nine/Shared/Services/UserContextService.cs b/4-Nine/Shared/Services/UserContextService.cs index 1db7b92..6ae322e 100644 --- a/4-Nine/Shared/Services/UserContextService.cs +++ b/4-Nine/Shared/Services/UserContextService.cs @@ -114,11 +114,20 @@ public UserContextService( /// /// Checks if a user is authenticated. + /// Returns false when called from a non-Blazor context such as background services. /// public async Task IsAuthenticatedAsync() { - var authState = await _authenticationStateProvider.GetAuthenticationStateAsync(); - return authState.User.Identity?.IsAuthenticated ?? false; + try + { + var authState = await _authenticationStateProvider.GetAuthenticationStateAsync(); + return authState.User.Identity?.IsAuthenticated ?? false; + } + catch (InvalidOperationException) + { + // Called outside a Blazor circuit (e.g., background/scheduled services). + return false; + } } /// @@ -339,7 +348,19 @@ private async Task EnsureInitializedAsync() if (_isInitialized) return; - var authState = await _authenticationStateProvider.GetAuthenticationStateAsync(); + AuthenticationState? authState; + try + { + authState = await _authenticationStateProvider.GetAuthenticationStateAsync(); + } + catch (InvalidOperationException) + { + // Called outside a Blazor circuit (e.g., background/scheduled services). + // Treat as unauthenticated — userId and organizationId will be null. + _isInitialized = true; + return; + } + var user = authState.User; if (user.Identity?.IsAuthenticated == true) diff --git a/4-Nine/wwwroot/assets/nine-logo.svg b/4-Nine/wwwroot/assets/nine-logo.svg index 7814f8f..20d2c7d 100644 --- a/4-Nine/wwwroot/assets/nine-logo.svg +++ b/4-Nine/wwwroot/assets/nine-logo.svg @@ -1,34 +1 @@ - - - - - - - - - - + \ No newline at end of file From 1dab2090da00238460b3f26b6bc715ff2310da7a Mon Sep 17 00:00:00 2001 From: CIS Guru Date: Thu, 12 Mar 2026 15:13:54 -0500 Subject: [PATCH 3/9] Phase 18: Database preview/import, version upgrade path, and compatibility matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add DatabasePreviewService with full import pipeline (table mapping, GUID normalization, FK ordering) - Add DatabasePreview.razor UI with preview table, conflict detection, and import confirmation - Update DatabaseSettings.razor with import trigger and status feedback - Fix GUID case mismatch bug: normalize all GUIDs to uppercase before raw SQL writes - Add version-upgrade copy block to Program.cs: copies PreviousDbFileName → new DbFileName on first launch after version bump so EF migrations apply against real data instead of blank DB - Update Compatibility-Matrix.md with versioning policy and DB filename conventions - Minor: LeaseNotificationService null guard, LeaseWorkflowService status transition fix, Index.razor property count tweak --- .../Models/DTOs/DatabasePreviewDTOs.cs | 107 ++- .../Services/DatabasePreviewService.cs | 818 +++++++++++++++--- .../Services/LeaseNotificationService.cs | 3 + .../Workflows/LeaseWorkflowService.cs | 19 +- .../Settings/Pages/DatabasePreview.razor | 752 ++++++++++++---- .../Settings/Pages/DatabaseSettings.razor | 86 +- .../Properties/Pages/Index.razor | 1 + 4-Nine/Program.cs | 29 + Documentation/Compatibility-Matrix.md | 39 +- 9 files changed, 1507 insertions(+), 347 deletions(-) diff --git a/2-Nine.Application/Models/DTOs/DatabasePreviewDTOs.cs b/2-Nine.Application/Models/DTOs/DatabasePreviewDTOs.cs index c4c685f..7bacd25 100644 --- a/2-Nine.Application/Models/DTOs/DatabasePreviewDTOs.cs +++ b/2-Nine.Application/Models/DTOs/DatabasePreviewDTOs.cs @@ -10,10 +10,17 @@ 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 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(); } /// @@ -60,6 +67,100 @@ 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; } +} + +/// +/// 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 List Errors { get; set; } = new(); + + public int TotalImported => PropertiesImported + TenantsImported + LeasesImported + + InvoicesImported + PaymentsImported + MaintenanceRequestsImported + RepairsImported + DocumentsImported; +} + /// /// Result object for database operations /// @@ -67,10 +168,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/DatabasePreviewService.cs b/2-Nine.Application/Services/DatabasePreviewService.cs index e8eda46..5b9d3fe 100644 --- a/2-Nine.Application/Services/DatabasePreviewService.cs +++ b/2-Nine.Application/Services/DatabasePreviewService.cs @@ -1,5 +1,6 @@ using Nine.Application.Models.DTOs; -using Nine.Core.Entities; +using Nine.Core.Interfaces; +using Nine.Core.Interfaces.Services; using Nine.Infrastructure.Data; using Nine.Infrastructure.Interfaces; using Microsoft.Data.Sqlite; @@ -9,79 +10,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 +114,681 @@ 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}"; + // ------------------------------------------------------------------------- + // 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. + // ------------------------------------------------------------------------- - 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 + 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" }; + + // ------------------------------------------------------------------------- + // 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); + + // ------------------------------------------------------------------------- + // Preview — raw SQL against backup, no EF model, schema-version tolerant + // ------------------------------------------------------------------------- - // Load preview data - var previewData = new DatabasePreviewData + /// + /// 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 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), }; - // 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); + + _logger.LogInformation( + "Preview loaded from {File}: {P} properties, {T} tenants, {L} leases, {I} invoices, {Pay} payments, {M} maintenance, {R} repairs", + backupFileName, + data.PropertyCount, data.TenantCount, data.LeaseCount, + data.InvoiceCount, data.PaymentCount, data.MaintenanceCount, data.RepairCount); + + 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 = 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(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 = 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), + 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 = 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(); - - _logger.LogInformation($"Loaded preview data: {previewData.PropertyCount} properties, {previewData.TenantCount} tenants, {previewData.LeaseCount} leases"); - - return previewData; + 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; } + // ------------------------------------------------------------------------- + // 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); + + 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, using the + /// intersection of columns that exist in both schemas. + /// OrganizationId is always substituted with the active org. + /// Returns the number of rows actually inserted. + /// + 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); + + // Validate required columns exist in backup + var missing = requiredCols + .Where(c => !backupCols.Contains(c)) + .ToList(); + if (missing.Count > 0) + { + errors.Add($"{tableName}: skipped — required columns missing: {string.Join(", ", missing)}"); + return 0; + } + + // Columns always overridden — never read from backup, always set to active-context values + var overrideCols = new HashSet(StringComparer.OrdinalIgnoreCase) + { "OrganizationId", "CreatedBy", "LastModifiedBy" }; + + // Intersection: columns present in both backup and active schemas, + // excluding all override cols (handled separately as literal overrides) + var sharedCols = backupCols + .Intersect(activeCols, StringComparer.OrdinalIgnoreCase) + .Where(c => !overrideCols.Contains(c)) + .OrderBy(c => c, StringComparer.OrdinalIgnoreCase) + .ToList(); + + // Columns to read from backup: Id first, then all other shared cols + var nonIdCols = sharedCols + .Where(c => !c.Equals("Id", StringComparison.OrdinalIgnoreCase)) + .ToList(); + var readCols = new[] { "Id" }.Concat(nonIdCols).ToArray(); + + // Columns to insert: Id, then overrides present in active schema, then the rest + var insertOverrides = new List { "OrganizationId" }; + if (activeCols.Contains("CreatedBy")) insertOverrides.Add("CreatedBy"); + if (activeCols.Contains("LastModifiedBy")) insertOverrides.Add("LastModifiedBy"); + + var insertCols = new[] { "Id" }.Concat(insertOverrides).Concat(nonIdCols).ToArray(); + var colList = string.Join(", ", insertCols.Select(c => $"[{c}]")); + var paramList = string.Join(", ", insertCols.Select(c => + c.Equals("OrganizationId", StringComparison.OrdinalIgnoreCase) ? "@orgId" : + c.Equals("CreatedBy", StringComparison.OrdinalIgnoreCase) ? "@currentUser" : + c.Equals("LastModifiedBy", StringComparison.OrdinalIgnoreCase) ? "@currentUser" : + $"@c_{c}")); + + 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 and reuse the command for each row + 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); + foreach (var col in readCols) + writeCmd.Parameters.Add(new SqliteParameter($"@c_{col}", DBNull.Value)); + + int count = 0; + foreach (var row in rows) + { + for (int i = 0; i < readCols.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_{readCols[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/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/Workflows/LeaseWorkflowService.cs b/2-Nine.Application/Services/Workflows/LeaseWorkflowService.cs index b871bb9..158c07f 100644 --- a/2-Nine.Application/Services/Workflows/LeaseWorkflowService.cs +++ b/2-Nine.Application/Services/Workflows/LeaseWorkflowService.cs @@ -624,14 +624,17 @@ await LogTransitionAsync( 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/4-Nine/Features/Administration/Settings/Pages/DatabasePreview.razor b/4-Nine/Features/Administration/Settings/Pages/DatabasePreview.razor index 16a5a49..d4519ee 100644 --- a/4-Nine/Features/Administration/Settings/Pages/DatabasePreview.razor +++ b/4-Nine/Features/Administration/Settings/Pages/DatabasePreview.razor @@ -20,27 +20,27 @@

This database is encrypted. Enter the password to preview its contents.

- + @if (!string.IsNullOrEmpty(errorMessage)) {
@errorMessage
} - +
-
Password is used for this preview session only (not saved)
- +
} 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,569 @@
-
-
-
-
- -

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

No properties found

+ } + } + + + @if (activeTab == "tenants") + { + @if (previewData.Tenants?.Any() == true) + { +
+ + + + + + + + + + + @foreach (var tenant in previewData.Tenants) + { + + + + + + + } + +
NameEmailPhoneAdded
@tenant.FullName@tenant.Email@tenant.Phone@tenant.CreatedOn.ToString("d")
+
+ @if (previewData.TenantCount > 100) { -

No properties found

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

No tenants found

+ } + } - - @if (activeTab == "tenants") + + @if (activeTab == "leases") + { + @if (previewData.Leases?.Any() == true) { -
- @if (previewData.Tenants?.Any() == true) - { -
- - +
+
+ + + + + + + + + + + + @foreach (var lease in previewData.Leases) + { - - - - + + + + + + - - - @foreach (var tenant in previewData.Tenants) - { - - - - - - - } - -
PropertyTenantStart DateEnd DateMonthly RentStatus
NameEmailPhoneCreated@lease.PropertyAddress@lease.TenantName@lease.StartDate.ToString("d")@lease.EndDate.ToString("d")@lease.MonthlyRent.ToString("C") + @lease.Status +
@tenant.FullName@tenant.Email@tenant.Phone@tenant.CreatedOn.ToString("d")
+ } + + +
+ } + 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) + { +
+ Showing first 100 of @previewData.InvoiceCount invoices. All records will be imported.
} - else + } + else + { +

No invoices found

+ } + } + + + @if (activeTab == "payments") + { + @if (previewData.Payments?.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) { -

No tenants found

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

No payments found

+ } + } - - @if (activeTab == "leases") + + @if (activeTab == "maintenance") + { + @if (previewData.MaintenanceRequests?.Any() == true) { -
- @if (previewData.Leases?.Any() == true) - { -
- - +
+
+ + + + + + + + + + + + @foreach (var request in previewData.MaintenanceRequests) + { - - - - - - + + + + + + - - - @foreach (var lease in previewData.Leases) - { - - - - - - - - - } - -
TitlePropertyTypePriorityStatusRequested
PropertyTenantStart DateEnd DateMonthly RentStatus@request.Title@request.PropertyAddress@request.RequestType + @request.Priority + @request.Status@request.RequestedOn.ToString("d")
@lease.PropertyAddress@lease.TenantName@lease.StartDate.ToString("d")@lease.EndDate.ToString("d")@lease.MonthlyRent.ToString("C") - - @lease.Status - -
+ } + + +
+ @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 leases found

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

No repairs 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
  • + } +
+ } + 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 +666,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 +728,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 +758,8 @@ private async Task LoadPreviewData(string? password) { isLoading = true; - + loadingMessage = "Loading database preview..."; + try { previewData = await PreviewService.GetPreviewDataAsync(DecodedFileName, password); @@ -412,6 +775,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 +809,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..988d46c 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,60 @@
+ + + @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 +427,7 @@ @code { private List backups = new(); + private List otherDatabases = new(); private string? successMessage; private string? errorMessage; private bool isLoadingBackups = false; @@ -519,6 +576,7 @@ try { backups = await BackupService.GetAvailableBackupsAsync(); + otherDatabases = await PreviewService.GetOtherDatabaseFilesAsync(); } catch (Exception ex) { @@ -674,11 +732,37 @@ 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 AttemptAutoRecovery() { var confirmed = await JSRuntime.InvokeAsync("confirm", diff --git a/4-Nine/Features/PropertyManagement/Properties/Pages/Index.razor b/4-Nine/Features/PropertyManagement/Properties/Pages/Index.razor index 8f5a833..16ea62e 100644 --- a/4-Nine/Features/PropertyManagement/Properties/Pages/Index.razor +++ b/4-Nine/Features/PropertyManagement/Properties/Pages/Index.razor @@ -74,6 +74,7 @@ protected override async Task OnInitializedAsync() { Properties = await PropertyService.GetAllAsync(); + Console.WriteLine($"Loaded {Properties.Count} properties from the service."); availableProperties = Properties.Where(p => p.IsAvailable).Count(); occupiedProperties = Properties.Where(p => !p.IsAvailable).Count(); diff --git a/4-Nine/Program.cs b/4-Nine/Program.cs index 69e9b4a..4200c7d 100644 --- a/4-Nine/Program.cs +++ b/4-Nine/Program.cs @@ -287,6 +287,35 @@ app.Logger.LogInformation("Database migration from Electron to Nine folder completed successfully"); } + // ✅ v2.0.0+: Automatic migration when DatabaseFileName version changes (e.g., app_v1.0.0.db → app_v2.0.0.db) + // When bump-version.sh updates DatabaseFileName, PreviousDatabaseFileName holds the old name. + // Without this block the app would not find the new filename and create a blank database, + // losing all user data. This copies the old file to the new name so that EF's normal + // pending-migration path can then apply any schema changes on top of the real data. + var previousDbFileName = app.Configuration["ApplicationSettings:PreviousDatabaseFileName"]; + if (!string.IsNullOrEmpty(previousDbFileName) && !File.Exists(dbPath)) + { + var previousDbPath = Path.Combine(Path.GetDirectoryName(dbPath)!, previousDbFileName); + if (File.Exists(previousDbPath)) + { + app.Logger.LogInformation( + "Version upgrade detected: copying {PreviousFile} → {NewFile}", + previousDbFileName, dbFileName); + Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!); + File.Copy(previousDbPath, dbPath); + app.Logger.LogInformation( + "Database file upgraded to {NewFileName}. EF migrations will apply schema changes.", + dbFileName); + } + else + { + app.Logger.LogWarning( + "Version upgrade: expected previous database {PreviousFile} not found at {PreviousPath}. " + + "A new empty database will be created.", + previousDbFileName, previousDbPath); + } + } + var stagedRestorePath = $"{dbPath}.restore_pending"; bool restoredFromPending = false; diff --git a/Documentation/Compatibility-Matrix.md b/Documentation/Compatibility-Matrix.md index 2e786cc..61d4098 100644 --- a/Documentation/Compatibility-Matrix.md +++ b/Documentation/Compatibility-Matrix.md @@ -22,9 +22,9 @@ This matrix tracks version compatibility across Nine releases, enabling you to: ## Nine Version History -| Release Date | App Version | Database Schema | .NET SDK | ElectronNET | Bootstrap | QuestPDF | Migration Required | Breaking Changes | Status | Download | -| ------------ | ----------- | --------------- | -------- | ----------- | --------- | --------- | ------------------ | ---------------- | ------------------ | -------------------------------------------------------------------- | -| TBD | **1.2.0** | v1.2.0 | 10.0.1 | 23.6.2 | 5.3.3 | 2025.12.1 | Yes (v1.1.0→1.2.0) | TBD | **In Development** | - | +| Release Date | App Version | Database Schema | .NET SDK | ElectronNET | Bootstrap | QuestPDF | Migration Required | Breaking Changes | Status | Download | +| ------------ | ----------- | --------------- | -------- | ----------- | --------- | --------- | ------------------ | ---------------- | ------------------ | ------------------------------------------------------------------ | +| TBD | **1.2.0** | v1.2.0 | 10.0.1 | 23.6.2 | 5.3.3 | 2025.12.1 | Yes (v1.1.0→1.2.0) | TBD | **In Development** | - | | 2026-03-01 | 1.1.2 | v1.1.0 | 10.0.1 | 23.6.2 | 5.3.3 | 2025.12.1 | No | No | **Current** | [Release](https://github.com/xnodeoncode/nine/releases/tag/v1.1.2) | | 2026-02-28 | 1.1.1 | v1.1.0 | 10.0.1 | 23.6.2 | 5.3.3 | 2025.12.1 | No | No | Previous | [Release](https://github.com/xnodeoncode/nine/releases/tag/v1.1.1) | | 2026-02-18 | 1.1.0 | v1.1.0 | 10.0.1 | 23.6.2 | 5.3.3 | 2025.12.1 | Yes (v1.0.0→1.1.0) | New tables/cols | Superseded | [Release](https://github.com/xnodeoncode/nine/releases/tag/v1.1.0) | @@ -70,7 +70,7 @@ This matrix tracks version compatibility across Nine releases, enabling you to: | ------------------- | --------------------- | --------------- | ---------------------------------------------- | | **SQLite** | 3.46.0 | Database engine | Via Microsoft.Data.Sqlite | | **EF Core** | 10.0.1 | ORM | Breaking changes uncommon in minor versions | -| **Database Schema** | v1.0.0 (Nine) | Data structure | Tracks with app version MAJOR.MINOR milestones | +| **Database Schema** | v1.0.0 (Nine) | Data structure | Tracks with app version MAJOR.MINOR milestones | | | v0.0.0 (Professional) | Data structure | Pre-v1.0.0 rapid iteration phase | ### UI & Front-end @@ -122,13 +122,26 @@ This matrix tracks version compatibility across Nine releases, enabling you to: - Database filename: `app_v0.0.0.db` - **Future versions**: - - **MAJOR** (vX.0.0): Breaking schema changes requiring migration - - Database filename updates to `app_vX.0.0.db` - - Automatic backup created before migration - - **MINOR** (v1.X.0): New tables/columns, backward compatible - - Database filename may update to `app_v1.X.0.db` if schema changes - - **PATCH** (v1.0.X): No schema changes - - Database filename remains unchanged + - **PATCH** (v1.0.X): Additive-only schema changes — same DB file + - **MINOR** (v1.X.0): Destructive or non-nullable changes — new DB file + - **MAJOR** (vX.0.0): Breaking changes requiring manual data migration — new DB file + backup enforced + +#### Schema Change Classification + +| Change Type | Version | DB File Changes | EF Compatible | Notes | +| ---------------------------------------------------- | ------- | --------------- | ------------- | ------------------------------------------------------------------------------------------------- | +| Add nullable column | PATCH | No | ✅ Yes | Existing rows get NULL automatically | +| Add new table | PATCH | No | ✅ Yes | No impact on existing data | +| Add new index | PATCH | No | ✅ Yes | No impact on existing data | +| Remove column (unused/obsolete) | MINOR | Yes | ✅ Yes | EF generates `DropColumn`; data lost | +| Remove table | MINOR | Yes | ✅ Yes | EF generates `DropTable`; data lost | +| Add non-nullable column with default/backfill | MINOR | Yes | ✅ Yes | EF adds column + default; migration review required | +| Rename column or table | MAJOR | Yes | ⚠️ Partial | EF generates drop+add (data loss); must hand-edit migration to use `RenameColumn` / `RenameTable` | +| Change column type (compatible, e.g. int → bigint) | MAJOR | Yes | ✅ Yes | EF generates `AlterColumn`; verify data integrity | +| Change column type (incompatible, e.g. string → int) | MAJOR | Yes | ❌ No | EF cannot convert data; manual migration script needed | +| Restructure relationship or foreign key | MAJOR | Yes | ⚠️ Partial | May require data migration depending on cardinality | + +> **Rollback note:** PATCH releases share the same DB file across all patch versions. Rolling back from v1.0.3 → v1.0.1 leaves any patch-added columns in place. SQLite ignores unknown columns on reads and leaves them NULL on writes — safe but unsupported. --- @@ -148,7 +161,7 @@ This matrix tracks version compatibility across Nine releases, enabling you to: | ------------ | ---------- | -------------- | ---------------- | ------------------------------------------------ | | v1.0.1 | v1.1.0 | Automatic | Schema v1.1.0 | New DatabaseSettings table, IsSampleData columns | | v1.0.0 | v1.0.1 | None | No | Drop-in replacement | -| v0.3.0 | v0.3.1 | Automatic | Database path | Same migration as Nine | +| v0.3.0 | v0.3.1 | Automatic | Database path | Same migration as Nine | | v1.x.x | v2.0.0 | Automatic | Schema changes | Future: Major version, backup enforced | --- @@ -213,7 +226,7 @@ This matrix tracks version compatibility across Nine releases, enabling you to: | Limitation | All Versions | Reason | | ---------------------- | ---------------------- | ---------------------------- | -| **Maximum Properties** | 9 (Nine) | Simple Start tier constraint | +| **Maximum Properties** | 9 (Nine) | Simple Start tier constraint | | **Maximum Users** | 3 (1 system + 3 login) | Simplified access control | | **Organizations** | 1 | Desktop application scope | | **File Upload Size** | 10 MB per file | Performance management | From b00e187982a1780862856252a327753949539322 Mon Sep 17 00:00:00 2001 From: CIS Guru Date: Thu, 12 Mar 2026 15:15:39 -0500 Subject: [PATCH 4/9] docs: add Database-Upgrade-Strategy.md with version-skip test plan --- Documentation/Database-Upgrade-Strategy.md | 296 +++++++++++++++++++++ 1 file changed, 296 insertions(+) create mode 100644 Documentation/Database-Upgrade-Strategy.md diff --git a/Documentation/Database-Upgrade-Strategy.md b/Documentation/Database-Upgrade-Strategy.md new file mode 100644 index 0000000..e1bd7b2 --- /dev/null +++ b/Documentation/Database-Upgrade-Strategy.md @@ -0,0 +1,296 @@ +# Nine - Database Upgrade Strategy + +**Version:** 1.1.0-dev +**Last Updated:** March 12, 2026 +**Audience:** Developers, Contributors + +--- + +## Overview + +Nine uses versioned SQLite database files (`app_vX.Y.0.db`) tied to the application's MAJOR.MINOR version. This document describes how the upgrade path works, the known failure mode for version-skipping users, the fix implemented in `app-database-upgrade`, and how to test all upgrade scenarios. + +--- + +## Database Versioning Policy + +| App Version | Database File | Schema Version | Notes | +| ----------- | ------------- | -------------- | ---------------------------------- | +| v1.0.0 | app_v1.0.0.db | 1.0.0 | First public release | +| v1.1.0 | app_v1.1.0.db | 1.1.0 | Additive columns only | +| v2.0.0 | app_v2.0.0.db | 2.0.0 | Breaking schema changes | +| v2.0.5 | app_v2.0.0.db | 2.0.0 | PATCH — same DB file | +| v2.1.0 | app_v2.1.0.db | 2.1.0 | New DB file, EF migrations applied | + +**Rules:** + +- MAJOR or MINOR version bump → new `DatabaseFileName` (e.g. `app_v2.1.0.db`) +- PATCH version bump → same `DatabaseFileName`, no DB file change +- `PreviousDatabaseFileName` is set by `bump-version.sh` to the previous file name +- A compiled binary is **version-locked**: it only looks for the filename baked into its `appsettings.json` + +--- + +## Upgrade Path: How It Works + +### Normal upgrade (v1.0.0 → v1.1.0) + +`appsettings.json` in the v1.1.0 binary: + +```json +"DatabaseFileName": "app_v1.1.0.db", +"PreviousDatabaseFileName": "app_v1.0.0.db" +``` + +**Startup sequence (`Program.cs`):** + +1. Target `app_v1.1.0.db` — not found +2. `PreviousDatabaseFileName` = `app_v1.0.0.db` — found +3. **Copy** `app_v1.0.0.db` → `app_v1.1.0.db` +4. EF Core migrations apply the delta to `app_v1.1.0.db` +5. App starts with all user data intact ✅ + +### Version-skip upgrade (v1.0.0 → v2.0.0, skipping v1.1.0) + +`appsettings.json` in the v2.0.0 binary: + +```json +"DatabaseFileName": "app_v2.0.0.db", +"PreviousDatabaseFileName": "app_v1.1.0.db" +``` + +**Without the fix:** + +1. Target `app_v2.0.0.db` — not found +2. `PreviousDatabaseFileName` = `app_v1.1.0.db` — **not found** (user skipped v1.1.0) +3. Warning logged; no copy performed +4. EF Core creates **blank** `app_v2.0.0.db` — **user data is lost** ❌ + +**With the fix (implemented in `app-database-upgrade` branch):** + +1. Target `app_v2.0.0.db` — not found +2. `PreviousDatabaseFileName` = `app_v1.1.0.db` — not found +3. **Fallback scan**: glob `app_v*.db` in the config directory, parse each filename as `System.Version`, pick the highest version that is **less than** the target +4. Finds `app_v1.0.0.db`, copies it to `app_v2.0.0.db` +5. EF Core migrations apply the **full delta** from v1.0.0 schema to v2.0.0 schema +6. App starts with all user data intact ✅ + +### Fresh install (no previous DB) + +1. Target `app_vX.Y.0.db` — not found +2. `PreviousDatabaseFileName` is empty (or not found on disk) +3. Scan finds nothing +4. EF Core creates a new blank database +5. Seed data applied ✅ + +### Already upgraded (DB file exists) + +1. Target `app_vX.Y.0.db` — **found** +2. Entire upgrade block is skipped (guarded by `!File.Exists(dbPath)`) +3. EF checks for pending migrations, applies if any ✅ + +--- + +## The Fix: `System.Version` Fallback Scan + +**File:** `4-Nine/Program.cs` + +The existing `else` branch in the upgrade block (which only logged a warning) is replaced with: + +```csharp +else +{ + // Direct predecessor not found — user may have skipped one or more versions. + // Scan for any app_v*.db file in the same directory and use the highest version + // that is older than this binary's target. System.Version ensures correct numeric + // ordering (v10.0 > v9.0, which lexicographic sort would get wrong). + var dbDir = Path.GetDirectoryName(dbPath)!; + var targetVersion = Version.TryParse( + Path.GetFileNameWithoutExtension(dbFileName).Replace("app_v", ""), + out var tv) ? tv : null; + + var bestCandidate = Directory.Exists(dbDir) + ? Directory.GetFiles(dbDir, "app_v*.db") + .Where(f => f != dbPath) + .Select(f => new + { + Path = f, + Version = Version.TryParse( + Path.GetFileNameWithoutExtension(f).Replace("app_v", ""), + out var v) ? v : null + }) + .Where(x => x.Version != null && (targetVersion == null || x.Version < targetVersion)) + .OrderByDescending(x => x.Version) + .Select(x => x.Path) + .FirstOrDefault() + : null; + + if (bestCandidate != null) + { + app.Logger.LogInformation( + "Version skip detected: copying {Found} → {NewFile} (expected {Missing} was absent)", + Path.GetFileName(bestCandidate), dbFileName, previousDbFileName); + Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!); + File.Copy(bestCandidate, dbPath); + } + else + { + app.Logger.LogWarning( + "Version upgrade: no previous database found at {PreviousPath} and no app_v*.db candidates in {DbDir}. " + + "A new empty database will be created.", + previousDbPath, dbDir); + } +} +``` + +**Why `System.Version`?** +Lexicographic string sorting fails at double-digit major versions: +`"app_v10.0.0.db" < "app_v9.0.0.db"` (string sort — wrong) +`Version(10,0,0) > Version(9,0,0)` (`System.Version` — correct) + +**Why `x.Version < targetVersion`?** +Guards against accidentally picking a DB file from a _newer_ version than the current binary — which could only exist if the user manually placed it there (unsupported). + +--- + +## Testing Plan + +### Prerequisites + +All scenarios require a test user data directory. On Linux this is `~/.config/Nine/`. The test steps below create and manipulate files in that directory directly. + +> **Important:** Back up any real production database before running these tests. + +--- + +### Scenario 1: Fresh Install + +**Setup:** Delete `~/.config/Nine/app_v*.db` if any exist. Set `appsettings.json` to `DatabaseFileName: "app_v1.0.0.db"`, `PreviousDatabaseFileName: ""`. + +**Run:** `dotnet run` (or launch AppImage) + +**Expected:** + +- Log: no upgrade messages +- `~/.config/Nine/app_v1.0.0.db` created +- App starts, seed data present, login works + +**Pass criteria:** Fresh DB created, no errors in logs. + +--- + +### Scenario 2: Normal Upgrade (Direct Predecessor) + +**Setup:** + +1. Run Scenario 1 to create `app_v1.0.0.db` with real data +2. Change `appsettings.json` to `DatabaseFileName: "app_v1.1.0.db"`, `PreviousDatabaseFileName: "app_v1.0.0.db"` +3. (Optional) Add a pending EF migration in the `1.1.0` schema to verify migrations apply + +**Run:** `dotnet run` + +**Expected:** + +- Log: `"Version upgrade detected: copying app_v1.0.0.db → app_v1.1.0.db"` +- `app_v1.1.0.db` created +- All data from `app_v1.0.0.db` present in new file +- EF migrations applied (if any pending) +- App starts normally + +**Pass criteria:** Data preserved, no EF errors. + +--- + +### Scenario 3: Version Skip (Missing Predecessor) + +**Setup:** + +1. Run Scenario 1 to create `app_v1.0.0.db` with real data +2. Do **not** create `app_v1.1.0.db` +3. Change `appsettings.json` to `DatabaseFileName: "app_v2.0.0.db"`, `PreviousDatabaseFileName: "app_v1.1.0.db"` + +**Run:** `dotnet run` + +**Expected:** + +- Log: `"Version skip detected: copying app_v1.0.0.db → app_v2.0.0.db (expected app_v1.1.0.db was absent)"` +- `app_v2.0.0.db` created from `app_v1.0.0.db` +- All data preserved +- EF migrations applied (full delta from v1.0.0 schema to v2.0.0) +- App starts normally + +**Pass criteria:** Data preserved despite skipped version. + +--- + +### Scenario 4: Multi-Version Skip (v1.0.0 → v10.0.0) + +**Setup:** + +1. Create `app_v1.0.0.db` with real data +2. Create a dummy `app_v9.0.0.db` in the config directory (copy of v1.0.0) +3. Change `appsettings.json` to `DatabaseFileName: "app_v10.0.0.db"`, `PreviousDatabaseFileName: "app_v9.9.0.db"` (missing) + +**Run:** `dotnet run` + +**Expected:** + +- Log shows `app_v9.0.0.db` was selected (highest version below v10.0.0) +- `app_v10.0.0.db` created from `app_v9.0.0.db`, NOT `app_v1.0.0.db` +- `System.Version` correctly ranked v9.0.0 > v1.0.0 + +**Pass criteria:** v10 is handled correctly; v9 preferred over v1. + +--- + +### Scenario 5: Already Upgraded (Idempotency) + +**Setup:** + +1. Complete Scenario 2 (v1.1.0 DB exists) +2. Restart the app without changing any config + +**Run:** `dotnet run` + +**Expected:** + +- No copy block executing (guarded by `!File.Exists(dbPath)`) +- No upgrade log messages +- App starts normally from existing DB + +**Pass criteria:** No duplicate copies, no accidental data overwrite. + +--- + +### Scenario 6: No Previous DB, No Candidates + +**Setup:** + +1. Empty `~/.config/Nine/` (no DB files at all) +2. Set `appsettings.json` to `DatabaseFileName: "app_v2.0.0.db"`, `PreviousDatabaseFileName: "app_v1.9.0.db"` + +**Run:** `dotnet run` + +**Expected:** + +- Log: warning that no previous database was found +- EF creates a new blank `app_v2.0.0.db` +- App starts, seed data present + +**Pass criteria:** Clean degradation to fresh install behavior; no crash. + +--- + +## Merge Plan + +``` +phase-0-baseline (base, has Phase 18 + original upgrade block) + ↓ branch +app-database-upgrade (implements fix: version-skip scan + System.Version) + ↓ test Scenarios 1-6 + ↓ merge back to phase-0-baseline + ↓ merge to development + ↓ PR to main +``` + +Once merged to `development`, run a full build and Scenarios 1 and 5 against the actual `~/.config/Nine/` directory (with a real v1.0.0 DB backup) before merging to `main`. From bc1eea99fc7936c2a14c7b62ee3578332ba67cb0 Mon Sep 17 00:00:00 2001 From: CIS Guru Date: Thu, 12 Mar 2026 15:28:41 -0500 Subject: [PATCH 5/9] fix: version-skip fallback scan and schema version update on upgrade --- 4-Nine/Program.cs | 56 ++++++++++++++++-- Documentation/Database-Upgrade-Strategy.md | 67 ++++++++++++++++------ 2 files changed, 98 insertions(+), 25 deletions(-) diff --git a/4-Nine/Program.cs b/4-Nine/Program.cs index 4200c7d..5696fde 100644 --- a/4-Nine/Program.cs +++ b/4-Nine/Program.cs @@ -309,10 +309,49 @@ } else { - app.Logger.LogWarning( - "Version upgrade: expected previous database {PreviousFile} not found at {PreviousPath}. " + - "A new empty database will be created.", - previousDbFileName, previousDbPath); + // Direct predecessor not found — user may have skipped one or more versions. + // Scan for any app_v*.db in the config directory and pick the highest version + // that is older than this binary's target. System.Version is used instead of + // string sort so that v10.0.0 correctly ranks above v9.0.0. + var dbDir = Path.GetDirectoryName(dbPath)!; + var targetVersion = Version.TryParse( + Path.GetFileNameWithoutExtension(dbFileName).Replace("app_v", ""), + out var tv) ? tv : null; + + var bestCandidate = Directory.Exists(dbDir) + ? Directory.GetFiles(dbDir, "app_v*.db") + .Where(f => f != dbPath) + .Select(f => new + { + Path = f, + Ver = Version.TryParse( + Path.GetFileNameWithoutExtension(f).Replace("app_v", ""), + out var v) ? v : null + }) + .Where(x => x.Ver != null && (targetVersion == null || x.Ver < targetVersion)) + .OrderByDescending(x => x.Ver) + .Select(x => x.Path) + .FirstOrDefault() + : null; + + if (bestCandidate != null) + { + app.Logger.LogInformation( + "Version skip detected: copying {Found} → {NewFile} (expected {Missing} was absent)", + Path.GetFileName(bestCandidate), dbFileName, previousDbFileName); + Directory.CreateDirectory(dbDir); + File.Copy(bestCandidate, dbPath); + app.Logger.LogInformation( + "Database file upgraded to {NewFileName}. EF migrations will apply schema changes.", + dbFileName); + } + else + { + app.Logger.LogWarning( + "Version upgrade: no previous database found at {PreviousPath} and no app_v*.db " + + "candidates in {DbDir}. A new empty database will be created.", + previousDbPath, dbDir); + } } } @@ -505,9 +544,14 @@ } else if (currentDbVersion != appSettings.SchemaVersion) { - // Schema version mismatch - log warning but allow startup - app.Logger.LogWarning("Schema version mismatch! Database: {DbVersion}, Application: {AppVersion}", + // Schema version mismatch after a version upgrade — update the stored version to match. + // This is the normal post-copy state: the copied DB still records the old version but + // EF migrations have already brought the schema up to date. + app.Logger.LogInformation( + "Updating schema version from {DbVersion} to {AppVersion} (post-upgrade)", currentDbVersion, appSettings.SchemaVersion); + await schemaService.UpdateSchemaVersionAsync(appSettings.SchemaVersion, $"Version upgrade from {currentDbVersion}"); + app.Logger.LogInformation("Schema version updated successfully"); } else { diff --git a/Documentation/Database-Upgrade-Strategy.md b/Documentation/Database-Upgrade-Strategy.md index e1bd7b2..d5c0f2c 100644 --- a/Documentation/Database-Upgrade-Strategy.md +++ b/Documentation/Database-Upgrade-Strategy.md @@ -10,6 +10,8 @@ Nine uses versioned SQLite database files (`app_vX.Y.0.db`) tied to the application's MAJOR.MINOR version. This document describes how the upgrade path works, the known failure mode for version-skipping users, the fix implemented in `app-database-upgrade`, and how to test all upgrade scenarios. +**This fix ships in v1.1.0** alongside the `app_v1.1.0.db` database file. The normal upgrade path (v1.0.0 → v1.1.0) is covered by `PreviousDatabaseFileName`. The version-skip fallback scan is active in every release from v1.1.0 onward — it protects against any skip at any version boundary (v1.0.0 → v1.2.0, v1.1.0 → v1.3.0, v1.2.0 → v2.0.0, etc.). + --- ## Database Versioning Policy @@ -50,31 +52,37 @@ Nine uses versioned SQLite database files (`app_vX.Y.0.db`) tied to the applicat 4. EF Core migrations apply the delta to `app_v1.1.0.db` 5. App starts with all user data intact ✅ -### Version-skip upgrade (v1.0.0 → v2.0.0, skipping v1.1.0) +### Version-skip upgrade (any version) + +This scenario can occur at any version boundary. The earliest possible real-world instance is a user on v1.0.0 who skips v1.1.0 and installs v1.2.0 directly. The same logic handles v1.1.0 → v1.3.0, v1.2.0 → v2.0.0, v1.0.0 → v10.0.0, or any other gap. + +Example: user on v1.0.0 installs v1.2.0 directly. -`appsettings.json` in the v2.0.0 binary: +`appsettings.json` in the v1.2.0 binary: ```json -"DatabaseFileName": "app_v2.0.0.db", +"DatabaseFileName": "app_v1.2.0.db", "PreviousDatabaseFileName": "app_v1.1.0.db" ``` -**Without the fix:** +**Without the fix (pre-v1.1.0 behaviour):** -1. Target `app_v2.0.0.db` — not found +1. Target `app_v1.2.0.db` — not found 2. `PreviousDatabaseFileName` = `app_v1.1.0.db` — **not found** (user skipped v1.1.0) 3. Warning logged; no copy performed -4. EF Core creates **blank** `app_v2.0.0.db` — **user data is lost** ❌ +4. EF Core creates **blank** `app_v1.2.0.db` — **user data is lost** ❌ -**With the fix (implemented in `app-database-upgrade` branch):** +**With the fix (shipped in v1.1.0, present in every subsequent release):** -1. Target `app_v2.0.0.db` — not found +1. Target `app_v1.2.0.db` — not found 2. `PreviousDatabaseFileName` = `app_v1.1.0.db` — not found 3. **Fallback scan**: glob `app_v*.db` in the config directory, parse each filename as `System.Version`, pick the highest version that is **less than** the target -4. Finds `app_v1.0.0.db`, copies it to `app_v2.0.0.db` -5. EF Core migrations apply the **full delta** from v1.0.0 schema to v2.0.0 schema +4. Finds `app_v1.0.0.db`, copies it to `app_v1.2.0.db` +5. EF Core migrations apply the **full delta** from v1.0.0 schema to v1.2.0 schema 6. App starts with all user data intact ✅ +The same logic handles multi-step skips (e.g. only `app_v1.0.0.db` present when installing v1.4.0) and double-digit versions (v9.x → v10.x) correctly via `System.Version` numeric comparison. + ### Fresh install (no previous DB) 1. Target `app_vX.Y.0.db` — not found @@ -203,20 +211,22 @@ All scenarios require a test user data directory. On Linux this is `~/.config/Ni ### Scenario 3: Version Skip (Missing Predecessor) +Simulates a user on v1.0.0 who skips v1.1.0 and installs v1.2.0 directly. This is the earliest possible real-world version-skip. + **Setup:** 1. Run Scenario 1 to create `app_v1.0.0.db` with real data 2. Do **not** create `app_v1.1.0.db` -3. Change `appsettings.json` to `DatabaseFileName: "app_v2.0.0.db"`, `PreviousDatabaseFileName: "app_v1.1.0.db"` +3. Change `appsettings.json` to `DatabaseFileName: "app_v1.2.0.db"`, `PreviousDatabaseFileName: "app_v1.1.0.db"` **Run:** `dotnet run` **Expected:** -- Log: `"Version skip detected: copying app_v1.0.0.db → app_v2.0.0.db (expected app_v1.1.0.db was absent)"` -- `app_v2.0.0.db` created from `app_v1.0.0.db` +- Log: `"Version skip detected: copying app_v1.0.0.db → app_v1.2.0.db (expected app_v1.1.0.db was absent)"` +- `app_v1.2.0.db` created from `app_v1.0.0.db` - All data preserved -- EF migrations applied (full delta from v1.0.0 schema to v2.0.0) +- EF migrations applied (full delta from v1.0.0 schema to v1.2.0) - App starts normally **Pass criteria:** Data preserved despite skipped version. @@ -267,14 +277,14 @@ All scenarios require a test user data directory. On Linux this is `~/.config/Ni **Setup:** 1. Empty `~/.config/Nine/` (no DB files at all) -2. Set `appsettings.json` to `DatabaseFileName: "app_v2.0.0.db"`, `PreviousDatabaseFileName: "app_v1.9.0.db"` +2. Set `appsettings.json` to `DatabaseFileName: "app_v1.2.0.db"`, `PreviousDatabaseFileName: "app_v1.1.0.db"` **Run:** `dotnet run` **Expected:** - Log: warning that no previous database was found -- EF creates a new blank `app_v2.0.0.db` +- EF creates a new blank `app_v1.2.0.db` - App starts, seed data present **Pass criteria:** Clean degradation to fresh install behavior; no crash. @@ -283,14 +293,33 @@ All scenarios require a test user data directory. On Linux this is `~/.config/Ni ## Merge Plan +This fix ships as part of **v1.1.0**. The branch workflow: + ``` -phase-0-baseline (base, has Phase 18 + original upgrade block) +phase-0-baseline (base: Phase 18 + original one-step upgrade block) ↓ branch -app-database-upgrade (implements fix: version-skip scan + System.Version) +app-database-upgrade (this branch: version-skip scan + System.Version fix) ↓ test Scenarios 1-6 ↓ merge back to phase-0-baseline ↓ merge to development ↓ PR to main + ↓ bump-version.sh → v1.1.0 + sets DatabaseFileName: "app_v1.1.0.db" + sets PreviousDatabaseFileName: "app_v1.0.0.db" ``` -Once merged to `development`, run a full build and Scenarios 1 and 5 against the actual `~/.config/Nine/` directory (with a real v1.0.0 DB backup) before merging to `main`. +**Version notes:** + +- `PreviousDatabaseFileName` covers the direct one-step upgrade (v1.0.0 → v1.1.0, v1.1.0 → v1.2.0, etc.) +- The fallback scan covers **any skip at any version boundary** — v1.0.0 → v1.2.0, v1.1.0 → v1.3.0, v1.2.0 → v2.0.0, v1.0.0 → v10.0.0, etc. +- This protection is present in every release from v1.1.0 onward; there is no version at which it "becomes" relevant +- Run all 6 scenarios against `~/.config/Nine/` before merging to `main` + +``` + +**Version notes:** + +- v1.1.0 `PreviousDatabaseFileName` = `app_v1.0.0.db` — covers the direct v1.0.0 → v1.1.0 upgrade path (no skip possible yet, only one prior version exists) +- The fallback scan becomes the safety net for v1.2.0+ when a user could first skip a version +- Once merged to `development`, run Scenarios 1 and 5 against the actual `~/.config/Nine/` directory (with a real v1.0.0 DB backup) before merging to `main` +``` From 26b97c803f4f9987089264ae5e9de2449d80bb3a Mon Sep 17 00:00:00 2001 From: CIS Guru Date: Thu, 12 Mar 2026 19:25:05 -0500 Subject: [PATCH 6/9] refactor: decouple DB version bump from app version bump (--bump-db flag) --- bump-version.sh | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/bump-version.sh b/bump-version.sh index 5de56d6..7f561ae 100755 --- a/bump-version.sh +++ b/bump-version.sh @@ -1,11 +1,21 @@ #!/bin/bash # Nine Semantic Version Bumper -# Usage: ./bump-version.sh [major|minor|patch] +# Usage: ./bump-version.sh [major|minor|patch] [--bump-db] +# +# App version and database schema version are independent. +# Pass --bump-db only when this release includes a schema change. +# Without --bump-db the DatabaseFileName in appsettings.json is left unchanged. set -e VERSION_TYPE="${1:-patch}" +BUMP_DB=false +for arg in "$@"; do + if [ "$arg" == "--bump-db" ]; then + BUMP_DB=true + fi +done CSPROJ_FILE="4-Nine/Nine.csproj" APPSETTINGS_FILE="4-Nine/appsettings.json" @@ -37,7 +47,7 @@ case "$VERSION_TYPE" in MAJOR=$((MAJOR + 1)) MINOR=0 PATCH=0 - echo -e "Bump Type: ${YELLOW}MAJOR${NC} (breaking changes, database migration required)" + echo -e "Bump Type: ${YELLOW}MAJOR${NC} (breaking changes)" ;; minor) MINOR=$((MINOR + 1)) @@ -80,8 +90,8 @@ sed -i "s|$CURRENT_VERSION| Date: Thu, 2 Apr 2026 17:28:47 -0500 Subject: [PATCH 7/9] feat: unsupported schema UI, quarantine rename, robust import with column aliases --- 0-Nine.Core/Entities/Property.cs | 4 +- 0-Nine.Core/Exceptions/DatabaseExceptions.cs | 54 + .../Data/CompiledModels/PropertyEntityType.cs | 8 +- .../Data/DesignTimeDbContextFactory.cs | 17 + ...31_RenameIsAvailableToIsActive.Designer.cs | 4392 +++++++++++++++++ ...60313122831_RenameIsAvailableToIsActive.cs | 25 + .../ApplicationDbContextModelSnapshot.cs | 4 +- .../Data/SqlCipherConnectionInterceptor.cs | 22 + .../Services/DatabaseUnlockState.cs | 33 +- .../Services/DatabasePreviewService.cs | 98 +- .../Services/DatabaseService.cs | 344 +- 2-Nine.Application/Services/LeaseService.cs | 10 +- .../Services/PropertyManagementService.cs | 2 +- .../Services/PropertyService.cs | 2 +- .../Workflows/SampleDataWorkflowService.cs | 8 +- .../Entities/Properties/PropertyForm.razor | 2 +- .../Entities/Properties/PropertyFormModel.cs | 2 +- .../Leases/CreateForm.razor | 4 +- .../PropertyManagement/Leases/EditForm.razor | 6 +- .../Properties/PropertyCreateForm.razor | 2 +- .../Properties/PropertyEditForm.razor | 4 +- .../Properties/PropertyViewForm.razor | 6 +- 4-Nine/Assets/install-desktop-integration.sh | 8 +- .../Extensions/ElectronServiceExtensions.cs | 108 +- 4-Nine/Features/DatabaseUnlock/Index.razor | 168 +- .../LeaseOffers/Pages/Accept.razor | 4 +- .../Properties/Pages/Index.razor | 6 +- .../Properties/Pages/View.razor | 2 +- .../PropertyManagement/Prospects/Index.razor | 2 +- 4-Nine/Nine.csproj | 8 +- 4-Nine/Program.cs | 99 +- .../Components/Account/Pages/Login.razor | 28 +- 4-Nine/Shared/Components/Pages/Home.razor | 4 +- .../Shared/Services/DatabaseBackupService.cs | 35 +- 4-Nine/appsettings.Development.json | 1 + 4-Nine/appsettings.json | 10 +- 4-Nine/wwwroot/js/loginGuard.js | 50 + .../Services/DocumentServiceTests.cs | 6 +- .../Services/InvoiceServiceTests.cs | 6 +- .../Services/LeaseServiceTests.cs | 22 +- .../Services/MaintenanceServiceTests.cs | 10 +- .../Services/PaymentServiceTests.cs | 6 +- .../Services/PropertyServiceTests.cs | 8 +- Documentation/Database-Era-Migration-Plan.md | 431 ++ Documentation/Database-Upgrade-Strategy.md | 55 + set-version.sh | 124 + 46 files changed, 6023 insertions(+), 227 deletions(-) create mode 100644 0-Nine.Core/Exceptions/DatabaseExceptions.cs create mode 100644 1-Nine.Infrastructure/Data/DesignTimeDbContextFactory.cs create mode 100644 1-Nine.Infrastructure/Data/Migrations/20260313122831_RenameIsAvailableToIsActive.Designer.cs create mode 100644 1-Nine.Infrastructure/Data/Migrations/20260313122831_RenameIsAvailableToIsActive.cs create mode 100644 4-Nine/wwwroot/js/loginGuard.js create mode 100644 Documentation/Database-Era-Migration-Plan.md create mode 100755 set-version.sh 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/2-Nine.Application/Services/DatabasePreviewService.cs b/2-Nine.Application/Services/DatabasePreviewService.cs index 5b9d3fe..6dae065 100644 --- a/2-Nine.Application/Services/DatabasePreviewService.cs +++ b/2-Nine.Application/Services/DatabasePreviewService.cs @@ -254,6 +254,16 @@ public async Task AddToBackupsAsync(string sourceFilePa private static readonly HashSet DocumentRequiredCols = new(StringComparer.OrdinalIgnoreCase) { "Id", "OrganizationId", "FileName", "FileData" }; + /// + /// 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 // ------------------------------------------------------------------------- @@ -681,8 +691,14 @@ public async Task ImportFromPreviewAsync(string backupFileName, st } /// - /// Copies rows from backupConn into activeConn for the given table, using the - /// intersection of columns that exist in both schemas. + /// 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. /// @@ -700,47 +716,54 @@ private static async Task ImportTableAsync( var backupCols = await GetTableColumnsAsync(backupConn, tableName); var activeCols = await GetTableColumnsAsync(activeConn, tableName); - // Validate required columns exist in backup - var missing = requiredCols - .Where(c => !backupCols.Contains(c)) + // 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 (missing.Count > 0) - { - errors.Add($"{tableName}: skipped — required columns missing: {string.Join(", ", missing)}"); - return 0; - } + if (missingRequired.Count > 0) + errors.Add($"{tableName}: warning — required columns not found in backup: {string.Join(", ", missingRequired)}"); - // Columns always overridden — never read from backup, always set to active-context values + // Columns always overridden — never read from backup var overrideCols = new HashSet(StringComparer.OrdinalIgnoreCase) { "OrganizationId", "CreatedBy", "LastModifiedBy" }; - // Intersection: columns present in both backup and active schemas, - // excluding all override cols (handled separately as literal overrides) - var sharedCols = backupCols - .Intersect(activeCols, StringComparer.OrdinalIgnoreCase) - .Where(c => !overrideCols.Contains(c)) - .OrderBy(c => c, StringComparer.OrdinalIgnoreCase) - .ToList(); + // 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(); - // Columns to read from backup: Id first, then all other shared cols - var nonIdCols = sharedCols - .Where(c => !c.Equals("Id", StringComparison.OrdinalIgnoreCase)) - .ToList(); - var readCols = new[] { "Id" }.Concat(nonIdCols).ToArray(); + var readBackupCols = colMapping.Select(x => x.BackupCol).ToArray(); + var insertActiveCols = colMapping.Select(x => x.ActiveCol).ToArray(); - // Columns to insert: Id, then overrides present in active schema, then the rest + // 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("CreatedBy")) insertOverrides.Add("CreatedBy"); if (activeCols.Contains("LastModifiedBy")) insertOverrides.Add("LastModifiedBy"); - var insertCols = new[] { "Id" }.Concat(insertOverrides).Concat(nonIdCols).ToArray(); - var colList = string.Join(", ", insertCols.Select(c => $"[{c}]")); - var paramList = string.Join(", ", insertCols.Select(c => + 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" : ""; @@ -762,23 +785,30 @@ private static async Task ImportTableAsync( if (rows.Count == 0) return 0; - // Build parameterized INSERT and reuse the command for each row + // 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); - foreach (var col in readCols) - writeCmd.Parameters.Add(new SqliteParameter($"@c_{col}", DBNull.Value)); + 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) { - for (int i = 0; i < readCols.Length; i++) + 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]; + var val = row[i + 1]; if (val is string s && Guid.TryParse(s, out var g)) val = g.ToString("D").ToUpperInvariant(); - writeCmd.Parameters[$"@c_{readCols[i]}"].Value = val ?? DBNull.Value; + writeCmd.Parameters[$"@c_{insertActiveCols[i]}"].Value = val ?? DBNull.Value; } count += await writeCmd.ExecuteNonQueryAsync(); } diff --git a/2-Nine.Application/Services/DatabaseService.cs b/2-Nine.Application/Services/DatabaseService.cs index e92db66..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,6 +117,269 @@ 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) /// diff --git a/2-Nine.Application/Services/LeaseService.cs b/2-Nine.Application/Services/LeaseService.cs index 5e042cf..6a6419d 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,7 @@ public async Task UpdateLeaseStatusAsync(Guid leaseId, string newStatus) if (!hasOtherActiveLeases) { - property.IsAvailable = true; + property.IsActive = true; } } 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/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/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/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 (!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 @@
public async Task GetDatabasePathAsync() { - if (HybridSupport.IsElectronActive) - { - return await _pathService.GetDatabasePathAsync(); - } - else - { - var connectionString = _configuration.GetConnectionString("DefaultConnection"); - if (string.IsNullOrEmpty(connectionString)) - { - throw new InvalidOperationException("Connection string 'DefaultConnection' not found"); - } - - // Parse SQLite connection string - supports both "Data Source=" and "DataSource=" - var dbPath = connectionString - .Replace("Data Source=", "") - .Replace("DataSource=", "") - .Split(';')[0] - .Trim(); - - // Make absolute path if relative - if (!Path.IsPathRooted(dbPath)) - { - dbPath = Path.Combine(Directory.GetCurrentDirectory(), dbPath); - } - - _logger.LogInformation("Database path resolved to: {DbPath}", dbPath); - return dbPath; - } + // Always delegate to IPathService — it is registered as ElectronPathService for desktop + // and WebPathService for web, so it already encapsulates the platform difference. + // Using HybridSupport.IsElectronActive here is unreliable at startup because Electron + // may not have connected yet, causing the fallback to resolve an incorrect path. + var dbPath = await _pathService.GetDatabasePathAsync(); + _logger.LogInformation("Database path resolved to: {DbPath}", dbPath); + return dbPath; } private async Task CleanupOldBackupsAsync(string backupDir, int keepCount) diff --git a/4-Nine/appsettings.Development.json b/4-Nine/appsettings.Development.json index 9d1ab69..95e5333 100644 --- a/4-Nine/appsettings.Development.json +++ b/4-Nine/appsettings.Development.json @@ -1,4 +1,5 @@ { + "DetailedErrors": true, "Logging": { "LogLevel": { "Default": "Information", diff --git a/4-Nine/appsettings.json b/4-Nine/appsettings.json index a1a4e18..033ad14 100644 --- a/4-Nine/appsettings.json +++ b/4-Nine/appsettings.json @@ -1,6 +1,6 @@ { "ConnectionStrings": { - "DefaultConnection": "DataSource=Data/app_v1.0.0.db;Cache=Shared" + "DefaultConnection": "DataSource=app_v1.3.0.db;Cache=Shared" }, "Logging": { "LogLevel": { @@ -14,7 +14,7 @@ "ApplicationSettings": { "AppName": "Nine", "ProductName": "Nine", - "Version": "1.0.0", + "Version": "1.3.0", "Company": "Nine App, LLC", "Copyright": "Copyright © 2024 Nine App, LLC. All rights reserved.", "Description": "Nine is a modern property management desktop application. Nine is open-source free software.", @@ -23,9 +23,9 @@ "Email": "cisguru@outlook.com", "Repository": "https://github.com/xnodeoncode/nine", "SoftDeleteEnabled": true, - "DatabaseFileName": "app_v1.0.0.db", - "PreviousDatabaseFileName": "", - "SchemaVersion": "1.0.0", + "DatabaseFileName": "app_v1.3.0.db", + "PreviousDatabaseFileName": "app_v1.2.0.db", + "SchemaVersion": "1.3.0", "MaxOrganizationUsers": 3, "License": "MIT", "LicenseUrl": "https://github.com/xnodeoncode/nine/blob/main/LICENSE", diff --git a/4-Nine/wwwroot/js/loginGuard.js b/4-Nine/wwwroot/js/loginGuard.js new file mode 100644 index 0000000..70dec33 --- /dev/null +++ b/4-Nine/wwwroot/js/loginGuard.js @@ -0,0 +1,50 @@ +(function () { + function setupLoginGuard() { + // Identify the login form by its email field (specific to Login.razor) + var emailInput = document.getElementById("Input.Email"); + if (!emailInput) return; + + var form = emailInput.closest("form"); + if (!form || form._loginGuardAttached) return; + form._loginGuardAttached = true; + + var submitted = false; + form.addEventListener("submit", function (e) { + if (submitted) { + console.log( + "Login form already submitted, preventing duplicate submission.", + ); + e.preventDefault(); + e.stopImmediatePropagation(); + return false; + } + submitted = true; + var btn = form.querySelector('button[type="submit"]'); + if (btn) { + console.log("Disabling submit button and showing loading spinner."); + btn.disabled = true; + btn.classList.remove("btn-primary"); + btn.classList.add("btn-success"); + btn.innerHTML = + 'Logging in...'; + } + }); + } + + function init() { + setupLoginGuard(); + // Re-attach after Blazor enhanced navigation (navigating to/from login page) + document.addEventListener("blazor:navigated", setupLoginGuard); + } + + if (document.readyState === "loading") { + console.log("Waiting for DOMContentLoaded to initialize login guard..."); + document.addEventListener("DOMContentLoaded", init); + console.log("Login guard will be initialized on DOMContentLoaded."); + } else { + console.log( + "Document already loaded, initializing login guard immediately.", + ); + init(); + } +})(); diff --git a/6-Tests/Nine.Application.Tests/Services/DocumentServiceTests.cs b/6-Tests/Nine.Application.Tests/Services/DocumentServiceTests.cs index 1225163..31ca630 100644 --- a/6-Tests/Nine.Application.Tests/Services/DocumentServiceTests.cs +++ b/6-Tests/Nine.Application.Tests/Services/DocumentServiceTests.cs @@ -87,7 +87,7 @@ public DocumentServiceTests() City = "Test City", State = "TS", ZipCode = "12345", - IsAvailable = true, + IsActive = true, CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow }; @@ -702,7 +702,7 @@ public async Task GetByIdAsync_DifferentOrganization_ReturnsNull() City = "Other City", State = "OT", ZipCode = "99999", - IsAvailable = true, + IsActive = true, CreatedBy = otherUserId, CreatedOn = DateTime.UtcNow }; @@ -778,7 +778,7 @@ public async Task GetAllAsync_ReturnsOnlyCurrentOrganizationDocuments() City = "Other City", State = "OT", ZipCode = "88888", - IsAvailable = true, + IsActive = true, CreatedBy = otherUserId, CreatedOn = DateTime.UtcNow }; diff --git a/6-Tests/Nine.Application.Tests/Services/InvoiceServiceTests.cs b/6-Tests/Nine.Application.Tests/Services/InvoiceServiceTests.cs index 46cb09a..ba31907 100644 --- a/6-Tests/Nine.Application.Tests/Services/InvoiceServiceTests.cs +++ b/6-Tests/Nine.Application.Tests/Services/InvoiceServiceTests.cs @@ -87,7 +87,7 @@ public InvoiceServiceTests() City = "Test City", State = "TS", ZipCode = "12345", - IsAvailable = false, + IsActive = false, CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow }; @@ -846,7 +846,7 @@ public async Task GetByIdAsync_DifferentOrganization_ReturnsNull() City = "Other City", State = "OT", ZipCode = "99999", - IsAvailable = true, + IsActive = true, CreatedBy = otherUserId, CreatedOn = DateTime.UtcNow }; @@ -953,7 +953,7 @@ public async Task GetAllAsync_ReturnsOnlyCurrentOrganizationInvoices() City = "Other City", State = "OT", ZipCode = "88888", - IsAvailable = true, + IsActive = true, CreatedBy = otherUserId, CreatedOn = DateTime.UtcNow }; diff --git a/6-Tests/Nine.Application.Tests/Services/LeaseServiceTests.cs b/6-Tests/Nine.Application.Tests/Services/LeaseServiceTests.cs index 09b61d7..54762c9 100644 --- a/6-Tests/Nine.Application.Tests/Services/LeaseServiceTests.cs +++ b/6-Tests/Nine.Application.Tests/Services/LeaseServiceTests.cs @@ -86,7 +86,7 @@ public LeaseServiceTests() City = "Test City", State = "TS", ZipCode = "12345", - IsAvailable = true, + IsActive = true, CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow }; @@ -302,7 +302,7 @@ public async Task CreateAsync_ActiveLease_MarksPropertyUnavailable() // Assert var property = await _context.Properties.FindAsync(_testPropertyId); - Assert.False(property!.IsAvailable); + Assert.False(property!.IsActive); } [Fact] @@ -327,7 +327,7 @@ public async Task CreateAsync_PendingLease_DoesNotMarkPropertyUnavailable() // Assert var property = await _context.Properties.FindAsync(_testPropertyId); - Assert.True(property!.IsAvailable); + Assert.True(property!.IsActive); } [Fact] @@ -353,7 +353,7 @@ public async Task DeleteAsync_ActiveLease_MarksPropertyAvailable() // Assert var property = await _context.Properties.FindAsync(_testPropertyId); - Assert.True(property!.IsAvailable); + Assert.True(property!.IsActive); } #endregion @@ -453,7 +453,7 @@ public async Task GetActiveLeasesAsync_ReturnsOnlyActiveLeases() City = "Test City", State = "TS", ZipCode = "12345", - IsAvailable = true, + IsActive = true, CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow }; @@ -510,7 +510,7 @@ public async Task GetLeasesExpiringSoonAsync_ReturnsLeasesWithinThreshold() City = "Test City", State = "TS", ZipCode = "12345", - IsAvailable = true, + IsActive = true, CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow }; @@ -595,7 +595,7 @@ public async Task GetLeasesByStatusAsync_ReturnsLeasesWithStatus() City = "Test City", State = "TS", ZipCode = "12345", - IsAvailable = true, + IsActive = true, CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow }; @@ -710,7 +710,7 @@ public async Task UpdateLeaseStatusAsync_UpdatesStatusAndPropertyAvailability() // Assert Assert.Equal(ApplicationConstants.LeaseStatuses.Active, updated.Status); var property = await _context.Properties.FindAsync(_testPropertyId); - Assert.False(property!.IsAvailable); + Assert.False(property!.IsActive); } [Fact] @@ -736,7 +736,7 @@ public async Task UpdateLeaseStatusAsync_ToTerminated_MarksPropertyAvailable() // Assert var property = await _context.Properties.FindAsync(_testPropertyId); - Assert.True(property!.IsAvailable); + Assert.True(property!.IsActive); } #endregion @@ -775,7 +775,7 @@ public async Task GetByIdAsync_DifferentOrganization_ReturnsNull() City = "Other City", State = "OT", ZipCode = "99999", - IsAvailable = true, + IsActive = true, CreatedBy = otherUserId, CreatedOn = DateTime.UtcNow }; @@ -864,7 +864,7 @@ public async Task GetAllAsync_ReturnsOnlyCurrentOrganizationLeases() City = "Other City", State = "OT", ZipCode = "88888", - IsAvailable = true, + IsActive = true, CreatedBy = otherUserId, CreatedOn = DateTime.UtcNow }; diff --git a/6-Tests/Nine.Application.Tests/Services/MaintenanceServiceTests.cs b/6-Tests/Nine.Application.Tests/Services/MaintenanceServiceTests.cs index aa56750..9897e69 100644 --- a/6-Tests/Nine.Application.Tests/Services/MaintenanceServiceTests.cs +++ b/6-Tests/Nine.Application.Tests/Services/MaintenanceServiceTests.cs @@ -85,7 +85,7 @@ public MaintenanceServiceTests() Bedrooms = 3, Bathrooms = 2, SquareFeet = 1500, - IsAvailable = true, + IsActive = true, CreatedBy = _testUser.Id, CreatedOn = DateTime.UtcNow }; @@ -385,7 +385,7 @@ public async Task CreateAsync_InvalidPropertyOrganization_ThrowsException() Bedrooms = 2, Bathrooms = 1, SquareFeet = 900, - IsAvailable = true, + IsActive = true, CreatedBy = _testUser.Id, CreatedOn = DateTime.UtcNow }; @@ -986,7 +986,7 @@ public async Task GetMaintenanceCostsByPropertyAsync_ReturnsCorrectTotals() Bedrooms = 2, Bathrooms = 1, SquareFeet = 1000, - IsAvailable = true, + IsActive = true, CreatedBy = _testUser.Id, CreatedOn = DateTime.UtcNow }; @@ -1113,7 +1113,7 @@ public async Task GetByIdAsync_DifferentOrganization_ReturnsNull() Bedrooms = 2, Bathrooms = 1, SquareFeet = 900, - IsAvailable = true, + IsActive = true, CreatedBy = _testUser.Id, CreatedOn = DateTime.UtcNow }; @@ -1185,7 +1185,7 @@ public async Task GetAllAsync_ReturnsOnlyCurrentOrganizationRequests() Bedrooms = 2, Bathrooms = 1, SquareFeet = 900, - IsAvailable = true, + IsActive = true, CreatedBy = _testUser.Id, CreatedOn = DateTime.UtcNow }; diff --git a/6-Tests/Nine.Application.Tests/Services/PaymentServiceTests.cs b/6-Tests/Nine.Application.Tests/Services/PaymentServiceTests.cs index 22f0800..8b55322 100644 --- a/6-Tests/Nine.Application.Tests/Services/PaymentServiceTests.cs +++ b/6-Tests/Nine.Application.Tests/Services/PaymentServiceTests.cs @@ -90,7 +90,7 @@ public PaymentServiceTests() City = "Test City", State = "TS", ZipCode = "12345", - IsAvailable = false, + IsActive = false, CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow }; @@ -784,7 +784,7 @@ public async Task GetByIdAsync_DifferentOrganization_ReturnsNull() City = "Other City", State = "OT", ZipCode = "99999", - IsAvailable = true, + IsActive = true, CreatedBy = otherUserId, CreatedOn = DateTime.UtcNow }; @@ -901,7 +901,7 @@ public async Task GetAllAsync_ReturnsOnlyCurrentOrganizationPayments() City = "Other City", State = "OT", ZipCode = "88888", - IsAvailable = true, + IsActive = true, CreatedBy = otherUserId, CreatedOn = DateTime.UtcNow }; diff --git a/6-Tests/Nine.Application.Tests/Services/PropertyServiceTests.cs b/6-Tests/Nine.Application.Tests/Services/PropertyServiceTests.cs index d1dfecc..ae9e24f 100644 --- a/6-Tests/Nine.Application.Tests/Services/PropertyServiceTests.cs +++ b/6-Tests/Nine.Application.Tests/Services/PropertyServiceTests.cs @@ -438,7 +438,7 @@ public async Task GetVacantPropertiesAsync_ReturnsOnlyVacantProperties() State = "TS", ZipCode = "12345", PropertyType = "House", - IsAvailable = true, + IsActive = true, CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow }; @@ -452,7 +452,7 @@ public async Task GetVacantPropertiesAsync_ReturnsOnlyVacantProperties() State = "TS", ZipCode = "12345", PropertyType = "House", - IsAvailable = true, + IsActive = true, CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow }; @@ -511,7 +511,7 @@ public async Task GetVacantPropertiesAsync_ExcludesUnavailableProperties() State = "TS", ZipCode = "12345", PropertyType = "House", - IsAvailable = false, // Not available + IsActive = false, // Not available CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow }; @@ -552,7 +552,7 @@ public async Task CalculateOccupancyRateAsync_CalculatesCorrectPercentage() State = "TS", ZipCode = "12345", PropertyType = "House", - IsAvailable = true, + IsActive = true, CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow }).ToArray(); diff --git a/Documentation/Database-Era-Migration-Plan.md b/Documentation/Database-Era-Migration-Plan.md new file mode 100644 index 0000000..fa486a6 --- /dev/null +++ b/Documentation/Database-Era-Migration-Plan.md @@ -0,0 +1,431 @@ +# Database Era Migration Plan + +**Feature Branch:** `app-database-upgrade` +**Target Release:** v1.1.0 +**Author:** CIS Guru +**Created:** March 25, 2026 +**Status:** In Progress + +--- + +## 1. Problem Statement + +Nine v1.1.0 ships with a squashed migration history from its predecessor, Aquiis. The squash replaced the original incremental migration chain (starting with `20260106195859_InitialCreate`) with a clean baseline (`20260128153724_v1_0_0_InitialCreate`). All Aquiis releases (v0.3.0, v1.0.0, v1.1.0) still contain the orginal marker ID in `__EFMigrationsHistory`. When EF Core's `MigrateAsync` runs against such a database, it cannot reconcile the history with the current migration chain and crashes. + +Note on version number overlap: both Aquiis and Nine released a `v1.0.0`, but they are different products on different migration chains. An Aquiis `app_v1.0.0.db` is a pre-squash era database. A Nine `app_v1.0.0.db` is a current-era database. The bridge detects by migration ID content, never by filename. + +A secondary problem: the pre-squash schema is missing five migrations added between January 28 and March 13, 2026. Without these, the current application code will fail on startup (wrong indexes, missing columns, wrong column names). + +--- + +## 2. Scope + +### Feature Scope + +The bridge is implemented in `DatabaseService.cs` — pure .NET code with no platform-specific dependencies. It runs identically on all platforms. + +| In Scope | Out of Scope | +| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| All Aquiis release databases: v0.3.0, v1.0.0, v1.1.0 | Nine databases (current era — bridge is a no-op by design) | +| All platforms: Linux, Windows, macOS | Encrypted (SQLCipher) databases — supported (bridge opens via EF pipeline; `SqlCipherConnectionInterceptor` applies `PRAGMA key` before any bridge SQL runs) | +| Single-SQLite-file deployments (business + identity in one file) | Manual migration tooling | +| Automated bridge on app startup | | + +### Test Coverage (this branch) + +All manual test runs in this branch are on **Linux AppImage only**. The bridge will fire on Windows and macOS in exactly the same way — there is no platform-specific code path — but those scenarios have not been exercised yet. + +| Platform | Tested Here | Notes | +| ---------------- | ----------- | ---------------------- | +| Linux (AppImage) | ✅ | All tests 1–6 | +| Windows | ❌ | Future branch / CI run | +| macOS | ❌ | Future branch / CI run | + +--- + +## 3. Era Definitions + +`DetectEraAsync()` in `DatabaseService.cs` returns one of the following `Era` enum values by inspecting the three known marker IDs in `__EFMigrationsHistory`: + +| `Era` value | Constant | Marker Migration ID | Era Range | Description | +| ---------------- | ---------------------- | ------------------------------------- | ------------------------------------------- | -------------------------------------------------- | +| `Fresh` | — | _(no history table, or table empty)_ | New install | No migrations run yet | +| `Current` | `CurrentEraMarker` | `20260128153724_v1_0_0_InitialCreate` | Nine ≥ v1.0.0 | Squashed baseline — no bridge needed | +| `FirstAncestor` | `FirstAncestorMarker` | `20260106195859_InitialCreate` | All Aquiis releases: v0.3.0, v1.0.0, v1.1.0 | Original incremental chain — bridge required | +| `SecondAncestor` | `SecondAncestorMarker` | _(empty — TODO at next major squash)_ | Not yet defined | Placeholder — bridge method not yet populated | +| `NotSupported` | — | _(history exists, no known marker)_ | > 2 generations behind, or foreign/corrupt | DB backed up; `SchemaNotSupportedException` thrown | + +**Detection logic (`DetectEraAsync`):** + +1. No `__EFMigrationsHistory` table, or table is empty → `Era.Fresh` +2. `SecondAncestorMarker` present (and non-empty) → `Era.SecondAncestor` +3. `FirstAncestorMarker` present → `Era.FirstAncestor` +4. `CurrentEraMarker` present → `Era.Current` +5. History rows exist but no known marker found → `Era.NotSupported` + +**Support policy:** Nine supports the current era plus two generations back. `Era.NotSupported` triggers a database backup followed by `DatabaseExceptions.SchemaNotSupportedException`, directing the user to the import workflow. + +**Updating markers at each major squash:** + +- `CurrentEraMarker` → first migration ID of the new chain +- `FirstAncestorMarker` → previous `CurrentEraMarker` +- `SecondAncestorMarker` → previous `FirstAncestorMarker` +- `ApplyFirstAncestorBridgeAsync()` → new bridge SQL +- `ApplySecondAncestorBridgeAsync()` → previous `ApplyFirstAncestorBridgeAsync()` SQL + +--- + +## 4. Pre-Squash Schema State (Aquiis Era) + +Schema confirmed by inspecting `~/.config/Nine/Backups/app_v0.3.0.db` (Aquiis v0.3.0, 946,176 bytes, 43 tables). Aquiis v1.0.0 and v1.1.0 databases carry the same four migration IDs and the same five missing migrations — the pre-squash chain was never updated after the squash occurred. Nine began at v1.0.0 with the squashed baseline already in place. + +### Migration history (all Aquiis releases: v0.3.0, v1.0.0, v1.1.0) + +| MigrationId | ProductVersion | +| ------------------------------------- | -------------- | +| `20260106195859_InitialCreate` | 10.0.1 | +| `20260128153724_v1_0_0_InitialCreate` | 10.0.1 | +| `20260201231400_AddDatabaseSettings` | 10.0.1 | +| `20260201234216_AddEncryptionSalt` | 10.0.1 | + +### Missing migrations (not yet applied) + +| MigrationId | Applied | +| ----------------------------------------------------- | ------- | +| `20260209120000_FixInvoicePaymentUniqueIndexes` | ❌ | +| `20260212163628_AddIsSampleDataFlag` | ❌ | +| `20260212165047_UpdateExistingSampleDataFlag` | ❌ | +| `20260216205819_ConsolidateOrganizationIdToBaseModel` | ❌ | +| `20260313122831_RenameIsAvailableToIsActive` | ❌ | + +### Key schema differences vs current + +| Table | Column / Index | v0.3.0 state | Current state | +| --------------------- | -------------------------------- | -------------------- | --------------------------------------- | +| `Properties` | `IsAvailable` column | Present | Renamed to `IsActive` | +| `Invoices` | `IX_Invoices_InvoiceNumber` | Single-column UNIQUE | Dropped | +| `Invoices` | `IX_Invoice_OrgId_InvoiceNumber` | Missing | Composite UNIQUE (OrgId, InvoiceNumber) | +| `Invoices` | `IX_Invoices_OrganizationId` | Present | Dropped | +| `Payments` | `IX_Payments_OrganizationId` | Present | Dropped | +| `Payments` | `IX_Payment_OrgId_PaymentNumber` | Missing | Composite UNIQUE (OrgId, PaymentNumber) | +| All ~25 entity tables | `IsSampleData` column | Missing | `INTEGER NOT NULL DEFAULT 0` | + +--- + +## 5. Bridge Implementation + +### 5.1 Location + +``` +2-Nine.Application/Services/DatabaseService.cs + └─ DetectEraAsync() (private) — returns Era enum value + └─ ApplyFirstAncestorBridgeAsync() (private) — Aquiis → Nine v1.x bridge + └─ ApplySecondAncestorBridgeAsync() (private) — placeholder; populate at next squash + └─ BackupUnsupportedDatabaseAsync() (private) — copies DB before NotSupported throw + └─ InitializeAsync() (public) — orchestrates detection, bridging, MigrateAsync + +0-Nine.Core/Exceptions/DatabaseExceptions.cs + └─ DatabaseExceptions.SchemaNotSupportedException — era outside support window; carries BackupPath + └─ DatabaseExceptions.SchemaInvalidException — schema unrecognisable or corrupt + └─ DatabaseExceptions.MigrationException — bridge step failure +``` + +### 5.2 Startup Call Sequence + +``` +InitializeAsync() + │ + ├─ CloseConnection() + ClearAllPools() + │ + ├─ OpenConnectionAsync() (via EF — SqlCipherConnectionInterceptor fires, PRAGMA key applied) + ├─ DetectEraAsync() ← ERA DETECTION + │ ├─ Queries __EFMigrationsHistory for three marker IDs + │ └─ Returns Era enum value + ├─ CloseConnection() + ClearAllPools() + │ + ├─ switch (era) + │ ├─ Era.NotSupported + │ │ ├─ BackupUnsupportedDatabaseAsync() — copies DB file to Backups/ folder + │ │ └─ throw SchemaNotSupportedException(backupPath) + │ │ + │ ├─ Era.SecondAncestor + │ │ └─ ApplySecondAncestorBridgeAsync() ← ERA BRIDGE (placeholder) + │ │ ├─ OpenConnectionAsync() (via EF) + │ │ ├─ BEGIN TRANSACTION + │ │ │ └─ (TODO: bridge SQL at next squash) + │ │ ├─ COMMIT (or ROLLBACK on error) + │ │ └─ CloseConnection() + │ │ + │ ├─ Era.FirstAncestor + │ │ └─ ApplyFirstAncestorBridgeAsync() ← ERA BRIDGE + │ │ ├─ OpenConnectionAsync() (via EF) + │ │ ├─ BEGIN TRANSACTION + │ │ │ ├─ Repair migration history (remove previous-era marker, add identity baseline) + │ │ │ ├─ Fix Invoice/Payment indexes + │ │ │ ├─ Add IsSampleData column to ~25 tables + │ │ │ ├─ Tag existing system-seeded rows (CreatedBy = SystemUser GUID) + │ │ │ ├─ Rename Properties.IsAvailable → IsActive + │ │ │ └─ INSERT 5 missing migration IDs into __EFMigrationsHistory + │ │ ├─ COMMIT (or ROLLBACK on any error — database left intact for retry) + │ │ └─ CloseConnection() + │ │ + │ └─ Era.Fresh / Era.Current → no-op + │ + ├─ ClearAllPools() + │ + ├─ _identityContext.MigrateAsync() (0 pending — history already correct) + │ + ├─ CloseConnection() + ClearAllPools() + │ + └─ _businessContext.MigrateAsync() (any migrations added after bridge point) +``` + +### 5.3 Bridge SQL Steps + +**Step 1 — Repair migration history** + +```sql +DELETE FROM "__EFMigrationsHistory" + WHERE "MigrationId" = '20260106195859_InitialCreate'; + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") + VALUES ('20260104205913_InitialCreate', '10.0.1'); +``` + +_Rationale:_ The pre-squash marker is removed. The current-era identity InitialCreate is registered so EF sees zero pending identity migrations (the identity tables already exist in the file). + +**Step 2 — Fix Invoice/Payment indexes** _(migration: FixInvoicePaymentUniqueIndexes)_ + +```sql +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"); +``` + +_Rationale:_ The v0.3.0 single-column `IX_Invoices_InvoiceNumber` unique constraint is per-database, not per-organisation. In a multi-tenant environment this would prevent two organisations from both using invoice number `INV-001`. The composite indexes enforce uniqueness within an organisation only. + +**Step 3 — Add IsSampleData column** _(migration: AddIsSampleDataFlag)_ + +```sql +ALTER TABLE "" ADD COLUMN "IsSampleData" INTEGER NOT NULL DEFAULT 0; +``` + +Applied to 31 tables: `ApplicationScreenings`, `CalendarEvents`, `CalendarSettings`, `ChecklistItems`, `Checklists`, `ChecklistTemplateItems`, `ChecklistTemplates`, `Documents`, `Inspections`, `Invoices`, `LeaseOffers`, `Leases`, `MaintenanceRequests`, `Notes`, `NotificationPreferences`, `Notifications`, `OrganizationEmailSettings`, `OrganizationSettings`, `OrganizationSMSSettings`, `Payments`, `Properties`, `ProspectiveTenants`, `RentalApplications`, `Repairs`, `SecurityDepositDividends`, `SecurityDepositInvestmentPools`, `SecurityDeposits`, `Tenants`, `Tours`, `UserProfiles`, `WorkflowAuditLogs`. + +_Rationale:_ Allows the UI to filter out system seed/demo data from user-entered records. All pre-existing rows default to `0` (real data) — the next step overrides system-seeded rows. + +**Step 4 — Tag system-seeded rows** _(migration: UpdateExistingSampleDataFlag)_ + +```sql +UPDATE "" SET "IsSampleData" = 1 + WHERE "CreatedBy" = '00000000-0000-0000-0000-000000000001'; +``` + +Applied to: `Properties`, `Tenants`, `Leases`, `Invoices`, `Payments`. + +_Rationale:_ The SystemUser GUID (`00000000-0000-0000-0000-000000000001`) is the seeder identity. Any row it created is demo/sample data. + +**Step 5 — ConsolidateOrganizationIdToBaseModel** _(no-op)_ + +No DDL required. This migration was a code-only refactor (moved `OrganizationId` from child entities into `BaseModel`). The column already existed on all relevant tables. + +**Step 6 — Rename IsAvailable → IsActive** _(migration: RenameIsAvailableToIsActive)_ + +```sql +ALTER TABLE "Properties" RENAME COLUMN "IsAvailable" TO "IsActive"; +``` + +_Rationale:_ `IsAvailable` was renamed to `IsActive` to align with the `Property.IsActive` property name used throughout the codebase and to avoid confusion with the `Property.Status` workflow field (`ApplicationConstants.PropertyStatuses.Available`). + +**Step 7 — Record applied migrations** + +```sql +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'); +``` + +After the bridge completes, `__EFMigrationsHistory` contains all current-era IDs. `MigrateAsync` will find 0 pending migrations across both contexts and proceed without schema changes. + +### 5.4 Transaction Safety + +The entire bridge executes inside a single `DbConnection.BeginTransaction()`. If any SQL statement fails, `transaction.Rollback()` is called in the `catch` block, leaving the database in exactly its original state. The next application startup will re-detect the pre-squash marker and retry the bridge from scratch. + +The bridge never partially updates the database. + +--- + +## 6. Feature Impact + +### Modified Files + +| File | Change Type | Purpose | +| ------------------------------------------------------------------------------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `2-Nine.Application/Services/DatabaseService.cs` | Added methods, modified method | `DetectEraAsync()`, `ApplyFirstAncestorBridgeAsync()`, `ApplySecondAncestorBridgeAsync()`, `BackupUnsupportedDatabaseAsync()` + updated `InitializeAsync()` | +| `0-Nine.Core/Exceptions/DatabaseExceptions.cs` | New file | `SchemaNotSupportedException`, `SchemaInvalidException`, `MigrationException` | +| `0-Nine.Core/Entities/Property.cs` | Property rename | `IsAvailable` → `IsActive` | +| `2-Nine.Application/Services/LeaseService.cs` | Reference update | `IsAvailable` → `IsActive` | +| `2-Nine.Application/Services/PropertyService.cs` | Reference update | `IsAvailable` → `IsActive` | +| `2-Nine.Application/Services/PropertyManagementService.cs` | Reference update | `IsAvailable` → `IsActive` | +| `2-Nine.Application/Services/SampleDataWorkflowService.cs` | Reference update | `IsAvailable` → `IsActive` | +| `1-Nine.Infrastructure/Data/Migrations/20260313122831_RenameIsAvailableToIsActive.cs` | New file | EF migration for current-era DB files | +| `1-Nine.Infrastructure/Data/Migrations/ApplicationDbContextModelSnapshot.cs` | Updated | Reflects `IsActive` column name | +| `4-Nine/appsettings.json` | Config fix | Version/SchemaVersion/DatabaseFileName alignment | + +### Deleted Files + +| File | Reason | +| ---------------------------------------------------- | ------------------------------------------------------- | +| `0-Nine.Core/Entities/DatabaseEraResult.cs` | Dead code — era concept replaced by inline bridge logic | +| `1-Nine.Infrastructure/Services/DatabaseEraState.cs` | Dead code — same reason | + +### Interface Changes + +| Interface | Change | +| ------------------ | ----------------------------------------------------------------- | +| `IDatabaseService` | Removed `CheckEraAsync()` — no longer part of the public contract | + +### Feature Behaviours Changed + +| Feature | Before | After | +| --------------------- | ------------------------------------- | ---------------------------------------- | +| Property availability | `Property.IsAvailable` boolean | `Property.IsActive` boolean | +| Invoice uniqueness | Per-database unique invoice numbers | Per-organisation unique invoice numbers | +| Payment uniqueness | No uniqueness constraint | Per-organisation unique payment numbers | +| Sample data filtering | Not possible | `IsSampleData` flag on all entity tables | +| Pre-squash DB startup | Crash (EF migration history mismatch) | Automatic bridge, then normal startup | + +--- + +## 7. Testing Plan + +### Test Matrix + +| # | Test | Database State | Expected Result | +| --- | -------------------------------------- | -------------------------------------------------------------------------------- | --------------------------------------------------------------- | +| 1 | Pre-squash fast-fail guard | Any Aquiis DB without bridge code | App refuses to start with clear error ✅ Done | +| 2 | Nine v1.0.0 → v1.1.0 upgrade | Nine `app_v1.0.0.db` present, `app_v1.1.0.db` absent | File copied, EF migrations applied, app starts ✅ Done | +| 3 | Nine v1.1.0 same-version restart | Nine `app_v1.1.0.db` present, 0 pending migrations | App starts immediately, no migration work ✅ Done | +| 4a | Aquiis v0.3.0 → Nine v1.1.0 era bridge | Aquiis `app_v0.3.0.db` placed as `app_v1.1.0.db` | Bridge fires, all DDL applied, app fully functional 🔴 Pending | +| 4b | Aquiis v1.0.0 → Nine v1.1.0 era bridge | Aquiis `app_v1.0.0.db` picked up by version-skip scan, copied to `app_v1.1.0.db` | Bridge fires, all DDL applied, app fully functional 🔴 Pending | +| 4c | Aquiis v1.1.0 → Nine v1.1.0 era bridge | Aquiis `app_v1.1.0.db` already in place (no copy needed) | Bridge fires, all DDL applied, app fully functional 🔴 Pending | +| 5 | Fresh install | No `.db` file present | EF creates blank DB, seed data applied 🔴 Pending | +| 6 | Same-version restart (post-bridge) | `app_v1.1.0.db` previously bridged | Bridge guard returns early, 0 migrations, fast start 🔴 Pending | + +### Test 4 Setup (Aquiis Era Bridge) + +Run once for each Aquiis source database. The verification checklist is identical for all three. + +**Test 4a — Aquiis v0.3.0 source:** + +```bash +mv ~/.config/Nine/Data/app_v1.1.0.db ~/.config/Nine/Data/app_v1.1.0.db.bak 2>/dev/null || true +cp ~/.config/Nine/Backups/app_v0.3.0.db ~/.config/Nine/Data/app_v1.1.0.db +cd /home/cisguru/Source/Nine/4-Nine && dotnet run --unpacked +``` + +**Test 4b — Aquiis v1.0.0 source** (version-skip scan path — Aquiis `app_v1.0.0.db`, no `app_v1.1.0.db`): + +```bash +mv ~/.config/Nine/Data/app_v1.1.0.db ~/.config/Nine/Data/app_v1.1.0.db.bak 2>/dev/null || true +cp ~/.config/Nine/Backups/app_v1.0.0.db.aquiis ~/.config/Nine/Data/app_v1.0.0.db +cd /home/cisguru/Source/Nine/4-Nine && dotnet run --unpacked +# Program.cs version-skip scan should copy app_v1.0.0.db → app_v1.1.0.db, then bridge fires +``` + +**Test 4c — Aquiis v1.1.0 source** (file already in place, no copy needed): + +```bash +cp ~/.config/Nine/Backups/app_v1.1.0.db.aquiis ~/.config/Nine/Data/app_v1.1.0.db +cd /home/cisguru/Source/Nine/4-Nine && dotnet run --unpacked +# Nine sees app_v1.1.0.db already exists, goes straight to DatabaseService; bridge fires +``` + +**Verification checklist:** + +- [ ] Log line: `First-ancestor era database detected. Applying bridge...` +- [ ] Log line: `Database initialization complete.` +- [ ] App UI loads without errors +- [ ] Properties list shows `IsActive` filter working +- [ ] Invoice and payment records display correctly +- [ ] `IsSampleData` column visible to admin tools (optional) +- [ ] `sqlite3 ~/.config/Nine/Data/app_v1.1.0.db "SELECT MigrationId FROM __EFMigrationsHistory ORDER BY MigrationId"` — shows 8 current-era IDs, no `20260106195859_InitialCreate` + +--- + +## 8. Remaining Work + +| # | Task | Status | Notes | +| --- | ------------------------------------------------------ | ---------- | ----------------------------------------------------------------------------- | +| 1 | `DetectEraAsync()` + era switch in `InitializeAsync()` | ✅ Done | `DatabaseService.cs` — returns `Era` enum; switch drives bridge or backup | +| 2 | `ApplyFirstAncestorBridgeAsync()` | ✅ Done | Aquiis → Nine v1.x bridge SQL | +| 2b | `ApplySecondAncestorBridgeAsync()` | ✅ Done | Placeholder; populate at next major squash | +| 2c | `BackupUnsupportedDatabaseAsync()` | ✅ Done | Copies DB to Backups/ before `SchemaNotSupportedException` is thrown | +| 2d | `DatabaseExceptions.cs` | ✅ Done | `SchemaNotSupportedException`, `SchemaInvalidException`, `MigrationException` | +| 3 | Build verification | 🔴 Pending | `dotnet build Nine.sln` — confirm 0 errors | +| 4 | Run Tests 4–6 | 🔴 Pending | See test matrix above | +| 5 | Commit and merge | 🔴 Pending | `app-database-upgrade` → `phase-0-baseline` | + +--- + +## 9. Future Era Bridges + +This implementation establishes the pattern for all future era bridges. + +### Terminology + +The term "previous" refers to the application version, not the user's data. A user running Nine v1.x has current, active data — it is stored in a schema that predates the current release. The bridge performs a schema transformation only; the data arrives intact on the other side. + +### Marker Rotation at Each Major Squash + +At each major squash (e.g. v1.x → v2.0.0): + +1. The squash produces a new baseline migration ID (e.g. `20270XXX_v2_0_0_InitialCreate`) +2. Rotate the three marker constants in `DatabaseService.cs`: + - `CurrentEraMarker` → new baseline ID + - `FirstAncestorMarker` → previous `CurrentEraMarker` + - `SecondAncestorMarker` → previous `FirstAncestorMarker` +3. Write new `ApplyFirstAncestorBridgeAsync()` SQL for the previous-era → current-era schema transformation +4. Populate `ApplySecondAncestorBridgeAsync()` — see note below +5. `DetectEraAsync()` and the `InitializeAsync()` switch require no structural changes + +### Composing the Second-Ancestor Bridge + +`ApplySecondAncestorBridgeAsync()` cannot simply copy the previous `ApplyFirstAncestorBridgeAsync()` SQL. A database two generations behind must arrive at the **current** schema, not an intermediate one. The method must contain all schema transformations needed to reach the current era directly: + +**Example at v2.0.0:** + +| Detected era | Method called | SQL required | +| -------------------------------- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `FirstAncestor` (Nine v1.x) | `ApplyFirstAncestorBridgeAsync()` | v1.x schema → v2.x schema | +| `SecondAncestor` (Aquiis v0.3.x) | `ApplySecondAncestorBridgeAsync()` | Aquiis schema → v1.x schema transformations **+** v1.x schema → v2.x schema transformations, as a single transaction | + +The second-ancestor bridge starts from the previous `ApplyFirstAncestorBridgeAsync()` SQL (Aquiis → v1.x) and appends the new `ApplyFirstAncestorBridgeAsync()` SQL (v1.x → v2.x). Both sets of steps run in one transaction — the data is transformed directly from two generations back to current without stopping at an intermediate state. + +### Support Policy + +Nine supports the current era plus two generations back. + +| Era distance | `DetectEraAsync` result | Action | +| -------------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------- | +| 0 (current) | `Era.Current` | No-op; `MigrateAsync` handles any pending migrations | +| 1 generation | `Era.FirstAncestor` | `ApplyFirstAncestorBridgeAsync()` transforms schema to current era | +| 2 generations | `Era.SecondAncestor` | `ApplySecondAncestorBridgeAsync()` transforms schema to current era in a single transaction | +| 3+ generations | `Era.NotSupported` | DB backed up to `Backups/`; `DatabaseExceptions.SchemaNotSupportedException` thrown; user directed to import workflow | + +--- + +## 10. Related Documents + +- [Database-Upgrade-Strategy.md](Database-Upgrade-Strategy.md) — Runtime upgrade path (version-skip scan, `PreviousDatabaseFileName` logic) +- [Database-Management-Guide.md](Database-Management-Guide.md) — Operational guidance for database files +- [Compatibility-Matrix.md](Compatibility-Matrix.md) — Supported upgrade paths by version diff --git a/Documentation/Database-Upgrade-Strategy.md b/Documentation/Database-Upgrade-Strategy.md index d5c0f2c..73aa0f0 100644 --- a/Documentation/Database-Upgrade-Strategy.md +++ b/Documentation/Database-Upgrade-Strategy.md @@ -31,6 +31,9 @@ Nine uses versioned SQLite database files (`app_vX.Y.0.db`) tied to the applicat - `PreviousDatabaseFileName` is set by `bump-version.sh` to the previous file name - A compiled binary is **version-locked**: it only looks for the filename baked into its `appsettings.json` +> **App version and schema version are not always in sync.** +> A user running v2.0.5 has app version `2.0.5` but database schema version `2.0.0`. The schema version is anchored to the MAJOR.MINOR milestone at which the schema last changed, not the current app version. PATCH releases carry the same `DatabaseFileName` as their MINOR base — the entire upgrade block is skipped on startup because the target DB file already exists. + --- ## Upgrade Path: How It Works @@ -291,6 +294,58 @@ Simulates a user on v1.0.0 who skips v1.1.0 and installs v1.2.0 directly. This i --- +## Known Incompatibility: Pre-Squash Databases (Pre-v1.0.0) + +### Background + +Between v0.3.0 and v1.0.0, the EF Core migration history was **squashed**. The many individual incremental migrations were replaced by a single consolidated `InitialCreate` migration that creates the full schema in one step. This means the upgrade path described above **only works for databases created at v1.0.0 or later**. + +### What Happens When a Pre-v1.0.0 Database Is Used + +Despite the version-skip glob scan finding and copying the old file correctly, the migration step fails: + +1. Version-skip glob finds `app_v0.3.0.db` — copied to target ✅ +2. EF inspects `__EFMigrationsHistory` in the copied DB — no `InitialCreate` entry found +3. EF treats `InitialCreate` as **pending** and tries to run it +4. Every `CREATE TABLE` statement fails: `SQLite Error 1: 'table "AspNetRoles" already exists'` +5. App crashes at startup ❌ + +This is correct and expected behaviour. The pre-squash database already has all the tables (built by old individual migrations), but its history table doesn't contain the `InitialCreate` entry that the post-squash code expects. + +### Supported Upgrade Boundary + +| Source DB Version | Target Version | Auto-Upgrade? | +| ------------------- | -------------- | --------------------------------------------- | +| v1.0.0 or later | Any v1.x.x+ | ✅ Supported | +| v0.x.x (pre-v1.0.0) | Any v1.x.x+ | ❌ Not supported (pre-squash incompatibility) | + +**The auto-upgrade path is guaranteed only for v1.0.0+ (post-squash) source databases.** + +### Test Result + +Verified in manual test (`test1.2.log`): running `Nine-1.1.0-x86_64.AppImage` against `app_v0.3.0.db` produces: + +``` +Version skip detected: copying app_v0.3.0.db → app_v1.1.0.db (expected app_v1.0.0.db was absent) +SQLite Error 1: 'table "AspNetRoles" already exists' +``` + +The copy logic is correct; only the migration fails. + +### Recovery Strategy (Backlog) + +Currently a pre-v1.0.0 database causes a crash with no user-friendly recovery path. Planned improvement: + +1. Detect the incompatible schema error during `MigrateAsync()` +2. Present the user with a choice: + - **Start fresh** — create a new blank database (all legacy data lost, app becomes usable immediately) + - **Abort** — exit cleanly, leaving the old database file intact +3. Future: provide a data-import tool to extract records from the old schema and import them into the new one + +_This recovery path is a backlog item and is not part of the v1.1.0 release._ + +--- + ## Merge Plan This fix ships as part of **v1.1.0**. The branch workflow: diff --git a/set-version.sh b/set-version.sh new file mode 100755 index 0000000..1d7f703 --- /dev/null +++ b/set-version.sh @@ -0,0 +1,124 @@ +#!/bin/bash + +# Nine Version Setter +# Directly sets a specific version without calculating a bump. +# +# Usage: +# ./set-version.sh "1.1.0" # set app version only +# ./set-version.sh "1.1.0" --db-version "1.0.0" # set app + DB version (prev = current) +# ./set-version.sh "1.1.0" --db-version "1.0.0" --prev-db "0.3.0" # full manual control + +set -e + +NEW_VERSION="${1}" +DB_VERSION="" +PREV_DB_VERSION="" + +# Parse flags +NEXT_FLAG="" +for arg in "$@"; do + case "$arg" in + --db-version) + NEXT_FLAG="db" + ;; + --prev-db) + NEXT_FLAG="prev" + ;; + *) + if [ "$NEXT_FLAG" == "db" ]; then + DB_VERSION="$arg" + NEXT_FLAG="" + elif [ "$NEXT_FLAG" == "prev" ]; then + PREV_DB_VERSION="$arg" + NEXT_FLAG="" + fi + ;; + esac +done + +CSPROJ_FILE="4-Nine/Nine.csproj" +APPSETTINGS_FILE="4-Nine/appsettings.json" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +echo -e "${YELLOW}🔧 Nine Version Setter${NC}" +echo "" + +# Validate new version +if [ -z "$NEW_VERSION" ]; then + echo -e "${RED}❌ Usage: ./set-version.sh \"1.1.0\" [--db-version \"1.0.0\"]${NC}" + exit 1 +fi + +if ! [[ "$NEW_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo -e "${RED}❌ Invalid version format: $NEW_VERSION (expected MAJOR.MINOR.PATCH)${NC}" + exit 1 +fi + +if [ -n "$DB_VERSION" ] && ! [[ "$DB_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo -e "${RED}❌ Invalid db-version format: $DB_VERSION (expected MAJOR.MINOR.PATCH)${NC}" + exit 1 +fi + +if [ -n "$PREV_DB_VERSION" ] && ! [[ "$PREV_DB_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo -e "${RED}❌ Invalid prev-db format: $PREV_DB_VERSION (expected MAJOR.MINOR.PATCH)${NC}" + exit 1 +fi + +# Read current version +CURRENT_VERSION=$(grep -oP '\K[^<]+' "$CSPROJ_FILE" | head -1) +if [ -z "$CURRENT_VERSION" ]; then + echo -e "${RED}❌ Could not find version in $CSPROJ_FILE${NC}" + exit 1 +fi + +echo -e "Current Version: ${GREEN}$CURRENT_VERSION${NC}" +echo -e "New Version: ${GREEN}$NEW_VERSION${NC}" + +if [ -n "$DB_VERSION" ]; then + CURRENT_DB=$(grep -oP '"DatabaseFileName": "\K[^"]+' "$APPSETTINGS_FILE") + NEW_DB="app_v${DB_VERSION}.db" + # Determine what PreviousDatabaseFileName will be set to + if [ -n "$PREV_DB_VERSION" ]; then + PREV_DB="app_v${PREV_DB_VERSION}.db" + else + PREV_DB="$CURRENT_DB" + fi + echo -e "Database: ${GREEN}$CURRENT_DB${NC} → ${GREEN}$NEW_DB${NC} (schema: $DB_VERSION)" + echo -e "Previous DB: ${GREEN}$PREV_DB${NC}" +fi + +echo "" +read -p "Continue? (y/n) " -n 1 -r +echo "" +if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo -e "${RED}❌ Aborted${NC}" + exit 1 +fi + +# Update .csproj +echo -e "${YELLOW}📝 Updating $CSPROJ_FILE...${NC}" +sed -i "s|$CURRENT_VERSION|$NEW_VERSION|g" "$CSPROJ_FILE" +sed -i "s|$CURRENT_VERSION.0|$NEW_VERSION.0|g" "$CSPROJ_FILE" +sed -i "s|$CURRENT_VERSION.0|$NEW_VERSION.0|g" "$CSPROJ_FILE" +sed -i "s|$CURRENT_VERSION|$NEW_VERSION|g" "$CSPROJ_FILE" + +# Update appsettings.json version +echo -e "${YELLOW}📝 Updating $APPSETTINGS_FILE...${NC}" +sed -i "s|\"Version\": \"$CURRENT_VERSION\"|\"Version\": \"$NEW_VERSION\"|g" "$APPSETTINGS_FILE" + +# Update database settings if --db-version was provided +if [ -n "$DB_VERSION" ]; then + sed -i "s|\"DatabaseFileName\": \"$CURRENT_DB\"|\"DatabaseFileName\": \"$NEW_DB\"|g" "$APPSETTINGS_FILE" + sed -i "s|\"PreviousDatabaseFileName\": \"[^\"]*\"|\"PreviousDatabaseFileName\": \"$PREV_DB\"|g" "$APPSETTINGS_FILE" + sed -i "s|\"SchemaVersion\": \"[^\"]*\"|\"SchemaVersion\": \"$DB_VERSION\"|g" "$APPSETTINGS_FILE" + sed -i "s|DataSource=[^;]*/app_v[^;.]*\.db|DataSource=Data/$NEW_DB|g" "$APPSETTINGS_FILE" + echo -e " DB file: ${GREEN}$NEW_DB${NC}, PreviousDB: ${GREEN}$PREV_DB${NC}" +fi + +echo "" +echo -e "${GREEN}✅ Version set to $NEW_VERSION${NC}" From 4e157af9ff412a1a1378213f36ea4ad061b98b55 Mon Sep 17 00:00:00 2001 From: CIS Guru Date: Thu, 2 Apr 2026 18:14:44 -0500 Subject: [PATCH 8/9] fix: auto-expire lease updates property status, clears renewal, filters dashboard widget --- 2-Nine.Application/Services/LeaseService.cs | 1 + .../Workflows/LeaseWorkflowService.cs | 20 +++++++++++++++++++ .../Entities/Leases/LeaseRenewalList.razor | 15 +++++++++++--- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/2-Nine.Application/Services/LeaseService.cs b/2-Nine.Application/Services/LeaseService.cs index 6a6419d..b6878c2 100644 --- a/2-Nine.Application/Services/LeaseService.cs +++ b/2-Nine.Application/Services/LeaseService.cs @@ -502,6 +502,7 @@ public async Task UpdateLeaseStatusAsync(Guid leaseId, string newStatus) if (!hasOtherActiveLeases) { property.IsActive = true; + property.Status = ApplicationConstants.PropertyStatuses.Available; } } diff --git a/2-Nine.Application/Services/Workflows/LeaseWorkflowService.cs b/2-Nine.Application/Services/Workflows/LeaseWorkflowService.cs index 158c07f..5a1cdbf 100644 --- a/2-Nine.Application/Services/Workflows/LeaseWorkflowService.cs +++ b/2-Nine.Application/Services/Workflows/LeaseWorkflowService.cs @@ -607,9 +607,29 @@ 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, 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) From f2a241d166444da3ca141c5c28f61a5cb629cfd8 Mon Sep 17 00:00:00 2001 From: CIS Guru Date: Sat, 4 Apr 2026 11:46:21 -0500 Subject: [PATCH 9/9] feat: add other-DB delete, expand import, bump v1.1.0 - Add DatabasePreviewService.DeleteDatabaseFileAsync with active-DB safety guard; refactor DeleteBackup to use it - Add Delete button and handler for Previous Database Versions Found files in Database Settings - Expand import to cover 7 additional tables: CalendarEvents, Inspections, Checklists, ChecklistItems, SecurityDeposits, Notes, Notifications - with required-cols sets and ImportResult DTO properties - Add preview DTOs, count cards, tabs, and data panels for all 7 tables in DatabasePreview.razor - ImportNotificationsAsync: fan out each notification to all real users (excluding system user) with fresh IDs - Fix SignalR ObjectDisposedException in NotificationCenter on dispose - Bump application and database version to 1.1.0 --- .../Models/DTOs/DatabasePreviewDTOs.cs | 107 ++++- .../Services/DatabasePreviewService.cs | 416 ++++++++++++++++- .../Notifications/NotificationCenter.razor | 7 +- .../Settings/Pages/DatabasePreview.razor | 418 ++++++++++++++++++ .../Settings/Pages/DatabaseSettings.razor | 50 ++- 4-Nine/Nine.csproj | 8 +- 4-Nine/appsettings.json | 8 +- 7 files changed, 986 insertions(+), 28 deletions(-) diff --git a/2-Nine.Application/Models/DTOs/DatabasePreviewDTOs.cs b/2-Nine.Application/Models/DTOs/DatabasePreviewDTOs.cs index 7bacd25..09c8575 100644 --- a/2-Nine.Application/Models/DTOs/DatabasePreviewDTOs.cs +++ b/2-Nine.Application/Models/DTOs/DatabasePreviewDTOs.cs @@ -13,6 +13,13 @@ public class DatabasePreviewData 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(); @@ -21,6 +28,13 @@ public class DatabasePreviewData 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(); } /// @@ -121,6 +135,88 @@ public class RepairPreview 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) /// @@ -155,10 +251,19 @@ public class ImportResult 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; + + InvoicesImported + PaymentsImported + MaintenanceRequestsImported + RepairsImported + DocumentsImported + + CalendarEventsImported + InspectionsImported + ChecklistsImported + ChecklistItemsImported + + SecurityDepositsImported + NotesImported + NotificationsImported; } /// diff --git a/2-Nine.Application/Services/DatabasePreviewService.cs b/2-Nine.Application/Services/DatabasePreviewService.cs index 6dae065..41c128b 100644 --- a/2-Nine.Application/Services/DatabasePreviewService.cs +++ b/2-Nine.Application/Services/DatabasePreviewService.cs @@ -1,4 +1,5 @@ using Nine.Application.Models.DTOs; +using Nine.Core.Constants; using Nine.Core.Interfaces; using Nine.Core.Interfaces.Services; using Nine.Infrastructure.Data; @@ -219,6 +220,41 @@ public async Task AddToBackupsAsync(string sourceFilePa } } + /// + /// 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}"); + + // 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 // ------------------------------------------------------------------------- @@ -254,6 +290,27 @@ public async Task AddToBackupsAsync(string sourceFilePa 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. @@ -353,17 +410,31 @@ public async Task GetPreviewDataAsync(string backupFileName 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 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), + 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), }; if (propCols.IsSupersetOf(PropertyRequiredCols)) @@ -387,11 +458,34 @@ public async Task GetPreviewDataAsync(string backupFileName 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} properties, {T} tenants, {L} leases, {I} invoices, {Pay} payments, {M} maintenance, {R} repairs", + "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.InvoiceCount, data.PaymentCount, data.MaintenanceCount, data.RepairCount, + data.CalendarEventCount, data.InspectionCount, data.ChecklistCount, + data.SecurityDepositCount, data.NoteCount, data.NotificationCount); return data; } @@ -597,6 +691,164 @@ FROM [Repairs] r 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 = 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 = 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; + } + + 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; + } + + 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) // ------------------------------------------------------------------------- @@ -658,7 +910,14 @@ public async Task ImportFromPreviewAsync(string backupFileName, st 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.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(); } @@ -702,6 +961,141 @@ public async Task ImportFromPreviewAsync(string backupFileName, st /// 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, 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/4-Nine/Features/Administration/Settings/Pages/DatabasePreview.razor b/4-Nine/Features/Administration/Settings/Pages/DatabasePreview.razor index d4519ee..486c70d 100644 --- a/4-Nine/Features/Administration/Settings/Pages/DatabasePreview.razor +++ b/4-Nine/Features/Administration/Settings/Pages/DatabasePreview.razor @@ -160,6 +160,73 @@ + +
+
+
+
+ +

@previewData.CalendarEventCount

+ Calendar Events +
+
+
+
+
+
+ +

@previewData.InspectionCount

+ Inspections +
+
+
+
+
+
+ +

@previewData.ChecklistCount

+ Checklists +
+
+
+
+
+
+ +

@previewData.ChecklistItemCount

+ Checklist Items +
+
+
+
+
+
+ +

@previewData.SecurityDepositCount

+ Security Deposits +
+
+
+
+
+
+ +

@previewData.NoteCount

+ Notes +
+
+
+
+
+
+ +

@previewData.NotificationCount

+ Notifications +
+
+
+
+
@@ -214,6 +281,48 @@ Repairs + + + + + + +
@@ -521,6 +630,287 @@

No repairs found

} } + + + @if (activeTab == "calendarevents") + { + @if (previewData.CalendarEvents?.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) + { + + + + + + + } + +
PropertyTypeInspected ByCompleted On
@ins.PropertyAddress@ins.InspectionType@(ins.InspectedBy ?? "—")@ins.CompletedOn.ToString("d")
+
+ @if (previewData.InspectionCount > 100) + { +
+ Showing first 100 of @previewData.InspectionCount inspections. All records will be imported. +
+ } + } + 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) + { +
+ 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

+ } + }
@@ -574,6 +964,34 @@ {
  • @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 diff --git a/4-Nine/Features/Administration/Settings/Pages/DatabaseSettings.razor b/4-Nine/Features/Administration/Settings/Pages/DatabaseSettings.razor index 988d46c..c4c949c 100644 --- a/4-Nine/Features/Administration/Settings/Pages/DatabaseSettings.razor +++ b/4-Nine/Features/Administration/Settings/Pages/DatabaseSettings.razor @@ -386,11 +386,16 @@ @onclick="() => PreviewOtherDatabase(db)"> Preview - + } @@ -706,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) @@ -763,6 +765,40 @@ } } + 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/Nine.csproj b/4-Nine/Nine.csproj index 0ff7993..0b3cdf2 100644 --- a/4-Nine/Nine.csproj +++ b/4-Nine/Nine.csproj @@ -12,10 +12,10 @@ Data/Migrations - 1.3.0 - 1.3.0.0 - 1.3.0.0 - 1.3.0 + 1.1.0 + 1.1.0.0 + 1.1.0.0 + 1.1.0 diff --git a/4-Nine/appsettings.json b/4-Nine/appsettings.json index 033ad14..e8b67a9 100644 --- a/4-Nine/appsettings.json +++ b/4-Nine/appsettings.json @@ -14,7 +14,7 @@ "ApplicationSettings": { "AppName": "Nine", "ProductName": "Nine", - "Version": "1.3.0", + "Version": "1.1.0", "Company": "Nine App, LLC", "Copyright": "Copyright © 2024 Nine App, LLC. All rights reserved.", "Description": "Nine is a modern property management desktop application. Nine is open-source free software.", @@ -23,9 +23,9 @@ "Email": "cisguru@outlook.com", "Repository": "https://github.com/xnodeoncode/nine", "SoftDeleteEnabled": true, - "DatabaseFileName": "app_v1.3.0.db", - "PreviousDatabaseFileName": "app_v1.2.0.db", - "SchemaVersion": "1.3.0", + "DatabaseFileName": "app_v1.1.0.db", + "PreviousDatabaseFileName": "app_v1.0.0.db", + "SchemaVersion": "1.1.0", "MaxOrganizationUsers": 3, "License": "MIT", "LicenseUrl": "https://github.com/xnodeoncode/nine/blob/main/LICENSE",