diff --git a/2-Aquiis.Application/DependencyInjection.cs b/2-Aquiis.Application/DependencyInjection.cs index 4b2848a..e5dd138 100644 --- a/2-Aquiis.Application/DependencyInjection.cs +++ b/2-Aquiis.Application/DependencyInjection.cs @@ -27,15 +27,19 @@ public static IServiceCollection AddApplication( services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/2-Aquiis.Application/Services/DigestService.cs b/2-Aquiis.Application/Services/DigestService.cs new file mode 100644 index 0000000..16a664e --- /dev/null +++ b/2-Aquiis.Application/Services/DigestService.cs @@ -0,0 +1,653 @@ +using Aquiis.Core.Constants; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Aquiis.Application.Services; + +/// +/// Service responsible for generating and sending daily and weekly digest emails to users. +/// Consolidates activity metrics and notifications into scheduled email summaries. +/// +public class DigestService +{ + private readonly ILogger _logger; + private readonly ApplicationDbContext _dbContext; + private readonly NotificationService _notificationService; + + public DigestService( + ILogger logger, + ApplicationDbContext dbContext, + NotificationService notificationService) + { + _logger = logger; + _dbContext = dbContext; + _notificationService = notificationService; + } + + /// + /// Sends daily digest emails to users who have opted in. + /// Consolidates notifications and activity metrics from the last 24 hours. + /// + public async Task SendDailyDigestsAsync() + { + try + { + _logger.LogInformation("Starting daily digest email processing at {Time}", DateTime.Now); + + var now = DateTime.UtcNow; + var yesterday = now.AddDays(-1); + + // Get all users with daily digest enabled + var usersWithDigest = await _dbContext.NotificationPreferences + .Where(np => np.EnableDailyDigest && + !np.IsDeleted && + np.EnableEmailNotifications && + !string.IsNullOrEmpty(np.EmailAddress)) + .Include(np => np.Organization) + .ToListAsync(); + + if (!usersWithDigest.Any()) + { + _logger.LogInformation("No users have daily digest enabled"); + return; + } + + _logger.LogInformation("Processing daily digests for {Count} user(s)", usersWithDigest.Count); + + var successCount = 0; + var failureCount = 0; + + // Process each user + foreach (var userPrefs in usersWithDigest) + { + try + { + // Get user's notifications from last 24 hours + var userNotifications = await _dbContext.Notifications + .Where(n => n.RecipientUserId == userPrefs.UserId && + n.OrganizationId == userPrefs.OrganizationId && + n.CreatedOn >= yesterday && + !n.IsDeleted) + .OrderByDescending(n => n.CreatedOn) + .Take(50) // Limit to 50 most recent + .ToListAsync(); + + var orgId = userPrefs.OrganizationId; + + // Collect daily metrics + var newApplications = await _dbContext.RentalApplications + .Where(a => a.OrganizationId == orgId && + a.CreatedOn >= yesterday && + !a.IsDeleted) + .CountAsync(); + + var paymentsToday = await _dbContext.Payments + .Where(p => p.OrganizationId == orgId && + p.PaidOn.Date >= yesterday.Date && + !p.IsDeleted) + .ToListAsync(); + + var paymentsCount = paymentsToday.Count; + var paymentsTotal = paymentsToday.Sum(p => p.Amount); + + var maintenanceCreated = await _dbContext.MaintenanceRequests + .Where(m => m.OrganizationId == orgId && + m.RequestedOn >= yesterday && + !m.IsDeleted) + .CountAsync(); + + var maintenanceCompleted = await _dbContext.MaintenanceRequests + .Where(m => m.OrganizationId == orgId && + m.CompletedOn != null && + m.CompletedOn >= yesterday && + !m.IsDeleted) + .CountAsync(); + + var inspectionsScheduled = await _dbContext.Inspections + .Where(i => i.OrganizationId == orgId && + i.CreatedOn >= yesterday && + !i.IsDeleted) + .CountAsync(); + + var inspectionsCompleted = await _dbContext.Inspections + .Where(i => i.OrganizationId == orgId && + //i.CompletedOn != null && + i.CompletedOn >= yesterday && + !i.IsDeleted) + .CountAsync(); + + var leasesExpiringSoon = await _dbContext.Leases + .Where(l => l.OrganizationId == orgId && + l.EndDate <= DateTime.Today.AddDays(90) && + l.EndDate > DateTime.Today && + !l.IsDeleted) + .CountAsync(); + + var activeProperties = await _dbContext.Properties + .Where(p => p.OrganizationId == orgId && + !p.IsDeleted) + .CountAsync(); + + var occupiedProperties = await _dbContext.Properties + .Where(p => p.OrganizationId == orgId && + !p.IsDeleted && + p.Status == ApplicationConstants.PropertyStatuses.Occupied) + .CountAsync(); + + var outstandingInvoices = await _dbContext.Invoices + .Where(i => i.OrganizationId == orgId && + (i.Status == ApplicationConstants.InvoiceStatuses.Pending || + i.Status == ApplicationConstants.InvoiceStatuses.Overdue) && + !i.IsDeleted) + .SumAsync(i => i.Amount); + + // Build email content + var subject = $"Daily Digest - {userPrefs.Organization?.Name ?? "Property Management"} - {DateTime.Today:MMM dd, yyyy}"; + + var body = BuildDailyDigestEmailBody( + userPrefs.UserId, + userPrefs.Organization?.Name ?? "Property Management", + DateTime.Today.ToString("MMMM dd, yyyy"), + newApplications, + paymentsCount, + paymentsTotal, + maintenanceCreated, + maintenanceCompleted, + inspectionsScheduled, + inspectionsCompleted, + leasesExpiringSoon, + userNotifications, + activeProperties, + occupiedProperties, + outstandingInvoices); + + // Send email via NotificationService's email service + await _notificationService.SendEmailDirectAsync( + userPrefs.EmailAddress!, + subject, + body); + + successCount++; + _logger.LogInformation( + "Sent daily digest to {Email} for organization {OrgName} ({NotificationCount} notifications)", + userPrefs.EmailAddress, + userPrefs.Organization?.Name, + userNotifications.Count); + } + catch (Exception ex) + { + failureCount++; + _logger.LogError(ex, + "Failed to send daily digest to user {UserId} in organization {OrgId}", + userPrefs.UserId, + userPrefs.OrganizationId); + } + } + + _logger.LogInformation( + "Daily digest processing complete: {Success} sent, {Failures} failed", + successCount, + failureCount); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing daily digest emails"); + } + } + + /// + /// Builds the HTML body for the daily digest email + /// + private string BuildDailyDigestEmailBody( + string userId, + string organizationName, + string date, + int newApplications, + int paymentsCount, + decimal paymentsTotal, + int maintenanceCreated, + int maintenanceCompleted, + int inspectionsScheduled, + int inspectionsCompleted, + int leasesExpiringSoon, + List notifications, + int activeProperties, + int occupiedProperties, + decimal outstandingInvoices) + { + var notificationList = string.Empty; + if (notifications.Any()) + { + var notificationItems = notifications + .Take(10) // Show top 10 + .Select(n => + { + var typeColor = n.Type switch + { + "Success" => "#10b981", + "Warning" => "#f59e0b", + "Error" => "#ef4444", + _ => "#6b7280" + }; + + return $@" +
+
{n.Title}
+
{n.Message}
+
{n.CreatedOn:MMM dd, HH:mm} ยท {n.Type}
+
"; + }); + + notificationList = string.Join("", notificationItems); + } + else + { + notificationList = "

No new notifications in the last 24 hours

"; + } + + var occupancyRate = activeProperties > 0 ? (occupiedProperties * 100.0 / activeProperties) : 0; + + return $@" + + + + + + + +
+
+

Daily Digest

+

{organizationName}

+

{date}

+
+ +
+

+ ๐Ÿ“Š Activity Overview (Last 24 Hours) +

+ + + + + + + + + + + + + + + + + + + + + + +
๐Ÿ“ New Applications{newApplications}
๐Ÿ’ฐ Payments Received{paymentsCount} (${paymentsTotal:N2})
๐Ÿ”ง Maintenance Requests{maintenanceCreated} new / {maintenanceCompleted} completed
๐Ÿ” Inspections{inspectionsScheduled} scheduled / {inspectionsCompleted} completed
โฐ Leases Expiring Soon{leasesExpiringSoon} (within 90 days)
+ +

+ ๐Ÿ”” Your Notifications +

+
+ {notificationList} +
+ +

+ ๐Ÿ“ˆ Quick Stats +

+ + + + + + + + + + + + + +
Active Properties{activeProperties}
Occupancy Rate{occupancyRate:F1}% ({occupiedProperties}/{activeProperties})
Outstanding Invoices${outstandingInvoices:N2}
+
+ +
+

+ You received this email because you have daily digest notifications enabled. +

+

+ To manage your notification preferences, visit your account settings. +

+
+
+ +"; + } + + /// + /// Sends weekly digest emails to users who have opted in. + /// Consolidates 7-day metrics and provides weekly summary. + /// + public async Task SendWeeklyDigestsAsync() + { + try + { + _logger.LogInformation("Starting weekly digest email processing at {Time}", DateTime.Now); + + var startDate = DateTime.Now.AddDays(-7); + var endDate = DateTime.Now; + + // Get all users with weekly digest enabled + var usersWithWeeklyDigest = await _dbContext.NotificationPreferences + .Where(np => np.EnableWeeklyDigest && + !np.IsDeleted && + np.EnableEmailNotifications && + !string.IsNullOrEmpty(np.EmailAddress)) + .Include(np => np.Organization) + .ToListAsync(); + + if (!usersWithWeeklyDigest.Any()) + { + _logger.LogInformation("No users have weekly digest enabled"); + return; + } + + _logger.LogInformation("Sending weekly digests to {Count} users", usersWithWeeklyDigest.Count); + + int successCount = 0; + int failureCount = 0; + + foreach (var pref in usersWithWeeklyDigest) + { + try + { + var organizationId = pref.OrganizationId; + var userId = pref.UserId; + var organizationName = pref.Organization?.Name ?? "Property Management"; + + // Collect weekly metrics + var applicationsSubmitted = await _dbContext.RentalApplications + .Where(a => a.OrganizationId == organizationId && + a.CreatedOn >= startDate && + !a.IsDeleted) + .CountAsync(); + + var applicationsApproved = await _dbContext.RentalApplications + .Where(a => a.OrganizationId == organizationId && + a.LastModifiedOn >= startDate && + a.Status == ApplicationConstants.ApplicationStatuses.Approved && + !a.IsDeleted) + .CountAsync(); + + var applicationsDenied = await _dbContext.RentalApplications + .Where(a => a.OrganizationId == organizationId && + a.LastModifiedOn >= startDate && + a.Status == ApplicationConstants.ApplicationStatuses.Denied && + !a.IsDeleted) + .CountAsync(); + + var leasesCreated = await _dbContext.Leases + .Where(l => l.OrganizationId == organizationId && + l.StartDate >= startDate.Date && + !l.IsDeleted) + .CountAsync(); + + var leasesExpiring = await _dbContext.Leases + .Where(l => l.OrganizationId == organizationId && + l.EndDate >= DateTime.Today && + l.EndDate <= DateTime.Today.AddDays(30) && + !l.IsDeleted) + .CountAsync(); + + var weeklyPayments = await _dbContext.Payments + .Where(p => p.OrganizationId == organizationId && + p.PaidOn >= startDate && + !p.IsDeleted) + .ToListAsync(); + + var totalRevenue = weeklyPayments.Sum(p => p.Amount); + var paymentsReceived = weeklyPayments.Count; + + var maintenanceCreated = await _dbContext.MaintenanceRequests + .Where(m => m.OrganizationId == organizationId && + m.RequestedOn >= startDate && + !m.IsDeleted) + .CountAsync(); + + var maintenanceCompleted = await _dbContext.MaintenanceRequests + .Where(m => m.OrganizationId == organizationId && + m.CompletedOn != null && + m.CompletedOn >= startDate && + !m.IsDeleted) + .CountAsync(); + + var maintenancePending = await _dbContext.MaintenanceRequests + .Where(m => m.OrganizationId == organizationId && + m.Status != ApplicationConstants.MaintenanceRequestStatuses.Completed && + m.Status != ApplicationConstants.MaintenanceRequestStatuses.Cancelled && + !m.IsDeleted) + .CountAsync(); + + var activeProperties = await _dbContext.Properties + .Where(p => p.OrganizationId == organizationId && + !p.IsDeleted) + .CountAsync(); + + var occupiedProperties = await _dbContext.Properties + .Where(p => p.OrganizationId == organizationId && + !p.IsDeleted && + p.Status == ApplicationConstants.PropertyStatuses.Occupied) + .CountAsync(); + + var occupancyRate = activeProperties > 0 ? (occupiedProperties * 100.0 / activeProperties) : 0; + + var vacantOver30Days = await _dbContext.Properties + .Where(p => p.OrganizationId == organizationId && + p.Status == ApplicationConstants.PropertyStatuses.Available && + p.LastModifiedOn < DateTime.Now.AddDays(-30) && + !p.IsDeleted) + .CountAsync(); + + var overdueMaintenanceCount = await _dbContext.MaintenanceRequests + .Where(m => m.OrganizationId == organizationId && + m.ScheduledOn < DateTime.Now && + m.Status != ApplicationConstants.MaintenanceRequestStatuses.Completed && + m.Status != ApplicationConstants.MaintenanceRequestStatuses.Cancelled && + !m.IsDeleted) + .CountAsync(); + + var overdueInvoicesOver30Days = await _dbContext.Invoices + .Where(i => i.OrganizationId == organizationId && + i.DueOn < DateTime.Now.AddDays(-30) && + (i.Status == ApplicationConstants.InvoiceStatuses.Pending || + i.Status == ApplicationConstants.InvoiceStatuses.Overdue) && + !i.IsDeleted) + .CountAsync(); + + // Build email + var emailBody = BuildWeeklyDigestEmailBody( + organizationName, + startDate, + endDate, + applicationsSubmitted, + applicationsApproved, + applicationsDenied, + leasesCreated, + leasesExpiring, + totalRevenue, + paymentsReceived, + maintenanceCreated, + maintenanceCompleted, + maintenancePending, + activeProperties, + occupancyRate, + vacantOver30Days, + overdueMaintenanceCount, + overdueInvoicesOver30Days + ); + + // Get user's email address from NotificationPreferences + var userEmail = pref.EmailAddress; + + if (!string.IsNullOrEmpty(userEmail)) + { + // Send email + await _notificationService.SendEmailDirectAsync( + userEmail, + $"Weekly Digest: {organizationName} - {startDate:MMM dd} to {endDate:MMM dd}", + emailBody, + organizationName + ); + + successCount++; + _logger.LogInformation("Sent weekly digest to user {UserId}", userId); + } + else + { + _logger.LogWarning("User {UserId} has no email address configured for weekly digest", userId); + } + } + catch (Exception ex) + { + failureCount++; + _logger.LogError(ex, "Error sending weekly digest to user {UserId}", pref.UserId); + } + } + + _logger.LogInformation("Weekly digest summary: {Success} succeeded, {Failure} failed", + successCount, failureCount); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in SendWeeklyDigestsAsync"); + throw; + } + } + + /// + /// Builds the HTML body for weekly digest emails + /// + private string BuildWeeklyDigestEmailBody( + string organizationName, + DateTime startDate, + DateTime endDate, + int applicationsSubmitted, + int applicationsApproved, + int applicationsDenied, + int leasesCreated, + int leasesExpiring, + decimal totalRevenue, + int paymentsReceived, + int maintenanceCreated, + int maintenanceCompleted, + int maintenancePending, + int activeProperties, + double occupancyRate, + int vacantOver30Days, + int overdueMaintenanceCount, + int overdueInvoicesOver30Days) + { + var needsAttention = string.Empty; + if (overdueMaintenanceCount > 0 || vacantOver30Days > 0 || overdueInvoicesOver30Days > 0) + { + needsAttention = $@" +

+ โš ๏ธ Needs Attention +

+ "; + + if (overdueMaintenanceCount > 0) + { + needsAttention += $@" + + + + "; + } + + if (vacantOver30Days > 0) + { + needsAttention += $@" + + + + "; + } + + if (overdueInvoicesOver30Days > 0) + { + needsAttention += $@" + + + + "; + } + + needsAttention += @" +
Overdue Maintenance Requests{overdueMaintenanceCount}
Properties Vacant >30 Days{vacantOver30Days}
Invoices Overdue >30 Days{overdueInvoicesOver30Days}
"; + } + + return $@" + + + + + + + +
+
+

Weekly Digest

+

{organizationName}

+

{startDate:MMM dd} - {endDate:MMM dd, yyyy}

+
+ +
+

+ ๐Ÿ“Š Weekly Overview +

+ + + + + + + + + + + + + + + + + + + + + + +
๐Ÿ“ Applications{applicationsSubmitted} submitted, {applicationsApproved} approved, {applicationsDenied} denied
๐Ÿ“„ Leases{leasesCreated} new, {leasesExpiring} expiring next 30 days
๐Ÿ’ฐ Revenue${totalRevenue:N2} collected ({paymentsReceived} payments)
๐Ÿ”ง Maintenance{maintenanceCreated} new, {maintenanceCompleted} resolved, {maintenancePending} pending
๐Ÿ  Occupancy{occupancyRate:F1}% ({activeProperties} properties)
+ + {needsAttention} +
+ +
+

+ You received this email because you have weekly digest notifications enabled. +

+

+ To manage your notification preferences, visit your account settings. +

+
+
+ +"; + } +} diff --git a/2-Aquiis.Application/Services/DocumentNotificationService.cs b/2-Aquiis.Application/Services/DocumentNotificationService.cs new file mode 100644 index 0000000..06ce32b --- /dev/null +++ b/2-Aquiis.Application/Services/DocumentNotificationService.cs @@ -0,0 +1,183 @@ +using Aquiis.Core.Constants; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Aquiis.Application.Services; + +/// +/// Service responsible for checking document expirations and sending reminders. +/// Monitors lease documents, insurance certificates, and property-related documents for expiration. +/// +public class DocumentNotificationService +{ + private readonly ILogger _logger; + private readonly ApplicationDbContext _dbContext; + private readonly NotificationService _notificationService; + + public DocumentNotificationService( + ILogger logger, + ApplicationDbContext dbContext, + NotificationService notificationService) + { + _logger = logger; + _dbContext = dbContext; + _notificationService = notificationService; + } + + /// + /// Checks for old documents (lease agreements, insurance certs) that may need renewal. + /// Sends notifications to admin users for documents older than 180 days. + /// + public async Task CheckDocumentExpirationsAsync() + { + try + { + _logger.LogInformation("Starting document expiration check at {Time}", DateTime.Now); + + var now = DateTime.Now; + var days180 = now.AddDays(-180); + + var organizations = await _dbContext.Organizations + .Where(o => !o.IsDeleted) + .Select(o => o.Id) + .ToListAsync(); + + int notificationsCreated = 0; + + foreach (var organizationId in organizations) + { + // Get admin users for this organization + var adminUsers = await _dbContext.UserOrganizations + .Where(uom => uom.OrganizationId == organizationId && + uom.Role == "Admin" && + !uom.IsDeleted) + .ToListAsync(); + + if (!adminUsers.Any()) + { + _logger.LogWarning("No admin users found for organization {OrgId}", organizationId); + continue; + } + + // Check for old lease documents (6+ months old) + var oldLeaseDocuments = await _dbContext.Documents + .Where(d => d.OrganizationId == organizationId && + !d.IsDeleted && + d.LeaseId != null && + d.CreatedOn < days180 && + d.DocumentType == ApplicationConstants.DocumentTypes.LeaseAgreement) + .Include(d => d.Lease) + .ThenInclude(l => l!.Property) + .Take(5) // Limit to 5 per check to avoid spam + .ToListAsync(); + + foreach (var doc in oldLeaseDocuments) + { + if (doc.Lease?.Property != null) + { + var daysOld = (now - doc.CreatedOn).Days; + var title = "Document Review Needed"; + var message = $"Lease document for {doc.Lease.Property.Address} was created {daysOld} days ago. Please review if renewal or update is needed."; + + foreach (var user in adminUsers) + { + await _notificationService.SendNotificationAsync( + recipientUserId: user.UserId, + title: title, + message: message, + type: "System", + category: "DocumentReview", + relatedEntityId: doc.Id, + relatedEntityType: "Document" + ); + notificationsCreated++; + } + } + } + + // Check for old property insurance documents (6+ months old) + // Note: Since Insurance/Certificate are not in DocumentTypes, checking description/filename + var oldPropertyInsurance = await _dbContext.Documents + .Where(d => d.OrganizationId == organizationId && + !d.IsDeleted && + d.PropertyId != null && + d.CreatedOn < days180 && + (d.Description.ToLower().Contains("insurance") || + d.Description.ToLower().Contains("certificate") || + d.FileName.ToLower().Contains("insurance") || + d.FileName.ToLower().Contains("certificate"))) + .Include(d => d.Property) + .Take(5) + .ToListAsync(); + + foreach (var doc in oldPropertyInsurance) + { + if (doc.Property != null) + { + var daysOld = (now - doc.CreatedOn).Days; + var title = "Document Review Needed"; + var message = $"Insurance/Certificate document for {doc.Property.Address} is {daysOld} days old. Please verify current coverage."; + + foreach (var user in adminUsers) + { + await _notificationService.SendNotificationAsync( + recipientUserId: user.UserId, + title: title, + message: message, + type: "System", + category: "DocumentReview", + relatedEntityId: doc.Id, + relatedEntityType: "Document" + ); + notificationsCreated++; + } + } + } + + // Check for old tenant documents - 1 year old + var days365 = now.AddDays(-365); + var oldTenantDocuments = await _dbContext.Documents + .Where(d => d.OrganizationId == organizationId && + !d.IsDeleted && + d.TenantId != null && + d.CreatedOn < days365) + .Include(d => d.Tenant) + .Take(5) + .ToListAsync(); + + foreach (var doc in oldTenantDocuments) + { + if (doc.Tenant != null) + { + var daysOld = (now - doc.CreatedOn).Days; + var title = "Document Review Needed"; + var message = $"{doc.DocumentType} for tenant {doc.Tenant.FullName} is {daysOld} days old. Please verify if update is needed."; + + foreach (var user in adminUsers) + { + await _notificationService.SendNotificationAsync( + recipientUserId: user.UserId, + title: title, + message: message, + type: "System", + category: "DocumentReview", + relatedEntityId: doc.Id, + relatedEntityType: "Document" + ); + notificationsCreated++; + } + } + } + } + + _logger.LogInformation( + "Document expiration check complete: {NotificationCount} notifications created across {OrgCount} organizations", + notificationsCreated, + organizations.Count); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking document expirations"); + } + } +} diff --git a/2-Aquiis.Application/Services/LeaseNotificationService.cs b/2-Aquiis.Application/Services/LeaseNotificationService.cs new file mode 100644 index 0000000..fea2936 --- /dev/null +++ b/2-Aquiis.Application/Services/LeaseNotificationService.cs @@ -0,0 +1,189 @@ +using Aquiis.Core.Constants; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Aquiis.Application.Services; + +/// +/// Service responsible for monitoring lease expirations and sending renewal reminders. +/// Sends notifications at 90, 60, and 30 days before lease expiration. +/// +public class LeaseNotificationService +{ + private readonly ILogger _logger; + private readonly ApplicationDbContext _dbContext; + private readonly NotificationService _notificationService; + + public LeaseNotificationService( + ILogger logger, + ApplicationDbContext dbContext, + NotificationService notificationService) + { + _logger = logger; + _dbContext = dbContext; + _notificationService = notificationService; + } + + /// + /// Checks for leases expiring and sends renewal notifications at 90, 60, and 30 day marks. + /// Updates lease renewal tracking fields accordingly. + /// + public async Task SendLeaseRenewalRemindersAsync(Guid organizationId, CancellationToken stoppingToken = default) + { + try + { + var today = DateTime.Today; + var addresses = string.Empty; + + // Check for leases expiring in 90 days (initial notification) + var leasesExpiring90Days = await _dbContext.Leases + .Include(l => l.Tenant) + .Include(l => l.Property) + .Where(l => !l.IsDeleted && + l.OrganizationId == organizationId && + l.Status == "Active" && + l.EndDate >= today.AddDays(85) && + l.EndDate <= today.AddDays(95) && + (l.RenewalNotificationSent == null || !l.RenewalNotificationSent.Value)) + .ToListAsync(stoppingToken); + + foreach (var lease in leasesExpiring90Days) + { + _logger.LogInformation( + "Lease expiring in 90 days: Lease ID {LeaseId}, Property: {PropertyAddress}, Tenant: {TenantName}, End Date: {EndDate}", + lease.Id, + lease.Property?.Address ?? "Unknown", + lease.Tenant?.FullName ?? "Unknown", + lease.EndDate.ToString("MMM dd, yyyy")); + + lease.RenewalNotificationSent = true; + lease.RenewalNotificationSentOn = DateTime.UtcNow; + lease.RenewalStatus = "Pending"; + lease.LastModifiedOn = DateTime.UtcNow; + lease.LastModifiedBy = ApplicationConstants.SystemUser.Id; // Automated task + + addresses += lease.Property?.Address + "\n"; + } + + // Send organization-wide notification if any leases are expiring + if (!string.IsNullOrEmpty(addresses)) + { + await _notificationService.NotifyAllUsersAsync( + organizationId, + "90-Day Lease Renewal Notification", + $"The following properties have leases expiring in 90 days:\n\n{addresses}", + NotificationConstants.Types.Info, + NotificationConstants.Categories.Lease, + null, + ApplicationConstants.EntityTypes.Lease); + } + + // clear addresses for next use + addresses = string.Empty; + + // Check for leases expiring in 60 days (reminder) + var leasesExpiring60Days = await _dbContext.Leases + .Include(l => l.Tenant) + .Include(l => l.Property) + .Where(l => !l.IsDeleted && + l.OrganizationId == organizationId && + l.Status == "Active" && + l.EndDate >= today.AddDays(55) && + l.EndDate <= today.AddDays(65) && + l.RenewalNotificationSent == true && + l.RenewalReminderSentOn == null) + .ToListAsync(stoppingToken); + + foreach (var lease in leasesExpiring60Days) + { + _logger.LogInformation( + "Lease expiring in 60 days (reminder): Lease ID {LeaseId}, Property: {PropertyAddress}, Tenant: {TenantName}, End Date: {EndDate}", + lease.Id, + lease.Property?.Address ?? "Unknown", + lease.Tenant?.FullName ?? "Unknown", + lease.EndDate.ToString("MMM dd, yyyy")); + + lease.RenewalReminderSentOn = DateTime.UtcNow; + lease.LastModifiedOn = DateTime.UtcNow; + lease.LastModifiedBy = ApplicationConstants.SystemUser.Id; // Automated task + + addresses += lease.Property?.Address + "\n"; + } + + // Send organization-wide notification if any leases are expiring + if (!string.IsNullOrEmpty(addresses)) + { + await _notificationService.NotifyAllUsersAsync( + organizationId, + "60-Day Lease Renewal Notification", + $"The following properties have leases expiring in 60 days:\n\n{addresses}", + NotificationConstants.Types.Info, + NotificationConstants.Categories.Lease, + null, + ApplicationConstants.EntityTypes.Lease); + } + + // clear addresses for next use + addresses = string.Empty; + + // Check for leases expiring in 30 days (final reminder) + var leasesExpiring30Days = await _dbContext.Leases + .Include(l => l.Tenant) + .Include(l => l.Property) + .Where(l => !l.IsDeleted && + l.OrganizationId == organizationId && + l.Status == "Active" && + l.EndDate >= today.AddDays(25) && + l.EndDate <= today.AddDays(35) && + l.RenewalStatus == "Pending") + .ToListAsync(stoppingToken); + + foreach (var lease in leasesExpiring30Days) + { + _logger.LogInformation( + "Lease expiring in 30 days (final reminder): Lease ID {LeaseId}, Property: {PropertyAddress}, Tenant: {TenantName}, End Date: {EndDate}", + lease.Id, + lease.Property?.Address ?? "Unknown", + lease.Tenant?.FullName ?? "Unknown", + lease.EndDate.ToString("MMM dd, yyyy")); + + addresses += lease.Property?.Address + "\n"; + } + + // Send organization-wide notification if any leases are expiring + if (!string.IsNullOrEmpty(addresses)) + { + await _notificationService.NotifyAllUsersAsync( + organizationId, + "30-Day Lease Renewal Notification", + $"The following properties have leases expiring in 30 days:\n\n{addresses}", + NotificationConstants.Types.Info, + NotificationConstants.Categories.Lease, + null, + ApplicationConstants.EntityTypes.Lease); + } + + // Save all updates + var totalUpdated = leasesExpiring90Days.Count + leasesExpiring60Days.Count + + leasesExpiring30Days.Count; + + if (totalUpdated > 0) + { + await _dbContext.SaveChangesAsync(stoppingToken); + _logger.LogInformation( + "Processed {Count} lease renewal notifications for organization {OrganizationId}: {Initial} initial, {Reminder60} 60-day, {Reminder30} 30-day reminders", + totalUpdated, + organizationId, + leasesExpiring90Days.Count, + leasesExpiring60Days.Count, + leasesExpiring30Days.Count); + } + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error checking lease renewals for organization {OrganizationId}", + organizationId); + } + } +} diff --git a/2-Aquiis.Application/Services/MaintenanceNotificationService.cs b/2-Aquiis.Application/Services/MaintenanceNotificationService.cs new file mode 100644 index 0000000..bfba8f4 --- /dev/null +++ b/2-Aquiis.Application/Services/MaintenanceNotificationService.cs @@ -0,0 +1,384 @@ +using Aquiis.Core.Constants; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Aquiis.Application.Services; + +/// +/// Service responsible for generating and sending maintenance status summaries. +/// Provides weekly reports on maintenance request metrics, pending work, and alerts. +/// +public class MaintenanceNotificationService +{ + private readonly ILogger _logger; + private readonly ApplicationDbContext _dbContext; + private readonly NotificationService _notificationService; + + public MaintenanceNotificationService( + ILogger logger, + ApplicationDbContext dbContext, + NotificationService notificationService) + { + _logger = logger; + _dbContext = dbContext; + _notificationService = notificationService; + } + + /// + /// Sends weekly maintenance status summary emails to property managers. + /// Includes weekly statistics, pending requests by priority, and alerts. + /// + public async Task SendMaintenanceStatusSummaryAsync() + { + try + { + _logger.LogInformation("Starting maintenance status summary email processing at {Time}", DateTime.Now); + + var now = DateTime.Now; + var startDate = now.AddDays(-7); + + // Get all users with weekly digest enabled (maintenance summary is part of weekly digest) + var usersWithWeeklyDigest = await _dbContext.NotificationPreferences + .Where(np => np.EnableWeeklyDigest && + !np.IsDeleted && + np.EnableEmailNotifications && + !string.IsNullOrEmpty(np.EmailAddress)) + .Include(np => np.Organization) + .ToListAsync(); + + if (!usersWithWeeklyDigest.Any()) + { + _logger.LogInformation("No users have weekly digest enabled for maintenance summary"); + return; + } + + _logger.LogInformation("Sending maintenance status summary to {Count} users", usersWithWeeklyDigest.Count); + + int successCount = 0; + int failureCount = 0; + + foreach (var pref in usersWithWeeklyDigest) + { + try + { + var organizationId = pref.OrganizationId; + var organizationName = pref.Organization?.Name ?? "Property Management"; + + // Weekly maintenance statistics + var weeklySubmitted = await _dbContext.MaintenanceRequests + .Where(m => m.OrganizationId == organizationId && + m.RequestedOn >= startDate && + !m.IsDeleted) + .CountAsync(); + + var weeklyCompleted = await _dbContext.MaintenanceRequests + .Where(m => m.OrganizationId == organizationId && + m.CompletedOn >= startDate && + m.Status == ApplicationConstants.MaintenanceRequestStatuses.Completed && + !m.IsDeleted) + .CountAsync(); + + var completedRequests = await _dbContext.MaintenanceRequests + .Where(m => m.OrganizationId == organizationId && + m.CompletedOn != null && + m.CompletedOn >= startDate && + m.Status == ApplicationConstants.MaintenanceRequestStatuses.Completed && + !m.IsDeleted) + .Select(m => new + { + ResolutionTime = (m.CompletedOn!.Value - m.RequestedOn).Days + }) + .ToListAsync(); + + var avgResolutionDays = completedRequests.Any() + ? completedRequests.Average(r => r.ResolutionTime) + : 0; + + // Pending requests by priority + var urgentPending = await _dbContext.MaintenanceRequests + .Where(m => m.OrganizationId == organizationId && + m.Status != ApplicationConstants.MaintenanceRequestStatuses.Completed && + m.Status != ApplicationConstants.MaintenanceRequestStatuses.Cancelled && + m.Priority == ApplicationConstants.MaintenanceRequestPriorities.Urgent && + !m.IsDeleted) + .CountAsync(); + + var highPending = await _dbContext.MaintenanceRequests + .Where(m => m.OrganizationId == organizationId && + m.Status != ApplicationConstants.MaintenanceRequestStatuses.Completed && + m.Status != ApplicationConstants.MaintenanceRequestStatuses.Cancelled && + m.Priority == ApplicationConstants.MaintenanceRequestPriorities.High && + !m.IsDeleted) + .CountAsync(); + + var mediumPending = await _dbContext.MaintenanceRequests + .Where(m => m.OrganizationId == organizationId && + m.Status != ApplicationConstants.MaintenanceRequestStatuses.Completed && + m.Status != ApplicationConstants.MaintenanceRequestStatuses.Cancelled && + m.Priority == ApplicationConstants.MaintenanceRequestPriorities.Medium && + !m.IsDeleted) + .CountAsync(); + + var lowPending = await _dbContext.MaintenanceRequests + .Where(m => m.OrganizationId == organizationId && + m.Status != ApplicationConstants.MaintenanceRequestStatuses.Completed && + m.Status != ApplicationConstants.MaintenanceRequestStatuses.Cancelled && + m.Priority == ApplicationConstants.MaintenanceRequestPriorities.Low && + !m.IsDeleted) + .CountAsync(); + + // Alerts + var overdueCount = await _dbContext.MaintenanceRequests + .Where(m => m.OrganizationId == organizationId && + m.ScheduledOn < now && + m.Status != ApplicationConstants.MaintenanceRequestStatuses.Completed && + m.Status != ApplicationConstants.MaintenanceRequestStatuses.Cancelled && + !m.IsDeleted) + .CountAsync(); + + var unassignedCount = await _dbContext.MaintenanceRequests + .Where(m => m.OrganizationId == organizationId && + m.Status == ApplicationConstants.MaintenanceRequestStatuses.Submitted && + !m.IsDeleted) + .CountAsync(); + + // Top 5 properties by maintenance requests + var topProperties = await _dbContext.MaintenanceRequests + .Where(m => m.OrganizationId == organizationId && + m.RequestedOn >= startDate && + //m.PropertyId != null && + !m.IsDeleted) + .GroupBy(m => new { m.PropertyId, m.Property!.Address }) + .Select(g => new + { + PropertyAddress = g.Key.Address, + RequestCount = g.Count() + }) + .OrderByDescending(p => p.RequestCount) + .Take(5) + .ToListAsync(); + + // Financial summary + var weeklyMaintenanceCost = await _dbContext.MaintenanceRequests + .Where(m => m.OrganizationId == organizationId && + m.RequestedOn >= startDate && + //m.EstimatedCost != null && + !m.IsDeleted) + .SumAsync(m => m.EstimatedCost); + + var totalPendingCost = await _dbContext.MaintenanceRequests + .Where(m => m.OrganizationId == organizationId && + m.Status != ApplicationConstants.MaintenanceRequestStatuses.Completed && + m.Status != ApplicationConstants.MaintenanceRequestStatuses.Cancelled && + //m.EstimatedCost != null && + !m.IsDeleted) + .SumAsync(m => m.EstimatedCost); + + // Build email + var emailBody = BuildMaintenanceStatusSummaryEmailBody( + organizationName, + startDate, + now, + weeklySubmitted, + weeklyCompleted, + avgResolutionDays, + urgentPending, + highPending, + mediumPending, + lowPending, + overdueCount, + unassignedCount, + topProperties.Select(p => (p.PropertyAddress ?? "Unknown", p.RequestCount)).ToList(), + weeklyMaintenanceCost, + totalPendingCost + ); + + var userEmail = pref.EmailAddress; + if (!string.IsNullOrEmpty(userEmail)) + { + await _notificationService.SendEmailDirectAsync( + userEmail, + $"Maintenance Status Summary: {organizationName} - {startDate:MMM dd} to {now:MMM dd}", + emailBody, + organizationName + ); + + successCount++; + _logger.LogInformation("Sent maintenance summary to user {UserId}", pref.UserId); + } + } + catch (Exception ex) + { + failureCount++; + _logger.LogError(ex, "Error sending maintenance summary to user {UserId}", pref.UserId); + } + } + + _logger.LogInformation( + "Maintenance status summary complete: {Success} sent, {Failure} failed", + successCount, + failureCount); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in SendMaintenanceStatusSummaryAsync"); + } + } + + /// + /// Builds the HTML body for maintenance status summary emails + /// + private string BuildMaintenanceStatusSummaryEmailBody( + string organizationName, + DateTime startDate, + DateTime endDate, + int weeklySubmitted, + int weeklyCompleted, + double avgResolutionDays, + int urgentPending, + int highPending, + int mediumPending, + int lowPending, + int overdueCount, + int unassignedCount, + List<(string address, int count)> topProperties, + decimal weeklyMaintenanceCost, + decimal totalPendingCost) + { + var topPropertiesHtml = string.Empty; + if (topProperties.Any()) + { + var propertyRows = topProperties.Select(p => $@" + + {p.address} + {p.count} + "); + + topPropertiesHtml = $@" +

+ ๐Ÿ† Top Properties (Most Requests This Week) +

+ + {string.Join("", propertyRows)} +
"; + } + + var alertsHtml = string.Empty; + if (overdueCount > 0 || unassignedCount > 0) + { + alertsHtml = $@" +

+ โš ๏ธ Alerts +

+ "; + + if (overdueCount > 0) + { + alertsHtml += $@" + + + + "; + } + + if (unassignedCount > 0) + { + alertsHtml += $@" + + + + "; + } + + alertsHtml += "
Overdue Requests{overdueCount}
Unassigned Requests{unassignedCount}
"; + } + + return $@" + + + + + + + +
+
+

๐Ÿ”ง Maintenance Status Summary

+

{organizationName}

+

{startDate:MMM dd} - {endDate:MMM dd, yyyy}

+
+ +
+

+ ๐Ÿ“Š Weekly Statistics +

+ + + + + + + + + + + + + + +
New Requests Submitted{weeklySubmitted}
Requests Completed{weeklyCompleted}
Avg Resolution Time{avgResolutionDays:F1} days
+ +

+ ๐Ÿ“‹ Pending Requests by Priority +

+ + + + + + + + + + + + + + + + + +
๐Ÿ”ด Urgent{urgentPending}
๐ŸŸ  High{highPending}
๐ŸŸก Medium{mediumPending}
โšช Low{lowPending}
+ + {alertsHtml} + + {topPropertiesHtml} + +

+ ๐Ÿ’ฐ Financial Summary +

+ + + + + + + + + +
Weekly Maintenance Cost${weeklyMaintenanceCost:N2}
Total Pending Cost${totalPendingCost:N2}
+
+ +
+

+ This maintenance summary is part of your weekly digest. +

+

+ To manage your notification preferences, visit your account settings. +

+
+
+ +"; + } +} diff --git a/2-Aquiis.Application/Services/NotificationService.cs b/2-Aquiis.Application/Services/NotificationService.cs index b92cd30..cc5275d 100644 --- a/2-Aquiis.Application/Services/NotificationService.cs +++ b/2-Aquiis.Application/Services/NotificationService.cs @@ -2,7 +2,6 @@ using Aquiis.Core.Constants; using Aquiis.Core.Entities; using Aquiis.Core.Interfaces.Services; -using Aquiis.Application.Services; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -12,7 +11,6 @@ public class NotificationService : BaseService { private readonly IEmailService _emailService; private readonly ISMSService _smsService; - private new readonly ILogger _logger; public NotificationService( ApplicationDbContext context, @@ -25,7 +23,6 @@ public NotificationService( { _emailService = emailService; _smsService = smsService; - _logger = logger; } /// @@ -110,6 +107,38 @@ await _smsService.SendSMSAsync( return notification; } + public async Task NotifyAllUsersAsync( + Guid organizationId, + string title, + string message, + string type, + string category, + Guid? relatedEntityId = null, + string? relatedEntityType = null) + { + // Query users through UserOrganizations to find all users in the organization + var userIds = await _context.UserOrganizations + .Where(uo => uo.OrganizationId == organizationId && uo.IsActive && !uo.IsDeleted) + .Select(uo => uo.UserId) + .ToListAsync(); + + Notification? lastNotification = null; + + foreach (var userId in userIds) + { + lastNotification = await SendNotificationAsync( + userId, + title, + message, + type, + category, + relatedEntityId, + relatedEntityType); + } + + return lastNotification!; + } + /// /// Mark notification as read /// @@ -271,4 +300,13 @@ private bool ShouldSendSMS(string category, NotificationPreferences prefs) _ => false }; } + + /// + /// Sends an email directly without creating a notification record. + /// Useful for digest emails and system notifications. + /// + public async Task SendEmailDirectAsync(string to, string subject, string body, string? fromName = null) + { + await _emailService.SendEmailAsync(to, subject, body, fromName); + } } \ No newline at end of file diff --git a/2-Aquiis.Application/Services/PaymentService.cs b/2-Aquiis.Application/Services/PaymentService.cs index 5536e8f..9c10a33 100644 --- a/2-Aquiis.Application/Services/PaymentService.cs +++ b/2-Aquiis.Application/Services/PaymentService.cs @@ -15,13 +15,16 @@ namespace Aquiis.Application.Services /// public class PaymentService : BaseService { + private readonly NotificationService _notificationService; public PaymentService( ApplicationDbContext context, ILogger logger, IUserContextService userContext, + NotificationService notificationService, IOptions settings) : base(context, logger, userContext, settings) { + _notificationService = notificationService; } /// diff --git a/2-Aquiis.Application/Services/ScheduledTaskService.cs b/2-Aquiis.Application/Services/ScheduledTaskService.cs index 4581fb3..73c4d7e 100644 --- a/2-Aquiis.Application/Services/ScheduledTaskService.cs +++ b/2-Aquiis.Application/Services/ScheduledTaskService.cs @@ -13,9 +13,11 @@ public class ScheduledTaskService : BackgroundService { private readonly ILogger _logger; private readonly IServiceProvider _serviceProvider; + private Timer? _timer; private Timer? _dailyTimer; private Timer? _hourlyTimer; + private Timer? _weeklyTimer; public ScheduledTaskService( ILogger logger, @@ -60,7 +62,23 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) TimeSpan.Zero, // Start immediately TimeSpan.FromHours(1)); - _logger.LogInformation("Scheduled Task Service started. Daily tasks will run at midnight, hourly tasks every hour."); + // Calculate time until next Monday 6 AM for weekly tasks + var daysUntilMonday = ((int)DayOfWeek.Monday - (int)now.DayOfWeek + 7) % 7; + if (daysUntilMonday == 0 && now.Hour >= 6) + { + daysUntilMonday = 7; // If it's Monday and past 6 AM, schedule for next Monday + } + var nextMonday = now.Date.AddDays(daysUntilMonday).AddHours(6); + var timeUntilMonday = nextMonday - now; + + // Start weekly timer (executes every Monday at 6 AM) + _weeklyTimer = new Timer( + async _ => await ExecuteWeeklyTasks(), + null, + 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."); // Keep the service running while (!stoppingToken.IsCancellationRequested) @@ -79,6 +97,7 @@ private async Task DoWork(CancellationToken stoppingToken) { var dbContext = scope.ServiceProvider.GetRequiredService(); var organizationService = scope.ServiceProvider.GetRequiredService(); + var leaseNotificationService = scope.ServiceProvider.GetRequiredService(); // Get all distinct organization IDs from OrganizationSettings var organizations = await dbContext.OrganizationSettings @@ -114,7 +133,7 @@ private async Task DoWork(CancellationToken stoppingToken) } // Task 4: Check for expiring leases and send renewal notifications - await CheckLeaseRenewals(dbContext, organizationId, stoppingToken); + await leaseNotificationService.SendLeaseRenewalRemindersAsync(organizationId, stoppingToken); // Task 5: Expire overdue leases using workflow service (with audit logging) var expiredLeaseCount = await ExpireOverdueLeases(scope, organizationId); @@ -275,115 +294,7 @@ private async Task SendPaymentReminders( } } - private async Task CheckLeaseRenewals(ApplicationDbContext dbContext, Guid organizationId, CancellationToken stoppingToken) - { - try - { - var today = DateTime.Today; - - // Check for leases expiring in 90 days (initial notification) - var leasesExpiring90Days = await dbContext.Leases - .Include(l => l.Tenant) - .Include(l => l.Property) - .Where(l => !l.IsDeleted && - l.OrganizationId == organizationId && - l.Status == "Active" && - l.EndDate >= today.AddDays(85) && - l.EndDate <= today.AddDays(95) && - (l.RenewalNotificationSent == null || !l.RenewalNotificationSent.Value)) - .ToListAsync(stoppingToken); - - foreach (var lease in leasesExpiring90Days) - { - // TODO: Send email notification when email service is integrated - _logger.LogInformation( - "Lease expiring in 90 days: Lease ID {LeaseId}, Property: {PropertyAddress}, Tenant: {TenantName}, End Date: {EndDate}", - lease.Id, - lease.Property?.Address ?? "Unknown", - lease.Tenant?.FullName ?? "Unknown", - lease.EndDate.ToString("MMM dd, yyyy")); - - lease.RenewalNotificationSent = true; - lease.RenewalNotificationSentOn = DateTime.UtcNow; - lease.RenewalStatus = "Pending"; - lease.LastModifiedOn = DateTime.UtcNow; - lease.LastModifiedBy = ApplicationConstants.SystemUser.Id; // Automated task - } - - // Check for leases expiring in 60 days (reminder) - var leasesExpiring60Days = await dbContext.Leases - .Include(l => l.Tenant) - .Include(l => l.Property) - .Where(l => !l.IsDeleted && - l.OrganizationId == organizationId && - l.Status == "Active" && - l.EndDate >= today.AddDays(55) && - l.EndDate <= today.AddDays(65) && - l.RenewalNotificationSent == true && - l.RenewalReminderSentOn == null) - .ToListAsync(stoppingToken); - - foreach (var lease in leasesExpiring60Days) - { - // TODO: Send reminder email - _logger.LogInformation( - "Lease expiring in 60 days (reminder): Lease ID {LeaseId}, Property: {PropertyAddress}, Tenant: {TenantName}, End Date: {EndDate}", - lease.Id, - lease.Property?.Address ?? "Unknown", - lease.Tenant?.FullName ?? "Unknown", - lease.EndDate.ToString("MMM dd, yyyy")); - - lease.RenewalReminderSentOn = DateTime.UtcNow; - lease.LastModifiedOn = DateTime.UtcNow; - lease.LastModifiedBy = ApplicationConstants.SystemUser.Id; // Automated task - } - - // Check for leases expiring in 30 days (final reminder) - var leasesExpiring30Days = await dbContext.Leases - .Include(l => l.Tenant) - .Include(l => l.Property) - .Where(l => !l.IsDeleted && - l.OrganizationId == organizationId && - l.Status == "Active" && - l.EndDate >= today.AddDays(25) && - l.EndDate <= today.AddDays(35) && - l.RenewalStatus == "Pending") - .ToListAsync(stoppingToken); - - foreach (var lease in leasesExpiring30Days) - { - // TODO: Send final reminder - _logger.LogInformation( - "Lease expiring in 30 days (final reminder): Lease ID {LeaseId}, Property: {PropertyAddress}, Tenant: {TenantName}, End Date: {EndDate}", - lease.Id, - lease.Property?.Address ?? "Unknown", - lease.Tenant?.FullName ?? "Unknown", - lease.EndDate.ToString("MMM dd, yyyy")); - } - - // Note: Lease expiration is now handled by ExpireOverdueLeases() - // which uses LeaseWorkflowService for proper audit logging - - var totalUpdated = leasesExpiring90Days.Count + leasesExpiring60Days.Count + - leasesExpiring30Days.Count; - - if (totalUpdated > 0) - { - await dbContext.SaveChangesAsync(stoppingToken); - _logger.LogInformation( - "Processed {Count} lease renewal notifications for organization {OrganizationId}: {Initial} initial, {Reminder60} 60-day, {Reminder30} 30-day reminders", - totalUpdated, - organizationId, - leasesExpiring90Days.Count, - leasesExpiring60Days.Count, - leasesExpiring30Days.Count); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error checking lease renewals for organization {OrganizationId}", organizationId); - } - } + // Lease renewal reminder logic moved to LeaseNotificationService private async Task ExecuteDailyTasks() { @@ -411,7 +322,7 @@ private async Task ExecuteDailyTasks() var overdueInspections = await propertyService.GetPropertiesWithOverdueInspectionsAsync(); if (overdueInspections.Any()) { - _logger.LogWarning("{Count} propert(ies) have overdue routine inspections", + _logger.LogWarning("{Count} properties have overdue routine inspections", overdueInspections.Count); foreach (var property in overdueInspections.Take(5)) // Log first 5 @@ -454,12 +365,15 @@ private async Task ExecuteDailyTasks() await ProcessYearEndDividends(scope, today.Year - 1); } + // Send daily digest emails to users who have opted in + var digestService = scope.ServiceProvider.GetRequiredService(); + await digestService.SendDailyDigestsAsync(); + // Additional daily tasks: // - Generate daily reports // - Send payment reminders // - Check for overdue invoices // - Archive old records - // - Send summary emails to property managers } catch (Exception ex) { @@ -467,6 +381,30 @@ private async Task ExecuteDailyTasks() } } + // Daily digest logic moved to DigestService + + private async Task ExecuteWeeklyTasks() + { + _logger.LogInformation("Executing weekly tasks at {Time}", DateTime.Now); + + try + { + using var scope = _serviceProvider.CreateScope(); + var digestService = scope.ServiceProvider.GetRequiredService(); + var documentNotificationService = scope.ServiceProvider.GetRequiredService(); + var maintenanceNotificationService = scope.ServiceProvider.GetRequiredService(); + + await digestService.SendWeeklyDigestsAsync(); + await documentNotificationService.CheckDocumentExpirationsAsync(); + await maintenanceNotificationService.SendMaintenanceStatusSummaryAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error executing weekly tasks"); + } + } + + // Old SendDailyDigestsAsync removed - functionality in DigestService private async Task ExecuteHourlyTasks() { _logger.LogInformation("Executing hourly tasks at {Time}", DateTime.Now); @@ -788,6 +726,7 @@ public override Task StopAsync(CancellationToken stoppingToken) _timer?.Dispose(); _dailyTimer?.Change(Timeout.Infinite, 0); _hourlyTimer?.Change(Timeout.Infinite, 0); + _weeklyTimer?.Change(Timeout.Infinite, 0); return base.StopAsync(stoppingToken); } diff --git a/2-Aquiis.Application/Services/Workflows/ApplicationWorkflowService.cs b/2-Aquiis.Application/Services/Workflows/ApplicationWorkflowService.cs index 074dce3..cea2c77 100644 --- a/2-Aquiis.Application/Services/Workflows/ApplicationWorkflowService.cs +++ b/2-Aquiis.Application/Services/Workflows/ApplicationWorkflowService.cs @@ -29,14 +29,17 @@ public enum ApplicationStatus public class ApplicationWorkflowService : BaseWorkflowService, IWorkflowState { private readonly NoteService _noteService; + private readonly NotificationService _notificationService; public ApplicationWorkflowService( ApplicationDbContext context, IUserContextService userContext, - NoteService noteService) + NoteService noteService, + NotificationService notificationService) : base(context, userContext) { _noteService = noteService; + _notificationService = notificationService; } #region State Machine Implementation @@ -198,6 +201,17 @@ await LogTransitionAsync( ApplicationConstants.ApplicationStatuses.Submitted, "SubmitApplication"); + // send notification to leasing agents + await _notificationService.NotifyAllUsersAsync( + orgId, + $"New Rental Application Submitted for {property?.Address}", + $"{prospect!.FullName} has submitted a new rental application for property ID {property!.Address}.", + NotificationConstants.Types.Info, + NotificationConstants.Categories.Application, + application!.Id, + ApplicationConstants.EntityTypes.Application + ); + return WorkflowResult.Ok( application, "Application submitted successfully"); @@ -241,6 +255,17 @@ await LogTransitionAsync( application.Status, "MarkUnderReview"); + // send notification to leasing agents + await _notificationService.NotifyAllUsersAsync( + application.OrganizationId, + $"Application Marked as Under Review for {application.Property?.Address}", + $"Application ID {application.Id} has been marked as under review.", + NotificationConstants.Types.Info, + NotificationConstants.Categories.Application, + application.Id, + ApplicationConstants.EntityTypes.Application + ); + return WorkflowResult.Ok("Application marked as under review"); }); @@ -336,6 +361,17 @@ await LogTransitionAsync( application.Status, "InitiateScreening"); + // notify leasing agents + await _notificationService.NotifyAllUsersAsync( + application.OrganizationId, + $"Screening Initiated for Application ID {application.Id}", + $"Background and/or credit screening has been initiated for application ID {application.Id}.", + NotificationConstants.Types.Info, + NotificationConstants.Categories.Application, + application.Id, + ApplicationConstants.EntityTypes.Application + ); + return WorkflowResult.Ok( screening, "Screening initiated successfully"); @@ -394,6 +430,18 @@ await LogTransitionAsync( application.Status, "ApproveApplication"); + + // send notification to leasing agents + await _notificationService.NotifyAllUsersAsync( + application.OrganizationId, + $"Application Approved for {application.Property?.Address}", + $"Application ID {application.Id} has been approved.", + NotificationConstants.Types.Info, + NotificationConstants.Categories.Application, + application.Id, + ApplicationConstants.EntityTypes.Application + ); + return WorkflowResult.Ok("Application approved successfully"); }); @@ -455,6 +503,17 @@ await LogTransitionAsync( "DenyApplication", denialReason); + // send notification to leasing agents + await _notificationService.NotifyAllUsersAsync( + application.OrganizationId, + $"Application Denied for {application.Property?.Address}", + $"Application ID {application.Id} has been denied. Reason: {denialReason}", + NotificationConstants.Types.Warning, + NotificationConstants.Categories.Application, + application.Id, + ApplicationConstants.EntityTypes.Application + ); + return WorkflowResult.Ok("Application denied"); }); @@ -518,6 +577,17 @@ await LogTransitionAsync( "WithdrawApplication", withdrawalReason); + // send notification to leasing agents + await _notificationService.NotifyAllUsersAsync( + application.OrganizationId, + $"Application Withdrawn for {application.Property?.Address}", + $"Application ID {application.Id} has been withdrawn by the prospect. Reason: {withdrawalReason}", + NotificationConstants.Types.Warning, + NotificationConstants.Categories.Application, + application.Id, + ApplicationConstants.EntityTypes.Application + ); + return WorkflowResult.Ok("Application withdrawn"); }); @@ -577,6 +647,17 @@ await LogTransitionAsync( "CompleteScreening", results.ResultNotes); + // notify leasing agents + await _notificationService.NotifyAllUsersAsync( + application.OrganizationId, + $"Screening Results Updated for Application ID {application.Id}", + $"Screening results have been updated for application ID {application.Id}. Overall Result: {screening.OverallResult}", + NotificationConstants.Types.Info, + NotificationConstants.Categories.Application, + application.Id, + ApplicationConstants.EntityTypes.Application + ); + return WorkflowResult.Ok("Screening results updated successfully"); }); @@ -876,6 +957,17 @@ await LogTransitionAsync( await _noteService.AddNoteAsync(ApplicationConstants.EntityTypes.Lease, lease.Id, noteContent); } + // send notification to leasing agents + await _notificationService.NotifyAllUsersAsync( + orgId, + $"Lease Offer Accepted for {leaseOffer.Property?.Address}", + $"Lease offer ID {leaseOffer.Id} has been accepted and tenant {tenant.FirstName} {tenant.LastName} has been created.", + NotificationConstants.Types.Success, + NotificationConstants.Categories.Lease, + lease.Id, + ApplicationConstants.EntityTypes.Lease + ); + return WorkflowResult.Ok(lease, "Lease offer accepted and tenant created successfully"); }); @@ -947,6 +1039,17 @@ await LogTransitionAsync( "DeclineLeaseOffer", declineReason); + // send notification to leasing agents + await _notificationService.NotifyAllUsersAsync( + orgId, + $"Lease Offer Declined for {leaseOffer.Property?.Address}", + $"Lease offer ID {leaseOffer.Id} has been declined. Reason: {declineReason}", + NotificationConstants.Types.Warning, + NotificationConstants.Categories.Lease, + leaseOffer.Id, + ApplicationConstants.EntityTypes.Lease + ); + return WorkflowResult.Ok("Lease offer declined"); }); @@ -1017,6 +1120,17 @@ await LogTransitionAsync( "ExpireLeaseOffer", "Offer expired after 30 days"); + // send notification to leasing agents + await _notificationService.SendNotificationAsync( + userId, + $"Lease Offer Expired for {leaseOffer.Property?.Address}", + $"Lease offer ID {leaseOffer.Id} has expired after 30 days.", + NotificationConstants.Types.Info, + NotificationConstants.Categories.Lease, + leaseOffer.Id, + ApplicationConstants.EntityTypes.Lease + ); + return WorkflowResult.Ok("Lease offer expired"); }); diff --git a/2-Aquiis.Application/Services/Workflows/BaseWorkflowService.cs b/2-Aquiis.Application/Services/Workflows/BaseWorkflowService.cs index 9fb9ab1..a9ebcb2 100644 --- a/2-Aquiis.Application/Services/Workflows/BaseWorkflowService.cs +++ b/2-Aquiis.Application/Services/Workflows/BaseWorkflowService.cs @@ -204,5 +204,6 @@ protected async Task GetActiveOrganizationIdAsync() { return await _userContext.GetActiveOrganizationIdAsync() ?? Guid.Empty; } + } } diff --git a/2-Aquiis.Application/Services/Workflows/LeaseWorkflowService.cs b/2-Aquiis.Application/Services/Workflows/LeaseWorkflowService.cs index a7b0f10..349964f 100644 --- a/2-Aquiis.Application/Services/Workflows/LeaseWorkflowService.cs +++ b/2-Aquiis.Application/Services/Workflows/LeaseWorkflowService.cs @@ -26,14 +26,17 @@ public enum LeaseStatus public class LeaseWorkflowService : BaseWorkflowService, IWorkflowState { private readonly NoteService _noteService; + private readonly NotificationService _notificationService; public LeaseWorkflowService( ApplicationDbContext context, IUserContextService userContext, - NoteService noteService) + NoteService noteService, + NotificationService notificationService) : base(context, userContext) { _noteService = noteService; + _notificationService = notificationService; } #region State Machine Implementation @@ -146,6 +149,18 @@ await LogTransitionAsync( lease.Status, "ActivateLease"); + await _notificationService.SendNotificationAsync( + userId, + $"Lease Activated for {lease.Tenant?.FullName}", + $"Lease for {lease.Tenant?.FullName} property '{lease.Property?.Address}' has been activated effective {lease.SignedOn:MMM dd, yyyy}.", + NotificationConstants.Types.Info, + NotificationConstants.Categories.Lease, + leaseId, + ApplicationConstants.EntityTypes.Lease); + + // TODO: Send email to tenant with welcome info + // _notificatoinService.SendLeaseActivationEmailToTenant(lease); + return WorkflowResult.Ok("Lease activated successfully"); }); } @@ -206,6 +221,16 @@ await LogTransitionAsync( "RecordTerminationNotice", reason); + // Send notification about termination notice + await _notificationService.SendNotificationAsync( + userId, + $"Termination Notice Recorded for {lease.Tenant?.FullName}", + $"A termination notice has been recorded for lease of {lease.Tenant?.FullName} at property '{lease.Property?.Address}'. Expected move-out date: {expectedMoveOutDate:MMM dd, yyyy}.", + NotificationConstants.Types.Warning, + NotificationConstants.Categories.Lease, + leaseId, + ApplicationConstants.EntityTypes.Lease); + return WorkflowResult.Ok($"Termination notice recorded. Move-out date: {expectedMoveOutDate:MMM dd, yyyy}"); }); } @@ -250,6 +275,16 @@ await LogTransitionAsync( lease.Status, "ConvertToMonthToMonth"); + // send notification about conversion + await _notificationService.SendNotificationAsync( + userId, + $"Lease Converted to Month-to-Month for {lease.Tenant?.FullName}", + $"Lease for {lease.Tenant?.FullName} property '{lease.Property?.Address}' has been converted to month-to-month tenancy.", + NotificationConstants.Types.Info, + NotificationConstants.Categories.Lease, + leaseId, + ApplicationConstants.EntityTypes.Lease); + return WorkflowResult.Ok("Lease converted to month-to-month successfully"); }); } @@ -335,6 +370,16 @@ await LogTransitionAsync( var noteContent = $"Lease renewed. New term: {renewalLease.StartDate:MMM dd, yyyy} - {renewalLease.EndDate:MMM dd, yyyy}. Rent: ${renewalLease.MonthlyRent:N2}/month."; await _noteService.AddNoteAsync(ApplicationConstants.EntityTypes.Lease, renewalLease.Id, noteContent); + // Send notification about lease renewal + await _notificationService.SendNotificationAsync( + userId, + $"Lease Renewed for {renewalLease.Tenant?.FullName}", + $"Lease for {renewalLease.Tenant?.FullName} property '{renewalLease.Property?.Address}' has been renewed for the term {renewalLease.StartDate:MMM dd, yyyy} to {renewalLease.EndDate:MMM dd, yyyy} at ${renewalLease.MonthlyRent:N2}/month.", + NotificationConstants.Types.Info, + NotificationConstants.Categories.Lease, + renewalLease.Id, + ApplicationConstants.EntityTypes.Lease); + return WorkflowResult.Ok( renewalLease, "Lease renewed successfully"); @@ -422,6 +467,16 @@ await LogTransitionAsync( await _noteService.AddNoteAsync(ApplicationConstants.EntityTypes.Lease, leaseId, noteContent); + // Send notification about completed move-out + await _notificationService.SendNotificationAsync( + userId, + $"Lease Move-Out Completed for {lease.Tenant?.FullName}", + $"Lease for {lease.Tenant?.FullName} property '{lease.Property?.Address}' has been marked as moved out effective {actualMoveOutDate:MMM dd, yyyy}.", + NotificationConstants.Types.Info, + NotificationConstants.Categories.Lease, + leaseId, + ApplicationConstants.EntityTypes.Lease); + return WorkflowResult.Ok("Move-out completed successfully"); }); } @@ -501,6 +556,16 @@ await LogTransitionAsync( "EarlyTerminate", $"[{terminationType}] {reason}"); + // Send notification about early termination + await _notificationService.NotifyAllUsersAsync( + lease.OrganizationId, + $"Early Lease Termination for {lease.Tenant?.FullName}", + $"Lease for {lease.Tenant?.FullName} property '{lease.Property?.Address}' has been early terminated effective {effectiveDate:MMM dd, yyyy}. Reason: {terminationType} - {reason}", + NotificationConstants.Types.Warning, + NotificationConstants.Categories.Lease, + leaseId, + ApplicationConstants.EntityTypes.Lease); + return WorkflowResult.Ok($"Lease terminated ({terminationType})"); }); } @@ -524,7 +589,7 @@ public async Task> ExpireOverdueLeaseAsync(Guid organization return await ExecuteWorkflowAsync(async () => { var userId = await _userContext.GetUserIdAsync() ?? "System"; - + // Find active leases past their end date var expiredLeases = await _context.Leases .Include(l => l.Property) @@ -536,6 +601,7 @@ public async Task> ExpireOverdueLeaseAsync(Guid organization .ToListAsync(); var count = 0; + var addresses = string.Empty; foreach (var lease in expiredLeases) { var oldStatus = lease.Status; @@ -551,15 +617,67 @@ await LogTransitionAsync( "AutoExpire", "Lease end date passed without renewal"); + addresses += $"- {lease.Property?.Address} (Tenant: {lease.Tenant?.FullName})\n"; + count++; } + await _notificationService.NotifyAllUsersAsync( + organizationId, + "Expired Lease Notification", + $"{count} lease(s) have been automatically expired as of today.\n\n{addresses}", + NotificationConstants.Types.Info, + NotificationConstants.Categories.Lease, + null, + ApplicationConstants.EntityTypes.Lease); + return WorkflowResult.Ok(count, $"{count} lease(s) expired"); }); } #endregion + #region Notification Methods + + /// + /// Notifies all users in the organization of leases expiring in the specified number of days. + /// + public async Task NotifyLeasesExpiringInAsync(int daysUntilExpiration) + { + var targetDate = DateTime.Today.AddDays(daysUntilExpiration); + var orgId = await GetActiveOrganizationIdAsync(); + + var expiringLeases = await _context.Leases + .Where(l => l.EndDate.Date == targetDate + && l.Status == ApplicationConstants.LeaseStatuses.Active + && l.OrganizationId == orgId + && !l.IsDeleted) + .Include(l => l.Property) + .Include(l => l.Tenant) + .ToListAsync(); + + var urgency = daysUntilExpiration switch + { + <= 30 => NotificationConstants.Types.Warning, + _ => NotificationConstants.Types.Info + }; + + foreach (var lease in expiringLeases) + { + await _notificationService.NotifyAllUsersAsync( + orgId, + $"Lease Expiring in {daysUntilExpiration} Days - {lease.Property.Address}", + $"Lease expires {lease.EndDate:MMM dd, yyyy}. Please review renewal options.", + urgency, + NotificationConstants.Categories.Lease, + lease.Id, + ApplicationConstants.EntityTypes.Lease + ); + } + } + + #endregion + #region Security Deposit Workflow Methods /// diff --git a/4-Aquiis.SimpleStart/Features/Calendar/Calendar.razor b/4-Aquiis.SimpleStart/Features/Calendar/Calendar.razor index 064c6a1..154c546 100644 --- a/4-Aquiis.SimpleStart/Features/Calendar/Calendar.razor +++ b/4-Aquiis.SimpleStart/Features/Calendar/Calendar.razor @@ -1,4 +1,4 @@ -@page "/Calendar" +@page "/calendar" @using Aquiis.Core.Entities @using Aquiis.Application.Services @using Aquiis.SimpleStart.Shared.Services diff --git a/4-Aquiis.SimpleStart/Features/Calendar/CalendarListView.razor b/4-Aquiis.SimpleStart/Features/Calendar/CalendarListView.razor index ba124cc..a7d0038 100644 --- a/4-Aquiis.SimpleStart/Features/Calendar/CalendarListView.razor +++ b/4-Aquiis.SimpleStart/Features/Calendar/CalendarListView.razor @@ -1,4 +1,4 @@ -@page "/Calendar/ListView" +@page "/calendar/listview" @using Aquiis.Core.Entities @using Aquiis.Application.Services @using Aquiis.SimpleStart.Shared.Services diff --git a/4-Aquiis.SimpleStart/Features/PropertyManagement/ChecklistTemplates/Create.razor b/4-Aquiis.SimpleStart/Features/PropertyManagement/ChecklistTemplates/Create.razor new file mode 100644 index 0000000..e301760 --- /dev/null +++ b/4-Aquiis.SimpleStart/Features/PropertyManagement/ChecklistTemplates/Create.razor @@ -0,0 +1 @@ +@page "/propertymanagement/checkliststemplates/create" \ No newline at end of file diff --git a/4-Aquiis.SimpleStart/Features/PropertyManagement/ChecklistTemplates/Edit.razor b/4-Aquiis.SimpleStart/Features/PropertyManagement/ChecklistTemplates/Edit.razor index c874469..55754d9 100644 --- a/4-Aquiis.SimpleStart/Features/PropertyManagement/ChecklistTemplates/Edit.razor +++ b/4-Aquiis.SimpleStart/Features/PropertyManagement/ChecklistTemplates/Edit.razor @@ -1,4 +1,4 @@ -@page "/propertymanagement/checklists/templates/edit/{TemplateId:guid}" +@page "/propertymanagement/checkliststemplates/{TemplateId:guid}" @using Aquiis.Core.Entities @using Aquiis.Application.Services diff --git a/4-Aquiis.SimpleStart/Features/PropertyManagement/ChecklistTemplates/Index.razor b/4-Aquiis.SimpleStart/Features/PropertyManagement/ChecklistTemplates/Index.razor index 04f2d70..ed7790f 100644 --- a/4-Aquiis.SimpleStart/Features/PropertyManagement/ChecklistTemplates/Index.razor +++ b/4-Aquiis.SimpleStart/Features/PropertyManagement/ChecklistTemplates/Index.razor @@ -1,4 +1,4 @@ -@page "/propertymanagement/checklists/templates" +@page "/propertymanagement/checkliststemplates" @using Aquiis.Core.Entities @using Aquiis.Application.Services diff --git a/4-Aquiis.SimpleStart/Features/PropertyManagement/Checklists/Pages/Create.razor b/4-Aquiis.SimpleStart/Features/PropertyManagement/Checklists/Pages/Create.razor index 74354ec..d6616bb 100644 --- a/4-Aquiis.SimpleStart/Features/PropertyManagement/Checklists/Pages/Create.razor +++ b/4-Aquiis.SimpleStart/Features/PropertyManagement/Checklists/Pages/Create.razor @@ -1,4 +1,5 @@ @page "/propertymanagement/checklists/create" +@page "/propertymanagement/checklists/create/{TemplateId:guid}" @using Aquiis.SimpleStart.Features.PropertyManagement @using Aquiis.Core.Entities @@ -217,8 +218,8 @@ private string? errorMessage; private string? successMessage; - [SupplyParameterFromQuery(Name = "templateId")] - public Guid? TemplateIdFromQuery { get; set; } + [Parameter] + public Guid? TemplateId { get; set; } protected override async Task OnInitializedAsync() { @@ -241,10 +242,10 @@ // Load templates templates = await ChecklistService.GetChecklistTemplatesAsync(); - // Pre-select template if provided in query string - if (TemplateIdFromQuery.HasValue) + // Pre-select template if provided in route + if (TemplateId.HasValue && TemplateId.Value != Guid.Empty) { - selectedTemplateId = TemplateIdFromQuery.Value; + selectedTemplateId = TemplateId.Value; await OnTemplateChanged(); } } diff --git a/4-Aquiis.SimpleStart/Features/PropertyManagement/Checklists/Pages/Complete.razor b/4-Aquiis.SimpleStart/Features/PropertyManagement/Checklists/Pages/Edit.razor similarity index 99% rename from 4-Aquiis.SimpleStart/Features/PropertyManagement/Checklists/Pages/Complete.razor rename to 4-Aquiis.SimpleStart/Features/PropertyManagement/Checklists/Pages/Edit.razor index 4ec0c1a..0b91d0b 100644 --- a/4-Aquiis.SimpleStart/Features/PropertyManagement/Checklists/Pages/Complete.razor +++ b/4-Aquiis.SimpleStart/Features/PropertyManagement/Checklists/Pages/Edit.razor @@ -1,5 +1,4 @@ -@page "/propertymanagement/checklists/complete/{ChecklistId:guid}" -@page "/propertymanagement/checklists/complete/new" +@page "/propertymanagement/checklists/{ChecklistId:guid}/complete" @using Aquiis.Core.Entities @using Aquiis.SimpleStart.Features.PropertyManagement @@ -696,7 +695,7 @@ else // Redirect to view page after a short delay await Task.Delay(1500); - NavigationManager.NavigateTo($"/propertymanagement/checklists/view/{ChecklistId}"); + NavigationManager.NavigateTo($"/propertymanagement/checklists/{ChecklistId}"); } catch (Exception ex) { diff --git a/4-Aquiis.SimpleStart/Features/PropertyManagement/Checklists/Pages/Checklists.razor b/4-Aquiis.SimpleStart/Features/PropertyManagement/Checklists/Pages/Index.razor similarity index 99% rename from 4-Aquiis.SimpleStart/Features/PropertyManagement/Checklists/Pages/Checklists.razor rename to 4-Aquiis.SimpleStart/Features/PropertyManagement/Checklists/Pages/Index.razor index 71d846f..d55859c 100644 --- a/4-Aquiis.SimpleStart/Features/PropertyManagement/Checklists/Pages/Checklists.razor +++ b/4-Aquiis.SimpleStart/Features/PropertyManagement/Checklists/Pages/Index.razor @@ -161,7 +161,7 @@ private void StartChecklist(Guid templateId) { // Navigate to complete page with template ID - checklist will be created on save - NavigationManager.NavigateTo($"/propertymanagement/checklists/complete/new?templateId={templateId}"); + NavigationManager.NavigateTo($"/propertymanagement/checklists/create/{templateId}"); } private void NavigateToMyChecklists() diff --git a/4-Aquiis.SimpleStart/Features/PropertyManagement/Checklists/Pages/View.razor b/4-Aquiis.SimpleStart/Features/PropertyManagement/Checklists/Pages/View.razor index 3dddfdc..d190434 100644 --- a/4-Aquiis.SimpleStart/Features/PropertyManagement/Checklists/Pages/View.razor +++ b/4-Aquiis.SimpleStart/Features/PropertyManagement/Checklists/Pages/View.razor @@ -1,4 +1,4 @@ -@page "/propertymanagement/checklists/view/{ChecklistId:guid}" +@page "/propertymanagement/checklists/{ChecklistId:guid}" @using Aquiis.Core.Entities @using Aquiis.SimpleStart.Features.PropertyManagement diff --git a/4-Aquiis.SimpleStart/Features/PropertyManagement/Inspections/Pages/Create.razor b/4-Aquiis.SimpleStart/Features/PropertyManagement/Inspections/Pages/Create.razor index bfec3fd..5663c53 100644 --- a/4-Aquiis.SimpleStart/Features/PropertyManagement/Inspections/Pages/Create.razor +++ b/4-Aquiis.SimpleStart/Features/PropertyManagement/Inspections/Pages/Create.razor @@ -395,7 +395,7 @@ else // Navigate to view inspection page after short delay await Task.Delay(500); - NavigationManager.NavigateTo($"/propertymanagement/inspections/view/{inspection.Id}"); + NavigationManager.NavigateTo($"/propertymanagement/inspections/{inspection.Id}"); } catch (Exception ex) { diff --git a/4-Aquiis.SimpleStart/Features/PropertyManagement/Inspections/Pages/Edit.razor b/4-Aquiis.SimpleStart/Features/PropertyManagement/Inspections/Pages/Edit.razor new file mode 100644 index 0000000..0cf17db --- /dev/null +++ b/4-Aquiis.SimpleStart/Features/PropertyManagement/Inspections/Pages/Edit.razor @@ -0,0 +1 @@ +@page "/propertymanagement/inspections/{InspectionId:guid}/" \ No newline at end of file diff --git a/4-Aquiis.SimpleStart/Features/PropertyManagement/Inspections/Pages/Schedule.razor b/4-Aquiis.SimpleStart/Features/PropertyManagement/Inspections/Pages/Index.razor similarity index 99% rename from 4-Aquiis.SimpleStart/Features/PropertyManagement/Inspections/Pages/Schedule.razor rename to 4-Aquiis.SimpleStart/Features/PropertyManagement/Inspections/Pages/Index.razor index 6b69da9..5394d14 100644 --- a/4-Aquiis.SimpleStart/Features/PropertyManagement/Inspections/Pages/Schedule.razor +++ b/4-Aquiis.SimpleStart/Features/PropertyManagement/Inspections/Pages/Index.razor @@ -1,4 +1,4 @@ -@page "/propertymanagement/inspections/schedule" +@page "/propertymanagement/inspections" @using Aquiis.SimpleStart.Features.PropertyManagement @using Aquiis.Core.Entities diff --git a/4-Aquiis.SimpleStart/Features/PropertyManagement/Inspections/Pages/View.razor b/4-Aquiis.SimpleStart/Features/PropertyManagement/Inspections/Pages/View.razor index c165f71..8071812 100644 --- a/4-Aquiis.SimpleStart/Features/PropertyManagement/Inspections/Pages/View.razor +++ b/4-Aquiis.SimpleStart/Features/PropertyManagement/Inspections/Pages/View.razor @@ -1,4 +1,4 @@ -@page "/propertymanagement/inspections/view/{InspectionId:guid}" +@page "/propertymanagement/inspections/{InspectionId:guid}" @using Aquiis.Core.Entities @using Aquiis.SimpleStart.Features.PropertyManagement diff --git a/4-Aquiis.SimpleStart/Features/PropertyManagement/MaintenanceRequests/Pages/Edit.razor b/4-Aquiis.SimpleStart/Features/PropertyManagement/MaintenanceRequests/Pages/Edit.razor index 17f15b2..81a664a 100644 --- a/4-Aquiis.SimpleStart/Features/PropertyManagement/MaintenanceRequests/Pages/Edit.razor +++ b/4-Aquiis.SimpleStart/Features/PropertyManagement/MaintenanceRequests/Pages/Edit.razor @@ -1,4 +1,4 @@ -@page "/propertymanagement/maintenance/edit/{Id:guid}" +@page "/propertymanagement/maintenance/{Id:guid}/edit" @inject MaintenanceService MaintenanceService @inject PropertyService PropertyService @inject LeaseService LeaseService diff --git a/4-Aquiis.SimpleStart/Features/PropertyManagement/MaintenanceRequests/Pages/View.razor b/4-Aquiis.SimpleStart/Features/PropertyManagement/MaintenanceRequests/Pages/View.razor index a6a78a5..b6aa0f3 100644 --- a/4-Aquiis.SimpleStart/Features/PropertyManagement/MaintenanceRequests/Pages/View.razor +++ b/4-Aquiis.SimpleStart/Features/PropertyManagement/MaintenanceRequests/Pages/View.razor @@ -1,4 +1,4 @@ -@page "/propertymanagement/maintenance/view/{Id:guid}" +@page "/propertymanagement/maintenance/{Id:guid}" @using Aquiis.Application.Services @using Aquiis.SimpleStart.Shared.Services diff --git a/4-Aquiis.SimpleStart/Features/PropertyManagement/Payments/Pages/Edit.razor b/4-Aquiis.SimpleStart/Features/PropertyManagement/Payments/Pages/Edit.razor index 4def041..10e6073 100644 --- a/4-Aquiis.SimpleStart/Features/PropertyManagement/Payments/Pages/Edit.razor +++ b/4-Aquiis.SimpleStart/Features/PropertyManagement/Payments/Pages/Edit.razor @@ -1,4 +1,4 @@ -@page "/propertymanagement/payments/edit/{PaymentId:guid}" +@page "/propertymanagement/payments/{PaymentId:guid}/edit" @using Aquiis.SimpleStart.Features.PropertyManagement @using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Components.Web diff --git a/4-Aquiis.SimpleStart/Features/PropertyManagement/Payments/Pages/View.razor b/4-Aquiis.SimpleStart/Features/PropertyManagement/Payments/Pages/View.razor index e04acdc..cbe6ad0 100644 --- a/4-Aquiis.SimpleStart/Features/PropertyManagement/Payments/Pages/View.razor +++ b/4-Aquiis.SimpleStart/Features/PropertyManagement/Payments/Pages/View.razor @@ -1,4 +1,4 @@ -@page "/propertymanagement/payments/view/{PaymentId:guid}" +@page "/propertymanagement/payments/{PaymentId:guid}" @using Aquiis.SimpleStart.Features.PropertyManagement @using Aquiis.Core.Entities diff --git a/4-Aquiis.SimpleStart/Features/PropertyManagement/Properties/Pages/Edit.razor b/4-Aquiis.SimpleStart/Features/PropertyManagement/Properties/Pages/Edit.razor index 13454ba..0556bda 100644 --- a/4-Aquiis.SimpleStart/Features/PropertyManagement/Properties/Pages/Edit.razor +++ b/4-Aquiis.SimpleStart/Features/PropertyManagement/Properties/Pages/Edit.razor @@ -1,4 +1,4 @@ -@page "/propertymanagement/properties/edit/{PropertyId:guid}" +@page "/propertymanagement/properties/{PropertyId:guid}/edit" @using System.ComponentModel.DataAnnotations @using Aquiis.Core.Entities diff --git a/4-Aquiis.SimpleStart/Features/PropertyManagement/Properties/Pages/Index.razor b/4-Aquiis.SimpleStart/Features/PropertyManagement/Properties/Pages/Index.razor index e75aac6..d2ab887 100644 --- a/4-Aquiis.SimpleStart/Features/PropertyManagement/Properties/Pages/Index.razor +++ b/4-Aquiis.SimpleStart/Features/PropertyManagement/Properties/Pages/Index.razor @@ -31,8 +31,6 @@ - - @if (properties == null) {
diff --git a/4-Aquiis.SimpleStart/Features/PropertyManagement/Properties/Pages/View.razor b/4-Aquiis.SimpleStart/Features/PropertyManagement/Properties/Pages/View.razor index 4fed820..8f30a40 100644 --- a/4-Aquiis.SimpleStart/Features/PropertyManagement/Properties/Pages/View.razor +++ b/4-Aquiis.SimpleStart/Features/PropertyManagement/Properties/Pages/View.razor @@ -1,4 +1,4 @@ -@page "/propertymanagement/properties/view/{PropertyId:guid}" +@page "/propertymanagement/properties/{PropertyId:guid}" @using Aquiis.SimpleStart.Features.PropertyManagement @using Aquiis.Core.Constants @using Aquiis.Core.Entities diff --git a/4-Aquiis.SimpleStart/Features/PropertyManagement/Tours/Index.razor b/4-Aquiis.SimpleStart/Features/PropertyManagement/Tours/Index.razor index 1205059..5c1e465 100644 --- a/4-Aquiis.SimpleStart/Features/PropertyManagement/Tours/Index.razor +++ b/4-Aquiis.SimpleStart/Features/PropertyManagement/Tours/Index.razor @@ -316,7 +316,7 @@ // Navigate to the property tour checklist to complete it if (tour.ChecklistId.HasValue) { - Navigation.NavigateTo($"/PropertyManagement/Checklists/View/{tour.ChecklistId.Value}"); + Navigation.NavigateTo($"/PropertyManagement/Checklists/{tour.ChecklistId.Value}/complete"); } else { @@ -392,6 +392,6 @@ private void ViewTourChecklist(Guid checklistId) { - Navigation.NavigateTo($"/PropertyManagement/Checklists/View/{checklistId}"); + Navigation.NavigateTo($"/PropertyManagement/Checklists/{checklistId}"); } } diff --git a/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/ConfirmEmail.razor b/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/ConfirmEmail.razor index e8a70ed..34da62e 100644 --- a/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/ConfirmEmail.razor +++ b/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/ConfirmEmail.razor @@ -26,6 +26,9 @@ protected override async Task OnInitializedAsync() { + + await base.OnInitializedAsync(); + if (UserId is null || Code is null) { RedirectManager.RedirectTo(""); diff --git a/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/ConfirmEmailChange.razor b/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/ConfirmEmailChange.razor index 0edf8ae..96413e8 100644 --- a/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/ConfirmEmailChange.razor +++ b/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/ConfirmEmailChange.razor @@ -31,6 +31,8 @@ protected override async Task OnInitializedAsync() { + await base.OnInitializedAsync(); + if (UserId is null || Email is null || Code is null) { RedirectManager.RedirectToWithStatus( diff --git a/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/ExternalLogin.razor b/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/ExternalLogin.razor index 1fd966c..8e1ae9e 100644 --- a/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/ExternalLogin.razor +++ b/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/ExternalLogin.razor @@ -68,7 +68,10 @@ protected override async Task OnInitializedAsync() { + await base.OnInitializedAsync(); + Input = Input ?? new InputModel(); + if (RemoteError is not null) { RedirectManager.RedirectToWithStatus("Account/Login", $"Error from external provider: {RemoteError}", HttpContext); diff --git a/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/ForgotPassword.razor b/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/ForgotPassword.razor index caf13c7..e6709ec 100644 --- a/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/ForgotPassword.razor +++ b/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/ForgotPassword.razor @@ -38,6 +38,8 @@ protected override async Task OnInitializedAsync() { + await base.OnInitializedAsync(); + Input = Input ?? new InputModel(); await Task.CompletedTask; } diff --git a/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/Login.razor b/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/Login.razor index fe3f02b..160895f 100644 --- a/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/Login.razor +++ b/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/Login.razor @@ -77,6 +77,8 @@ protected override async Task OnInitializedAsync() { + await base.OnInitializedAsync(); + Input = Input ?? new InputModel(); if (HttpMethods.IsGet(HttpContext.Request.Method)) diff --git a/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/LoginWith2fa.razor b/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/LoginWith2fa.razor index 2ad3ae2..a9c2f6b 100644 --- a/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/LoginWith2fa.razor +++ b/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/LoginWith2fa.razor @@ -58,6 +58,8 @@ protected override async Task OnInitializedAsync() { + await base.OnInitializedAsync(); + Input = Input ?? new InputModel(); // Ensure the user has gone through the username & password screen first diff --git a/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/LoginWithRecoveryCode.razor b/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/LoginWithRecoveryCode.razor index b6ae816..2fff562 100644 --- a/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/LoginWithRecoveryCode.razor +++ b/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/LoginWithRecoveryCode.razor @@ -44,9 +44,10 @@ protected override async Task OnInitializedAsync() { - Input = Input ?? new InputModel(); await base.OnInitializedAsync(); + Input = Input ?? new InputModel(); + // Ensure the user has gone through the username & password screen first user = await SignInManager.GetTwoFactorAuthenticationUserAsync() ?? throw new InvalidOperationException("Unable to load two-factor authentication user."); diff --git a/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/Manage/ChangePassword.razor b/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/Manage/ChangePassword.razor index 082cd71..36a6ad6 100644 --- a/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/Manage/ChangePassword.razor +++ b/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/Manage/ChangePassword.razor @@ -51,12 +51,16 @@ protected override async Task OnInitializedAsync() { + await base.OnInitializedAsync(); + + Input = Input ?? new InputModel(); user = await UserAccessor.GetRequiredUserAsync(HttpContext); hasPassword = await UserManager.HasPasswordAsync(user); if (!hasPassword) { RedirectManager.RedirectTo("Account/Manage/SetPassword"); } + } private async Task OnValidSubmitAsync() diff --git a/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/Manage/DeletePersonalData.razor b/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/Manage/DeletePersonalData.razor index 0970813..1ee6dea 100644 --- a/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/Manage/DeletePersonalData.razor +++ b/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/Manage/DeletePersonalData.razor @@ -50,8 +50,12 @@ protected override async Task OnInitializedAsync() { + await base.OnInitializedAsync(); + + Input = Input ?? new InputModel(); user = await UserAccessor.GetRequiredUserAsync(HttpContext); requirePassword = await UserManager.HasPasswordAsync(user); + } private async Task OnValidSubmitAsync() diff --git a/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/Manage/Disable2fa.razor b/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/Manage/Disable2fa.razor index ad461c8..a9e6da7 100644 --- a/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/Manage/Disable2fa.razor +++ b/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/Manage/Disable2fa.razor @@ -35,12 +35,15 @@ protected override async Task OnInitializedAsync() { + await base.OnInitializedAsync(); + user = await UserAccessor.GetRequiredUserAsync(HttpContext); if (HttpMethods.IsGet(HttpContext.Request.Method) && !await UserManager.GetTwoFactorEnabledAsync(user)) { throw new InvalidOperationException("Cannot disable 2FA for user as it's not currently enabled."); } + } private async Task OnSubmitAsync() diff --git a/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/Manage/Email.razor b/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/Manage/Email.razor index 434373d..e7e8001 100644 --- a/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/Manage/Email.razor +++ b/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/Manage/Email.razor @@ -65,11 +65,15 @@ protected override async Task OnInitializedAsync() { + await base.OnInitializedAsync(); + + Input = Input ?? new InputModel(); user = await UserAccessor.GetRequiredUserAsync(HttpContext); email = await UserManager.GetEmailAsync(user); isEmailConfirmed = await UserManager.IsEmailConfirmedAsync(user); Input.NewEmail ??= email; + } private async Task OnValidSubmitAsync() diff --git a/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/Manage/EnableAuthenticator.razor b/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/Manage/EnableAuthenticator.razor index 0ffa261..421337f 100644 --- a/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/Manage/EnableAuthenticator.razor +++ b/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/Manage/EnableAuthenticator.razor @@ -82,9 +82,12 @@ else protected override async Task OnInitializedAsync() { + await base.OnInitializedAsync(); + user = await UserAccessor.GetRequiredUserAsync(HttpContext); await LoadSharedKeyAndQrCodeUriAsync(user); + } private async Task OnValidSubmitAsync() diff --git a/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/Manage/ExternalLogins.razor b/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/Manage/ExternalLogins.razor index 5f772dc..f6f3f0e 100644 --- a/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/Manage/ExternalLogins.razor +++ b/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/Manage/ExternalLogins.razor @@ -82,6 +82,8 @@ protected override async Task OnInitializedAsync() { + await base.OnInitializedAsync(); + user = await UserAccessor.GetRequiredUserAsync(HttpContext); currentLogins = await UserManager.GetLoginsAsync(user); otherLogins = (await SignInManager.GetExternalAuthenticationSchemesAsync()) @@ -100,6 +102,7 @@ { await OnGetLinkLoginCallbackAsync(); } + } private async Task OnSubmitAsync() diff --git a/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/Manage/Index.razor b/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/Manage/Index.razor index 5fc0b84..0ae4a5c 100644 --- a/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/Manage/Index.razor +++ b/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/Manage/Index.razor @@ -55,6 +55,9 @@ protected override async Task OnInitializedAsync() { + await base.OnInitializedAsync(); + + Input = Input ?? new InputModel(); user = await UserAccessor.GetRequiredUserAsync(HttpContext); // Reload user from database to ensure we have the latest values diff --git a/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/Manage/SetPassword.razor b/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/Manage/SetPassword.razor index a3ae060..d61efee 100644 --- a/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/Manage/SetPassword.razor +++ b/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/Manage/SetPassword.razor @@ -48,6 +48,9 @@ protected override async Task OnInitializedAsync() { + await base.OnInitializedAsync(); + + Input = Input ?? new InputModel(); user = await UserAccessor.GetRequiredUserAsync(HttpContext); var hasPassword = await UserManager.HasPasswordAsync(user); diff --git a/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/Manage/TwoFactorAuthentication.razor b/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/Manage/TwoFactorAuthentication.razor index 0084aea..1dcb9d9 100644 --- a/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/Manage/TwoFactorAuthentication.razor +++ b/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/Manage/TwoFactorAuthentication.razor @@ -80,6 +80,8 @@ else protected override async Task OnInitializedAsync() { + await base.OnInitializedAsync(); + var user = await UserAccessor.GetRequiredUserAsync(HttpContext); canTrack = HttpContext.Features.Get()?.CanTrack ?? true; hasAuthenticator = await UserManager.GetAuthenticatorKeyAsync(user) is not null; diff --git a/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/Register.razor b/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/Register.razor index 4af2615..a602d9b 100644 --- a/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/Register.razor +++ b/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/Register.razor @@ -105,6 +105,8 @@ else protected override async Task OnInitializedAsync() { + await base.OnInitializedAsync(); + // Check if this is the first user (excluding system user) var users = await UserManager.Users .Where(u => u.Id != ApplicationConstants.SystemUser.Id) @@ -116,7 +118,6 @@ else _allowRegistration = _isFirstUser; // Only allow registration if this is the first user Input = Input ?? new InputModel(); - await base.OnInitializedAsync(); StateHasChanged(); } diff --git a/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/RegisterConfirmation.razor b/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/RegisterConfirmation.razor index 13430bc..d8f7061 100644 --- a/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/RegisterConfirmation.razor +++ b/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/RegisterConfirmation.razor @@ -42,6 +42,8 @@ else protected override async Task OnInitializedAsync() { + await base.OnInitializedAsync(); + if (Email is null) { RedirectManager.RedirectTo(""); diff --git a/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/ResendEmailConfirmation.razor b/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/ResendEmailConfirmation.razor index 92d3d4e..4e2b83b 100644 --- a/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/ResendEmailConfirmation.razor +++ b/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/ResendEmailConfirmation.razor @@ -38,6 +38,12 @@ [SupplyParameterFromForm] private InputModel Input { get; set; } = default!; + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + + Input = Input ?? new InputModel(); + } private async Task OnValidSubmitAsync() { Input = Input ?? new InputModel(); diff --git a/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/ResetPassword.razor b/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/ResetPassword.razor index 5734579..5dc8d72 100644 --- a/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/ResetPassword.razor +++ b/4-Aquiis.SimpleStart/Shared/Components/Account/Pages/ResetPassword.razor @@ -54,6 +54,8 @@ protected override void OnInitialized() { + base.OnInitialized(); + Input = Input ?? new InputModel(); if (Code is null) @@ -62,6 +64,7 @@ } Input.Code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(Code)); + base.OnInitialized(); } private async Task OnValidSubmitAsync() diff --git a/4-Aquiis.SimpleStart/Shared/Layout/NavMenu.razor b/4-Aquiis.SimpleStart/Shared/Layout/NavMenu.razor index 078d011..9795907 100644 --- a/4-Aquiis.SimpleStart/Shared/Layout/NavMenu.razor +++ b/4-Aquiis.SimpleStart/Shared/Layout/NavMenu.razor @@ -41,7 +41,7 @@
@@ -51,7 +51,7 @@ diff --git a/5-Aquiis.Professional/.gitattributes b/5-Aquiis.Professional/.gitattributes new file mode 100644 index 0000000..22412d8 --- /dev/null +++ b/5-Aquiis.Professional/.gitattributes @@ -0,0 +1,3 @@ +*.cs* linguist-language=C# +*.razor linguist-language=Razor +*.sql linguist-language=SQL-92 \ No newline at end of file diff --git a/5-Aquiis.Professional/.github/copilot-instructions.md b/5-Aquiis.Professional/.github/copilot-instructions.md new file mode 100644 index 0000000..95ca41a --- /dev/null +++ b/5-Aquiis.Professional/.github/copilot-instructions.md @@ -0,0 +1,629 @@ +# Aquiis Property Management System - AI Agent Instructions + +## Development Workflow + +### Feature Branch Strategy + +**CRITICAL: Always develop new features on feature branches, never directly on main** + +1. **Create Feature Branch**: Before starting any new phase or major feature: + ```bash + git checkout -b Phase-X-Feature-Name + ``` + - Use descriptive names: `Phase-6-Workflow-Services-and-Automation` + - Branch from `main` to ensure clean starting point + +2. **Development on Feature Branch**: + - All commits for the feature go to the feature branch + - Build and test frequently to ensure no breaking changes + - Keep commits focused and atomic + +3. **Merge to Main** (only when complete and error-free): + ```bash + # Ensure build succeeds with 0 errors + dotnet build Aquiis.sln + + # Switch to main and merge + git checkout main + git merge Phase-X-Feature-Name + + # Push to remote + git push origin main + ``` + +4. **Protection**: Main branch should always be production-ready + - Never commit directly to main during active development + - Only merge tested, working code + - If build fails, fix on feature branch before merging + +--- + +## Project Overview + +Aquiis is a multi-tenant property management system built with **ASP.NET Core 9.0 + Blazor Server**. It manages properties, tenants, leases, invoices, payments, documents, inspections, and maintenance requests with role-based access control. + +## Architecture Fundamentals + +### Multi-Tenant Design + +- **Critical**: Every entity has an `OrganizationId` for tenant isolation +- **UserContextService** (`Services/UserContextService.cs`) provides cached access to current user's `OrganizationId` +- All service methods MUST filter by `OrganizationId` - see `PropertyManagementService` for the pattern +- Never hard-code organization filtering - always use `await _userContext.GetOrganizationIdAsync()` + +### Service Layer Pattern + +- **PropertyManagementService** is the central data access service (not repository pattern) +- Handles authorization, multi-tenant filtering, and business logic in one place +- All CRUD operations go through this service - components never access `ApplicationDbContext` directly +- Service is injected into Blazor components: `@inject PropertyManagementService PropertyService` + +**CRITICAL: Tracking Fields Must Be Set at Service Layer** + +All tracking fields and organization context MUST be set at the **service layer**, never in UI components: + +- **Tracking Fields**: `CreatedBy`, `CreatedOn`, `LastModifiedBy`, `LastModifiedOn`, `OrganizationId` +- **Source of Truth**: Services inject `UserContextService` to get current user and active organization +- **Security**: UI cannot manipulate tracking fields or bypass organization isolation +- **Maintainability**: All tracking logic centralized in services (change once, apply everywhere) +- **Simplicity**: UI components don't need to inject `UserContextService` or pass these values + +**Service Method Pattern (CORRECT):** + +```csharp +// Service injects UserContextService +private readonly UserContextService _userContext; + +// Create method - tracking fields set internally +public async Task CreatePropertyAsync(Property property) +{ + var userId = await _userContext.GetUserIdAsync(); + var activeOrgId = await _userContext.GetActiveOrganizationIdAsync(); + + // Service sets tracking fields - UI never touches these + property.CreatedBy = userId; + property.CreatedOn = DateTime.UtcNow; + property.OrganizationId = activeOrgId; + + _dbContext.Properties.Add(property); + await _dbContext.SaveChangesAsync(); + return property; +} + +// Update method - LastModified tracking + org security check +public async Task UpdatePropertyAsync(Property property) +{ + var userId = await _userContext.GetUserIdAsync(); + var activeOrgId = await _userContext.GetActiveOrganizationIdAsync(); + + // Verify property belongs to active organization (security) + var existing = await _dbContext.Properties + .FirstOrDefaultAsync(p => p.Id == property.Id && p.OrganizationId == activeOrgId); + + if (existing == null) return false; + + // Service sets tracking fields + property.LastModifiedBy = userId; + property.LastModifiedOn = DateTime.UtcNow; + property.OrganizationId = activeOrgId; // Prevent org hijacking + + _dbContext.Entry(existing).CurrentValues.SetValues(property); + await _dbContext.SaveChangesAsync(); + return true; +} + +// Query method - automatic active organization filtering +public async Task> GetPropertiesAsync() +{ + var activeOrgId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _dbContext.Properties + .Where(p => p.OrganizationId == activeOrgId && !p.IsDeleted) + .ToListAsync(); +} +``` + +**UI Pattern (CORRECT):** + +```csharp +// UI only passes entity - NO userId, NO organizationId +private async Task CreateProperty() +{ + // Simple, clean, secure + var created = await PropertyService.CreatePropertyAsync(newProperty); + Navigation.NavigateTo("/propertymanagement/properties"); +} +``` + +**โŒ ANTI-PATTERN (DO NOT DO THIS):** + +```csharp +// BAD: UI passes tracking values (insecure, boilerplate, wrong layer) +public async Task CreatePropertyAsync(Property property, string userId, string organizationId) +{ + property.CreatedBy = userId; + property.OrganizationId = organizationId; + // ... +} + +// BAD: UI must inject UserContextService (wrong responsibility) +@inject UserContextService UserContext + +private async Task CreateProperty() +{ + var userId = await UserContext.GetUserIdAsync(); + var orgId = await UserContext.GetActiveOrganizationIdAsync(); + await PropertyService.CreatePropertyAsync(newProperty, userId, orgId); // WRONG +} +``` + +**Key Principles:** + +1. Services own tracking field logic - UI never sets these values +2. All queries automatically filter by active organization (via UserContextService) +3. Update operations verify entity belongs to active org (security) +4. UI components stay simple - just pass entities, no context plumbing +5. Future refactoring happens in services only (change once, not in 40+ UI files) + +### Soft Delete Pattern + +- Entities inherit from `BaseModel` which provides audit fields (`CreatedOn`, `CreatedBy`, `LastModifiedOn`, `LastModifiedBy`, `IsDeleted`) +- **Never hard delete** - always set `IsDeleted = true` when deleting +- Controlled by `ApplicationSettings.SoftDeleteEnabled` configuration +- All queries must filter `.Where(x => !x.IsDeleted)` - this is non-negotiable + +### Authentication & Authorization + +- ASP.NET Core Identity with custom `ApplicationUser` (adds `OrganizationId`, `FirstName`, `LastName`) +- Three primary roles: `Administrator`, `PropertyManager`, `Tenant` (defined in `ApplicationConstants`) +- Use `@attribute [Authorize(Roles = "Administrator,PropertyManager")]` on Blazor pages +- User context pattern: Inject `UserContextService` instead of repeatedly querying `AuthenticationStateProvider` + +## Property & Tenant Lifecycle Workflows + +### Property Status Management + +Properties follow a status-driven lifecycle (string values from `ApplicationConstants.PropertyStatuses`): + +- **Available** - Ready to market and show to prospects +- **ApplicationPending** - One or more applications submitted and under review +- **LeasePending** - Application approved, lease offered, awaiting tenant signature +- **Occupied** - Active lease in place +- **UnderRenovation** - Not marketable, undergoing repairs/upgrades +- **OffMarket** - Temporarily unavailable + +**Important:** `Property.Status` is a `string` field (max 50 chars), NOT an enum. Always use `ApplicationConstants.PropertyStatuses.*` constants. + +**Status transitions are automatic** based on application/lease workflow events. + +### Prospect-to-Tenant Journey + +1. **Lead/Inquiry** โ†’ ProspectiveTenant created with Status: `Inquiry` +2. **Tour Scheduled** โ†’ Tour record created, Status: `TourScheduled` +3. **Tour Completed** โ†’ Status: `Toured`, interest level captured +4. **Application Submitted** โ†’ RentalApplication created, **Property.Status โ†’ ApplicationPending**, Status: `ApplicationSubmitted` + - **Page:** `/propertymanagement/prospects/{id}/submit-application` + - Application fee collected (per-application, non-refundable) + - Application valid for 30 days, auto-expires if not processed + - Property status automatically changes from Available to ApplicationPending + - All required fields: current address, landlord info, employment, references + - Income-to-rent ratio calculated and displayed +5. **Screening** โ†’ ApplicationScreening created (background + credit checks), Status: `UnderReview` + - **Page:** `/propertymanagement/applications/{id}/review` (Initiate Screening button) + - Background check requested with status tracking + - Credit check requested with credit score capture + - Overall screening result: Pending, Passed, Failed, ConditionalPass +6. **Application Approved** โ†’ Lease created with Status: `Offered`, **Property.Status โ†’ LeasePending**, Status: `ApplicationApproved` + - **Page:** `/propertymanagement/applications/{id}/review` (Approve button after screening passes) + - All other pending applications for this property auto-denied + - Lease offer expires in 30 days if not signed + - `Lease.OfferedOn` and `Lease.ExpiresOn` (30 days) are set +7. **Lease Signed** โ†’ **Tenant created from ProspectiveTenant**, SecurityDeposit collected, **Property.Status โ†’ Occupied**, Status: `ConvertedToTenant` + - **Page:** `/propertymanagement/leases/{id}/accept` + - `TenantConversionService` handles conversion with validation + - `Tenant.ProspectiveTenantId` links back to prospect for audit trail + - `Lease.SignedOn` timestamp recorded for compliance + - SecurityDeposit must be paid in full upfront + - Move-in inspection auto-scheduled +8. **Lease Declined** โ†’ **Property.Status โ†’ Available or ApplicationPending** (if other apps exist), Status: `LeaseDeclined` + - `Lease.DeclinedOn` timestamp recorded +9. **Application Denied** โ†’ Status: `ApplicationDenied`, Property returns to Available if no other pending apps + +**Key Services:** + +- `TenantConversionService` - Handles ProspectiveTenant โ†’ Tenant conversion + - `ConvertProspectToTenantAsync(prospectId, userId)` - Creates tenant with audit trail + - Returns existing Tenant if already converted (idempotent operation) + - `IsProspectAlreadyConvertedAsync()` - Prevents duplicate conversions + - `GetProspectHistoryForTenantAsync()` - Retrieves full prospect history for compliance + +**Key Pages:** + +- `GenerateLeaseOffer.razor` - `/propertymanagement/applications/{id}/generate-lease-offer` + + - Generates lease offer from approved application + - Sets `Lease.OfferedOn` and `Lease.ExpiresOn` (30 days) + - Updates Property.Status to LeasePending + - Auto-denies all competing applications for the property + - Accessible to PropertyManager and Administrator roles only + +- `AcceptLease.razor` - `/propertymanagement/leases/{id}/accept` + - Accepts lease offer with full signature audit trail + - Captures: timestamp, IP address, user ID, payment method + - Calls TenantConversionService to create Tenant record + - Sets `Lease.SignedOn`, updates status to Active + - Updates Property.Status to Occupied + - Prevents acceptance of expired offers (checks `Lease.ExpiresOn`) + - Includes decline workflow (sets `Lease.DeclinedOn`) + +**Lease Lifecycle Fields:** + +- `OfferedOn` (DateTime?) - When lease offer was generated +- `SignedOn` (DateTime?) - When tenant accepted/signed the lease +- `DeclinedOn` (DateTime?) - When tenant declined the offer +- `ExpiresOn` (DateTime?) - Offer expiration date (30 days from OfferedOn) + +**Status Constants:** + +- ProspectiveStatuses: `LeaseOffered`, `LeaseDeclined`, `ConvertedToTenant` +- ApplicationStatuses: `LeaseOffered`, `LeaseAccepted`, `LeaseDeclined` +- LeaseStatuses: `Offered`, `Active`, `Declined`, `Terminated`, `Expired` + +### Multi-Lease Support + +- Tenants can have **multiple active leases simultaneously** +- Same tenant can lease multiple units in same or different buildings +- Each lease has independent security deposit, dividend tracking, and payment schedule + +### Security Deposit Investment Model + +**Investment Pool Approach:** + +- All security deposits pooled into investment account +- Annual earnings distributed as dividends +- Organization takes configurable percentage (default 20%), remainder distributed to tenants +- Dividend = (TenantShare / ActiveLeaseCount) per lease +- **Losses absorbed by organization** - no negative dividends + +**Dividend Distribution Rules:** + +- **Pro-rated** for tenants who moved in mid-year (e.g., 6 months = 50% dividend) +- Distributed at year-end even if tenant has moved out (sent to forwarding address) +- Tenant chooses: apply as lease credit OR receive as check +- Each active lease gets separate dividend (tenant with 2 leases gets 2 dividends) + +**Tracking:** + +- `SecurityDepositInvestmentPool` - annual pool performance +- `SecurityDepositDividend` - per-lease dividend with payment method choice +- Full audit trail of investment performance visible in tenant portal + +### E-Signature & Audit Trail + +- Lease offers require acceptance (checkbox "I Accept" for dev/demo) +- Full signature audit: IP address, timestamp, document version, user agent +- Lease offer expires after 30 days if not signed +- Unsigned leases roll to month-to-month at higher rate + +## Code Patterns & Conventions + +### Enums & Constants Location + +- **Status and type values** stored as string constants in `ApplicationConstants.cs` static classes +- Example: `ApplicationConstants.PropertyStatuses.Available`, `ApplicationConstants.LeaseStatuses.Active` +- **Enums** (PropertyStatus, ProspectStatus, etc.) defined in `ApplicationSettings.cs` for type safety but NOT used in database +- Database fields use `string` type with validation against ApplicationConstants values +- Never hard-code status/type values - always reference ApplicationConstants classes + +### Blazor Component Structure + +```csharp +@page "/propertymanagement/entities/create" +@using Aquiis.SimpleStart.Components.PropertyManagement.Entities +@attribute [Authorize(Roles = "Administrator,PropertyManager")] +@inject PropertyManagementService PropertyService +@inject UserContextService UserContext +@inject NavigationManager Navigation +@rendermode InteractiveServer + +// Component code follows... +``` + +### Creating Entities + +```csharp +// UI component creates entity with business data only +private async Task CreateEntity() +{ + // Service handles CreatedBy, CreatedOn, OrganizationId automatically + var created = await PropertyService.AddEntityAsync(entity); + Navigation.NavigateTo("/propertymanagement/entities"); +} +``` + +**Note:** Do NOT set tracking fields in UI. The service layer automatically sets: +- `CreatedBy` - from `UserContextService.GetUserIdAsync()` +- `CreatedOn` - `DateTime.UtcNow` +- `OrganizationId` - from `UserContextService.GetActiveOrganizationIdAsync()` + +### Service Method Pattern + +```csharp +// Service method automatically handles organization context and tracking +public async Task> GetEntitiesAsync() +{ + // Get active organization from UserContextService (injected in constructor) + var activeOrgId = await _userContext.GetActiveOrganizationIdAsync(); + + return await _dbContext.Entities + .Include(e => e.RelatedEntity) + .Where(e => !e.IsDeleted && e.OrganizationId == activeOrgId) + .ToListAsync(); +} + +// Create method sets tracking fields internally +public async Task AddEntityAsync(Entity entity) +{ + var userId = await _userContext.GetUserIdAsync(); + var activeOrgId = await _userContext.GetActiveOrganizationIdAsync(); + + // Service owns this logic - UI never sets these + entity.CreatedBy = userId; + entity.CreatedOn = DateTime.UtcNow; + entity.OrganizationId = activeOrgId; + + _dbContext.Entities.Add(entity); + await _dbContext.SaveChangesAsync(); + return entity; +} +``` + +**Important:** Never expose `organizationId` or `userId` as parameters in service methods. Services get these values from `UserContextService` automatically. + +### Constants Usage + +- All dropdown values come from `ApplicationConstants` (never hard-code) +- Examples: `ApplicationConstants.LeaseStatuses.Active`, `ApplicationConstants.PropertyTypes.Apartment` +- Status/type classes are nested: `ApplicationConstants.PaymentMethods.AllPaymentMethods` + +### Entity Relationships + +- Properties have many Leases, Documents, Inspections +- Leases belong to Property and Tenant +- Invoices belong to Lease (get Property/Tenant through navigation) +- Always use `.Include()` to eager-load related entities in services + +## Development Workflows + +### Database Changes + +1. **EF Core Migrations**: Primary approach for schema changes + - Migrations stored in `Data/Migrations/` + - Run `dotnet ef migrations add MigrationName --project Aquiis.SimpleStart` + - Apply with `dotnet ef database update --project Aquiis.SimpleStart` + - Generate SQL script: `dotnet ef migrations script --output schema.sql` +2. **SQL Scripts**: Reference scripts in `Data/Scripts/` (not executed, for documentation) +3. Update `ApplicationDbContext.cs` with DbSet and entity configuration +4. Connection string in `appsettings.json`: `"DefaultConnection": "DataSource=Infrastructure/Data/app.db;Cache=Shared"` +5. **Database**: SQLite (not SQL Server) - scripts will be SQLite syntax + +### Development Workflows + +**Running the Application:** + +- **Ctrl+Shift+B** to run `dotnet watch` (hot reload, default build task) +- **F5** in VS Code to debug (configured in `.vscode/launch.json`) +- Or: `dotnet run` in `Aquiis.SimpleStart/` directory +- Default URLs: Check terminal output for ports +- Default admin: `superadmin@example.local` / `SuperAdmin@123!` + +### Build Tasks (VS Code) + +- `build` - Debug build (Ctrl+Shift+B) +- `watch` - Hot reload development mode +- `build-release` - Production build +- `publish` - Create deployment package + +### Background Services + +- **ScheduledTaskService** runs daily/hourly automated tasks +- Registered as hosted service in `Program.cs` +- Add new scheduled tasks to `ScheduledTaskService.cs` with proper scoping + +## PDF Generation + +- Uses **QuestPDF 2025.7.4** with Community License (configured in Program.cs) +- PDF generators in `Components/PropertyManagement/Documents/` (e.g., `LeasePdfGenerator.cs`) +- Always save generated PDFs to `Documents` table with proper associations +- Pattern: Generate โ†’ Save to DB โ†’ Return Document object โ†’ Navigate to view + +## File Naming & Organization + +### Component Structure + +``` +Components/PropertyManagement/[Entity]/ + โ”œโ”€โ”€ [Entity].cs (Model - inherits BaseModel) + โ”œโ”€โ”€ Pages/ + โ”‚ โ”œโ”€โ”€ [Entities].razor (List view) + โ”‚ โ”œโ”€โ”€ Create[Entity].razor + โ”‚ โ”œโ”€โ”€ View[Entity].razor + โ”‚ โ””โ”€โ”€ Edit[Entity].razor + โ””โ”€โ”€ [Entity]PdfGenerator.cs (if applicable) +``` + +### Route Patterns + +- List: `/propertymanagement/entities` +- Create: `/propertymanagement/entities/create` +- View: `/propertymanagement/entities/view/{id:int}` +- Edit: `/propertymanagement/entities/edit/{id:int}` + +## Common Pitfalls to Avoid + +1. **DO NOT** access `ApplicationDbContext` directly in components - always use `PropertyManagementService` +2. **DO NOT** forget `OrganizationId` filtering - security breach waiting to happen +3. **DO NOT** hard-code status values - use `ApplicationConstants` classes +4. **DO NOT** hard delete entities - always soft delete (check `SoftDeleteEnabled` setting) +5. **DO NOT** forget to set audit fields (`CreatedBy`, `CreatedOn`) when creating entities +6. **DO NOT** query without `.Include()` for navigation properties you'll need +7. **DO NOT** use `!` null-forgiving operator without null checks - validate properly + +## Toast Notifications + +- Use `ToastService` (singleton) for user feedback instead of JavaScript alerts +- Pattern: `await JSRuntime.InvokeVoidAsync("toastService.showSuccess", "Message")` +- Types: Success, Error, Warning, Info +- Auto-dismiss after 5 seconds (configurable) + +## Document Management + +- Binary storage in `Documents.FileData` (VARBINARY(MAX)) +- View in browser: Use Blob URLs via `wwwroot/js/fileDownload.js` +- Download: Base64 encode and trigger download +- 10MB upload limit configured in components + +## Financial Features + +- Late fees auto-applied by `ScheduledTaskService` (daily at 2 AM) +- Payment tracking updates invoice status automatically +- Financial reports use `FinancialReportService` with PDF export +- Decimal precision: 18,2 for all monetary values + +## Inspection Tracking + +- 26-item checklist organized in 5 categories (Exterior, Interior, Kitchen, Bathroom, Systems) +- Routine inspections update `Property.NextRoutineInspectionDueDate` +- Generate PDFs with `InspectionPdfGenerator` and save to Documents + +## Key Files to Reference + +- `Program.cs` - Service registration, Identity config, startup logic +- `ApplicationConstants.cs` - All dropdown values, roles, statuses +- `PropertyManagementService.cs` - Service layer patterns +- `UserContextService.cs` - Multi-tenant context access +- `BaseModel.cs` - Audit field structure +- `ApplicationDbContext.cs` - Entity relationships + +## When Adding New Features + +1. Create model inheriting `BaseModel` with `OrganizationId` property +2. Add DbSet to `ApplicationDbContext` with proper relationships +3. Create SQL migration script in `Data/Scripts/` +4. Add CRUD methods to `PropertyManagementService` with org filtering +5. Create Blazor components following naming/routing conventions +6. Add constants to `ApplicationConstants` if needed +7. Update navigation in `NavMenu.razor` if top-level feature + +## Code Style Notes + +- Use async/await consistently (no `.Result` or `.Wait()`) +- Prefer explicit typing over `var` for service/entity types +- Use string interpolation for logging: `$"Processing {entityId}"` +- Handle errors with try-catch and user-friendly messages +- Include XML comments on service methods describing purpose + +## Documentation & Roadmap Management + +### Documentation Structure + +The project maintains comprehensive documentation organized by implementation status and version: + +**Roadmap Folder** (`/Documentation/Roadmap/`): +- **Purpose:** Implementation planning and feature proposals +- **Status:** Active consideration - may be approved or rejected +- **Workflow:** One file at a time - focus on current implementation +- **File Naming:** Descriptive names (e.g., `00-PROPERTY-TENANT-LIFECYCLE-ROADMAP.md`) +- **Rejection:** Rejected proposals have rejection reason added at the top of the file + +**Version Folders** (`/Documentation/vX.X.X/`): +- **Purpose:** Completed implementation notes for each version release +- **Status:** Historical record of what was actually implemented +- **Content:** Feature additions, changes, and implementation details for that specific release +- **File Naming:** Match feature/module names (e.g., `multi-organization-management.md`) + +### Semantic Versioning & Database Management + +The project follows **Semantic Versioning (MAJOR.MINOR.PATCH)**: + +- **MAJOR** version (X.0.0): Breaking changes that trigger database schema updates +- **MINOR** version (0.X.0): Significant UI changes or new features (backward compatible) +- **PATCH** version (0.0.X): Bug fixes, minor updates, safe application updates + +**Current Development Status:** +- **Production version:** v0.1.1 (in production) +- **Development version:** v0.2.0 (current work in progress) +- **Next major milestone:** v1.0.0 (when entity refactoring stabilizes) + +**Database Version Management:** + +The database filename and schema version are tracked separately from the application patch version: + +**Configuration in `appsettings.json`:** +```json +{ + "ConnectionStrings": { + "DefaultConnection": "DataSource=Infrastructure/Data/app_v0.0.0.db;Cache=Shared" + }, + "DatabaseSettings": { + "DatabaseFileName": "app_v0.0.0.db", + "PreviousDatabaseFileName": "", + "SchemaVersion": "0.0.0" + } +} +``` + +**Versioning Rules:** + +1. **Database filename** follows pattern: `app_v{MAJOR}.{MINOR}.0.db` + - Tracks MAJOR and MINOR app versions only (ignores PATCH) + - Example: App v2.1.25 uses database `app_v2.1.0.db` + - Current: App v0.2.0 uses database `app_v0.0.0.db` (pre-v1.0.0) + +2. **Schema version** (`SchemaVersion` in settings): + - Matches database filename version + - Example: `app_v2.1.0.db` has `SchemaVersion: "2.1.0"` + - Current: `SchemaVersion: "0.0.0"` (active refactoring phase) + +3. **Version 1.0.0 milestone**: + - At v1.0.0, database management becomes more formal + - Database filename becomes: `app_v1.0.0.db` (from `app_v0.0.0.db`) + - `SchemaVersion` initializes to `"1.0.0"` + - Indicates entity models have stabilized + +4. **Migration triggers**: + - MAJOR version bump โ†’ Database schema migration required + - MINOR version bump โ†’ Database filename updates (new .db file) + - PATCH version bump โ†’ No database changes (application updates only) + +**Example Version Progression:** + +| App Version | Database File | Schema Version | Notes | +|-------------|---------------|----------------|-------| +| v0.1.1 | app_v0.0.0.db | 0.0.0 | Production (active refactoring) | +| v0.2.0 | app_v0.0.0.db | 0.0.0 | Development (same schema) | +| v1.0.0 | app_v1.0.0.db | 1.0.0 | Milestone (entities stabilized) | +| v1.0.5 | app_v1.0.0.db | 1.0.0 | Patches (no DB change) | +| v1.1.0 | app_v1.1.0.db | 1.1.0 | Minor (new DB file) | +| v1.1.8 | app_v1.1.0.db | 1.1.0 | Patches (same DB) | +| v2.0.0 | app_v2.0.0.db | 2.0.0 | Major (breaking changes, migration) | + +**Implementation Workflow:** + +1. When incrementing MAJOR or MINOR version: + - Update `DatabaseFileName` in `appsettings.json` to new version + - Update `SchemaVersion` to match + - Set `PreviousDatabaseFileName` to old database name (for migration reference) + - Create EF Core migration if schema changes required + +2. When incrementing PATCH version: + - No changes to database settings + - Application version increments only + +3. Document completed features in `/Documentation/v{MAJOR}.{MINOR}.{PATCH}/` + +**Pre-v1.0.0 Strategy:** +- Database remains at `app_v0.0.0.db` until v1.0.0 +- Allows rapid iteration and entity refactoring +- Schema migrations managed via EF Core Migrations folder +- At v1.0.0 release, formalize database versioning with `app_v1.0.0.db` diff --git a/5-Aquiis.Professional/Extensions/ElectronServiceExtensions.cs b/5-Aquiis.Professional/Extensions/ElectronServiceExtensions.cs index be24494..06ad2a5 100644 --- a/5-Aquiis.Professional/Extensions/ElectronServiceExtensions.cs +++ b/5-Aquiis.Professional/Extensions/ElectronServiceExtensions.cs @@ -5,7 +5,7 @@ using Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore; using Aquiis.Core.Interfaces; using Aquiis.Core.Interfaces.Services; -using Aquiis.Application; +using Aquiis.Application; // โœ… Application facade using Aquiis.Application.Services; using Aquiis.Professional.Data; using Aquiis.Professional.Entities; @@ -14,7 +14,7 @@ namespace Aquiis.Professional.Extensions; /// -/// Extension methods for configuring Electron-specific services for Professional. +/// Extension methods for configuring Electron-specific services for SimpleStart. /// public static class ElectronServiceExtensions { @@ -34,7 +34,7 @@ public static IServiceCollection AddElectronServices( // Get connection string using the path service var connectionString = GetElectronConnectionString(configuration).GetAwaiter().GetResult(); - // Register Application layer (includes Infrastructure internally) + // โœ… Register Application layer (includes Infrastructure internally) services.AddApplication(connectionString); // Register Identity database context (Professional-specific) diff --git a/5-Aquiis.Professional/Extensions/WebServiceExtensions.cs b/5-Aquiis.Professional/Extensions/WebServiceExtensions.cs index 3c070b4..0c0c302 100644 --- a/5-Aquiis.Professional/Extensions/WebServiceExtensions.cs +++ b/5-Aquiis.Professional/Extensions/WebServiceExtensions.cs @@ -5,7 +5,7 @@ using Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore; using Aquiis.Core.Interfaces; using Aquiis.Core.Interfaces.Services; -using Aquiis.Application; +using Aquiis.Application; // โœ… Application facade using Aquiis.Application.Services; using Aquiis.Professional.Data; using Aquiis.Professional.Entities; @@ -14,7 +14,7 @@ namespace Aquiis.Professional.Extensions; /// -/// Extension methods for configuring Web-specific services for Professional. +/// Extension methods for configuring Web-specific services for SimpleStart. /// public static class WebServiceExtensions { @@ -35,7 +35,7 @@ public static IServiceCollection AddWebServices( var connectionString = configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found."); - // Register Application layer (includes Infrastructure internally) + // โœ… Register Application layer (includes Infrastructure internally) services.AddApplication(connectionString); // Register Identity database context (Professional-specific) diff --git a/5-Aquiis.Professional/Features/Administration/Application/Pages/ManageDatabase.razor b/5-Aquiis.Professional/Features/Administration/Application/Pages/ManageDatabase.razor index 862dd19..c05759f 100644 --- a/5-Aquiis.Professional/Features/Administration/Application/Pages/ManageDatabase.razor +++ b/5-Aquiis.Professional/Features/Administration/Application/Pages/ManageDatabase.razor @@ -1,14 +1,14 @@ @page "/administration/application/database" @using Aquiis.Application.Services @using Aquiis.Professional.Shared.Services -@using Aquiis.Professional.Services @using Aquiis.Application.Services.PdfGenerators @using Aquiis.Core.Constants +@using Aquiis.Core.Interfaces @using Microsoft.AspNetCore.Authorization @using Microsoft.Extensions.Options @using ElectronNET.API @inject DatabaseBackupService BackupService -@inject ElectronPathService ElectronPathService +@inject IPathService PathService @inject NavigationManager Navigation @inject SchemaValidationService SchemaService @inject IOptions AppSettings @@ -386,7 +386,7 @@ await Task.Delay(1000); // Get database path - var dbPath = await ElectronPathService.GetDatabasePathAsync(); + var dbPath = await PathService.GetDatabasePathAsync(); if (File.Exists(dbPath)) { @@ -734,7 +734,7 @@ // Get database path and backup directory var dbPath = HybridSupport.IsElectronActive - ? await ElectronPathService.GetDatabasePathAsync() + ? await PathService.GetDatabasePathAsync() : Path.Combine(Directory.GetCurrentDirectory(), "Data/app.db"); var backupDir = Path.Combine(Path.GetDirectoryName(dbPath)!, "Backups"); Directory.CreateDirectory(backupDir); diff --git a/5-Aquiis.Professional/Features/Administration/Organizations/Pages/EditOrganization.razor b/5-Aquiis.Professional/Features/Administration/Organizations/Pages/EditOrganization.razor index 2d42d5e..90be5f9 100644 --- a/5-Aquiis.Professional/Features/Administration/Organizations/Pages/EditOrganization.razor +++ b/5-Aquiis.Professional/Features/Administration/Organizations/Pages/EditOrganization.razor @@ -1,4 +1,4 @@ -@page "/administration/organizations/{Id:guid}/edit" +@page "/administration/organizations/edit/{Id:guid}" @using Aquiis.Core.Entities @using Aquiis.Application.Services diff --git a/5-Aquiis.Professional/Features/Administration/Organizations/Pages/ViewOrganization.razor b/5-Aquiis.Professional/Features/Administration/Organizations/Pages/ViewOrganization.razor index f479787..fa47f94 100644 --- a/5-Aquiis.Professional/Features/Administration/Organizations/Pages/ViewOrganization.razor +++ b/5-Aquiis.Professional/Features/Administration/Organizations/Pages/ViewOrganization.razor @@ -1,4 +1,4 @@ -@page "/administration/organizations/{Id:guid}" +@page "/administration/organizations/view/{Id:guid}" @using Aquiis.Core.Entities @using Aquiis.Application.Services diff --git a/5-Aquiis.Professional/Features/Administration/Settings/Pages/EmailSettings.razor b/5-Aquiis.Professional/Features/Administration/Settings/Pages/EmailSettings.razor index f389e74..3b650a3 100644 --- a/5-Aquiis.Professional/Features/Administration/Settings/Pages/EmailSettings.razor +++ b/5-Aquiis.Professional/Features/Administration/Settings/Pages/EmailSettings.razor @@ -1,5 +1,6 @@ @page "/administration/settings/email" @using Aquiis.Application.Services +@using Aquiis.Professional.Shared.Components.Account.Pages.Manage @using SocketIOClient.Messages @using System.ComponentModel.DataAnnotations @inject EmailSettingsService EmailSettingsService @@ -333,11 +334,11 @@ private async Task LoadSettings() { settings = await EmailSettingsService.GetOrCreateSettingsAsync(); - // TODO Phase 2.5: Uncomment when GetSendGridStatsAsync is implemented - if (settings.IsEmailEnabled) - { + // TODO Phase 2.5: Uncomment when GetEmailStatsAsync is implemented + if (settings.IsEmailEnabled) + { stats = await EmailService.GetEmailStatsAsync(); - } + } } private async Task SaveConfiguration() diff --git a/5-Aquiis.Professional/Features/Administration/Settings/Pages/SMSSettings.razor b/5-Aquiis.Professional/Features/Administration/Settings/Pages/SMSSettings.razor index a4d3a9f..bc1ada1 100644 --- a/5-Aquiis.Professional/Features/Administration/Settings/Pages/SMSSettings.razor +++ b/5-Aquiis.Professional/Features/Administration/Settings/Pages/SMSSettings.razor @@ -1,9 +1,7 @@ @page "/administration/settings/sms" - @using Aquiis.Application.Services @using SocketIOClient.Messages @using System.ComponentModel.DataAnnotations -@using Aquiis.Core.Entities @inject SMSSettingsService SMSSettingsService @inject SMSService SMSService @@ -301,10 +299,10 @@ { settings = await SMSSettingsService.GetOrCreateSettingsAsync(); // TODO Phase 2.5: Uncomment when GetSMSStatsAsync is implemented - if (settings.IsSMSEnabled) - { - stats = await SMSService.GetSMSStatsAsync(); - } + if (settings.IsSMSEnabled) + { + stats = await SMSService.GetSMSStatsAsync(); + } } private async Task SaveConfiguration() diff --git a/5-Aquiis.Professional/Features/Administration/Users/View.razor b/5-Aquiis.Professional/Features/Administration/Users/View.razor index 6f2f765..accb5a7 100644 --- a/5-Aquiis.Professional/Features/Administration/Users/View.razor +++ b/5-Aquiis.Professional/Features/Administration/Users/View.razor @@ -303,7 +303,7 @@ else protected override async Task OnInitializedAsync() { - //Input = Input ?? new InputModel(); + Input ??= new(); await LoadUserData(); isLoading = false; } diff --git a/5-Aquiis.Professional/Features/Calendar/Calendar.razor b/5-Aquiis.Professional/Features/Calendar/Calendar.razor index 49bc13b..4222083 100644 --- a/5-Aquiis.Professional/Features/Calendar/Calendar.razor +++ b/5-Aquiis.Professional/Features/Calendar/Calendar.razor @@ -1,4 +1,4 @@ -@page "/Calendar" +@page "/calendar" @using Aquiis.Core.Entities @using Aquiis.Application.Services @using Aquiis.Professional.Shared.Services diff --git a/5-Aquiis.Professional/Features/Calendar/CalendarListView.razor b/5-Aquiis.Professional/Features/Calendar/CalendarListView.razor index 7eda510..5373ea2 100644 --- a/5-Aquiis.Professional/Features/Calendar/CalendarListView.razor +++ b/5-Aquiis.Professional/Features/Calendar/CalendarListView.razor @@ -1,4 +1,4 @@ -@page "/Calendar/ListView" +@page "/calendar/listview" @using Aquiis.Core.Entities @using Aquiis.Application.Services @using Aquiis.Professional.Shared.Services diff --git a/5-Aquiis.Professional/Features/PropertyManagement/Applications/Pages/Create.razor b/5-Aquiis.Professional/Features/PropertyManagement/Applications/Pages/Edit.razor similarity index 100% rename from 5-Aquiis.Professional/Features/PropertyManagement/Applications/Pages/Create.razor rename to 5-Aquiis.Professional/Features/PropertyManagement/Applications/Pages/Edit.razor diff --git a/5-Aquiis.Professional/Features/PropertyManagement/Applications/Pages/View.razor b/5-Aquiis.Professional/Features/PropertyManagement/Applications/Pages/View.razor index 32dbf6d..b633e7a 100644 --- a/5-Aquiis.Professional/Features/PropertyManagement/Applications/Pages/View.razor +++ b/5-Aquiis.Professional/Features/PropertyManagement/Applications/Pages/View.razor @@ -1,4 +1,4 @@ -@page "/propertymanagement/applications/{ApplicationId:guid}" +@page "/propertymanagement/applications/{ApplicationId:guid}/view" @using Aquiis.Core.Entities @using Aquiis.Application.Services diff --git a/5-Aquiis.Professional/Features/PropertyManagement/ChecklistTemplates/Create.razor b/5-Aquiis.Professional/Features/PropertyManagement/ChecklistTemplates/Create.razor new file mode 100644 index 0000000..e301760 --- /dev/null +++ b/5-Aquiis.Professional/Features/PropertyManagement/ChecklistTemplates/Create.razor @@ -0,0 +1 @@ +@page "/propertymanagement/checkliststemplates/create" \ No newline at end of file diff --git a/5-Aquiis.Professional/Features/PropertyManagement/ChecklistTemplates/Edit.razor b/5-Aquiis.Professional/Features/PropertyManagement/ChecklistTemplates/Edit.razor index 204061d..52062f9 100644 --- a/5-Aquiis.Professional/Features/PropertyManagement/ChecklistTemplates/Edit.razor +++ b/5-Aquiis.Professional/Features/PropertyManagement/ChecklistTemplates/Edit.razor @@ -1,4 +1,4 @@ -@page "/propertymanagement/checklists/templates/{TemplateId:guid}" +@page "/propertymanagement/checkliststemplates/{TemplateId:guid}" @using Aquiis.Core.Entities @using Aquiis.Application.Services diff --git a/5-Aquiis.Professional/Features/PropertyManagement/ChecklistTemplates/Index.razor b/5-Aquiis.Professional/Features/PropertyManagement/ChecklistTemplates/Index.razor index a052d2e..6d407fd 100644 --- a/5-Aquiis.Professional/Features/PropertyManagement/ChecklistTemplates/Index.razor +++ b/5-Aquiis.Professional/Features/PropertyManagement/ChecklistTemplates/Index.razor @@ -1,4 +1,4 @@ -@page "/propertymanagement/checklists/templates" +@page "/propertymanagement/checkliststemplates" @using Aquiis.Core.Entities @using Aquiis.Application.Services diff --git a/5-Aquiis.Professional/Features/PropertyManagement/Checklists/Pages/Create.razor b/5-Aquiis.Professional/Features/PropertyManagement/Checklists/Pages/Create.razor index 0361df6..f1c37ac 100644 --- a/5-Aquiis.Professional/Features/PropertyManagement/Checklists/Pages/Create.razor +++ b/5-Aquiis.Professional/Features/PropertyManagement/Checklists/Pages/Create.razor @@ -1,4 +1,5 @@ @page "/propertymanagement/checklists/create" +@page "/propertymanagement/checklists/create/{TemplateId:guid}" @using Aquiis.Professional.Features.PropertyManagement @using Aquiis.Core.Entities @@ -217,8 +218,8 @@ private string? errorMessage; private string? successMessage; - [SupplyParameterFromQuery(Name = "templateId")] - public Guid? TemplateIdFromQuery { get; set; } + [Parameter] + public Guid? TemplateId { get; set; } protected override async Task OnInitializedAsync() { @@ -241,10 +242,10 @@ // Load templates templates = await ChecklistService.GetChecklistTemplatesAsync(); - // Pre-select template if provided in query string - if (TemplateIdFromQuery.HasValue) + // Pre-select template if provided in route + if (TemplateId.HasValue && TemplateId.Value != Guid.Empty) { - selectedTemplateId = TemplateIdFromQuery.Value; + selectedTemplateId = TemplateId.Value; await OnTemplateChanged(); } } diff --git a/5-Aquiis.Professional/Features/PropertyManagement/Checklists/Pages/Complete.razor b/5-Aquiis.Professional/Features/PropertyManagement/Checklists/Pages/Edit.razor similarity index 99% rename from 5-Aquiis.Professional/Features/PropertyManagement/Checklists/Pages/Complete.razor rename to 5-Aquiis.Professional/Features/PropertyManagement/Checklists/Pages/Edit.razor index 76c5085..f884c0a 100644 --- a/5-Aquiis.Professional/Features/PropertyManagement/Checklists/Pages/Complete.razor +++ b/5-Aquiis.Professional/Features/PropertyManagement/Checklists/Pages/Edit.razor @@ -1,5 +1,4 @@ -@page "/propertymanagement/checklists/complete/{ChecklistId:guid}" -@page "/propertymanagement/checklists/complete/new" +@page "/propertymanagement/checklists/{ChecklistId:guid}/complete" @using Aquiis.Core.Entities @using Aquiis.Professional.Features.PropertyManagement @@ -449,7 +448,7 @@ else // If checklist is already completed, redirect to view page if (checklist.Status == ApplicationConstants.ChecklistStatuses.Completed) { - NavigationManager.NavigateTo($"/propertymanagement/checklists/{ChecklistId}"); + NavigationManager.NavigateTo($"/propertymanagement/checklists/view/{ChecklistId}"); } } catch (Exception ex) diff --git a/5-Aquiis.Professional/Features/PropertyManagement/Checklists/Pages/Index.razor b/5-Aquiis.Professional/Features/PropertyManagement/Checklists/Pages/Index.razor index 004b123..3243c58 100644 --- a/5-Aquiis.Professional/Features/PropertyManagement/Checklists/Pages/Index.razor +++ b/5-Aquiis.Professional/Features/PropertyManagement/Checklists/Pages/Index.razor @@ -161,7 +161,7 @@ private void StartChecklist(Guid templateId) { // Navigate to complete page with template ID - checklist will be created on save - NavigationManager.NavigateTo($"/propertymanagement/checklists/complete/new?templateId={templateId}"); + NavigationManager.NavigateTo($"/propertymanagement/checklists/create/{templateId}"); } private void NavigateToMyChecklists() diff --git a/5-Aquiis.Professional/Features/PropertyManagement/Documents/Pages/Index.razor b/5-Aquiis.Professional/Features/PropertyManagement/Documents/Pages/Documents.razor similarity index 100% rename from 5-Aquiis.Professional/Features/PropertyManagement/Documents/Pages/Index.razor rename to 5-Aquiis.Professional/Features/PropertyManagement/Documents/Pages/Documents.razor diff --git a/5-Aquiis.Professional/Features/PropertyManagement/Documents/Pages/LeaseDocuments.razor b/5-Aquiis.Professional/Features/PropertyManagement/Documents/Pages/LeaseDocuments.razor index fd8bb1a..e2c6111 100644 --- a/5-Aquiis.Professional/Features/PropertyManagement/Documents/Pages/LeaseDocuments.razor +++ b/5-Aquiis.Professional/Features/PropertyManagement/Documents/Pages/LeaseDocuments.razor @@ -308,7 +308,7 @@ else private void GoToLease() { - Navigation.NavigateTo($"/propertymanagement/leases/view/{LeaseId}"); + Navigation.NavigateTo($"/propertymanagement/leases/{LeaseId}"); } private string GetFileIcon(string extension) diff --git a/5-Aquiis.Professional/Features/PropertyManagement/Inspections/Pages/Create.razor b/5-Aquiis.Professional/Features/PropertyManagement/Inspections/Pages/Create.razor index 2a623c6..f133fa2 100644 --- a/5-Aquiis.Professional/Features/PropertyManagement/Inspections/Pages/Create.razor +++ b/5-Aquiis.Professional/Features/PropertyManagement/Inspections/Pages/Create.razor @@ -395,7 +395,7 @@ else // Navigate to view inspection page after short delay await Task.Delay(500); - NavigationManager.NavigateTo($"/propertymanagement/inspections/view/{inspection.Id}"); + NavigationManager.NavigateTo($"/propertymanagement/inspections/{inspection.Id}"); } catch (Exception ex) { diff --git a/5-Aquiis.Professional/Features/PropertyManagement/Inspections/Pages/Edit.razor b/5-Aquiis.Professional/Features/PropertyManagement/Inspections/Pages/Edit.razor new file mode 100644 index 0000000..0cf17db --- /dev/null +++ b/5-Aquiis.Professional/Features/PropertyManagement/Inspections/Pages/Edit.razor @@ -0,0 +1 @@ +@page "/propertymanagement/inspections/{InspectionId:guid}/" \ No newline at end of file diff --git a/5-Aquiis.Professional/Features/PropertyManagement/Inspections/Pages/Schedule.razor b/5-Aquiis.Professional/Features/PropertyManagement/Inspections/Pages/Index.razor similarity index 99% rename from 5-Aquiis.Professional/Features/PropertyManagement/Inspections/Pages/Schedule.razor rename to 5-Aquiis.Professional/Features/PropertyManagement/Inspections/Pages/Index.razor index e63c83a..5967452 100644 --- a/5-Aquiis.Professional/Features/PropertyManagement/Inspections/Pages/Schedule.razor +++ b/5-Aquiis.Professional/Features/PropertyManagement/Inspections/Pages/Index.razor @@ -1,4 +1,4 @@ -@page "/propertymanagement/inspections/schedule" +@page "/propertymanagement/inspections" @using Aquiis.Professional.Features.PropertyManagement @using Aquiis.Core.Entities diff --git a/5-Aquiis.Professional/Features/PropertyManagement/Invoices/Pages/Edit.razor b/5-Aquiis.Professional/Features/PropertyManagement/Invoices/Pages/Edit.razor index fc3cdc6..7030a48 100644 --- a/5-Aquiis.Professional/Features/PropertyManagement/Invoices/Pages/Edit.razor +++ b/5-Aquiis.Professional/Features/PropertyManagement/Invoices/Pages/Edit.razor @@ -1,4 +1,4 @@ -@page "/propertymanagement/invoices/{Id:guid}/edit" +@page "/propertymanagement/invoices/edit/{Id:guid}" @using Aquiis.Professional.Features.PropertyManagement @using Aquiis.Core.Entities diff --git a/5-Aquiis.Professional/Features/PropertyManagement/Invoices/Pages/View.razor b/5-Aquiis.Professional/Features/PropertyManagement/Invoices/Pages/View.razor index e7fdfea..77db724 100644 --- a/5-Aquiis.Professional/Features/PropertyManagement/Invoices/Pages/View.razor +++ b/5-Aquiis.Professional/Features/PropertyManagement/Invoices/Pages/View.razor @@ -1,4 +1,4 @@ -@page "/propertymanagement/invoices/{Id:guid}" +@page "/propertymanagement/invoices/view/{Id:guid}" @using Aquiis.Professional.Features.PropertyManagement @using Aquiis.Core.Entities @using Microsoft.AspNetCore.Authorization diff --git a/5-Aquiis.Professional/Features/PropertyManagement/LeaseOffers/Pages/Create.razor b/5-Aquiis.Professional/Features/PropertyManagement/LeaseOffers/Pages/Create.razor index 566b95a..98b7396 100644 --- a/5-Aquiis.Professional/Features/PropertyManagement/LeaseOffers/Pages/Create.razor +++ b/5-Aquiis.Professional/Features/PropertyManagement/LeaseOffers/Pages/Create.razor @@ -344,7 +344,7 @@ if (result.Success) { ToastService.ShowSuccess("Lease offer generated successfully!"); - Navigation.NavigateTo($"/propertymanagement/leaseoffers/{result.Data!.Id}"); + Navigation.NavigateTo($"/propertymanagement/leaseoffers/view/{result.Data!.Id}"); } else { diff --git a/5-Aquiis.Professional/Features/PropertyManagement/LeaseOffers/Pages/View.razor b/5-Aquiis.Professional/Features/PropertyManagement/LeaseOffers/Pages/View.razor index 454930a..0245b02 100644 --- a/5-Aquiis.Professional/Features/PropertyManagement/LeaseOffers/Pages/View.razor +++ b/5-Aquiis.Professional/Features/PropertyManagement/LeaseOffers/Pages/View.razor @@ -1,4 +1,4 @@ -@page "/propertymanagement/leaseoffers/{Id:guid}" +@page "/propertymanagement/leaseoffers/view/{Id:guid}" @using Aquiis.Core.Entities @using Aquiis.Application.Services @@ -484,7 +484,7 @@ // Navigate to the newly created lease if (result.Data != null) { - Navigation.NavigateTo($"/propertymanagement/leases/{result.Data.Id}"); + Navigation.NavigateTo($"/propertymanagement/leases/{result.Data.Id}/view"); } } else @@ -544,7 +544,7 @@ { if (leaseOffer?.ConvertedLeaseId.HasValue == true) { - Navigation.NavigateTo($"/propertymanagement/leases/{leaseOffer.ConvertedLeaseId.Value}"); + Navigation.NavigateTo($"/propertymanagement/leases/{leaseOffer.ConvertedLeaseId.Value}/view"); } } @@ -552,7 +552,7 @@ { if (leaseOffer?.RentalApplicationId != null) { - Navigation.NavigateTo($"/propertymanagement/applications/{leaseOffer.RentalApplicationId}/review"); + Navigation.NavigateTo($"/propertymanagement/applications/{leaseOffer.RentalApplicationId}/view"); } else { diff --git a/5-Aquiis.Professional/Features/PropertyManagement/Leases/Pages/Edit.razor b/5-Aquiis.Professional/Features/PropertyManagement/Leases/Pages/Edit.razor index 131c14a..948aa4b 100644 --- a/5-Aquiis.Professional/Features/PropertyManagement/Leases/Pages/Edit.razor +++ b/5-Aquiis.Professional/Features/PropertyManagement/Leases/Pages/Edit.razor @@ -1,4 +1,4 @@ -@page "/propertymanagement/leases/{Id:guid}/edit" +@page "/propertymanagement/leases/edit/{Id:guid}" @using Aquiis.Core.Entities @using Microsoft.AspNetCore.Components.Authorization diff --git a/5-Aquiis.Professional/Features/PropertyManagement/Leases/Pages/View.razor b/5-Aquiis.Professional/Features/PropertyManagement/Leases/Pages/View.razor index 308a59d..def44f5 100644 --- a/5-Aquiis.Professional/Features/PropertyManagement/Leases/Pages/View.razor +++ b/5-Aquiis.Professional/Features/PropertyManagement/Leases/Pages/View.razor @@ -1,4 +1,4 @@ -@page "/propertymanagement/leases/{Id:guid}" +@page "/propertymanagement/leases/{Id:guid}/view" @using Aquiis.Core.Entities @using Aquiis.Core.Validation diff --git a/5-Aquiis.Professional/Features/PropertyManagement/Payments/Pages/Edit.razor b/5-Aquiis.Professional/Features/PropertyManagement/Payments/Pages/Edit.razor index f951cfd..f898c3b 100644 --- a/5-Aquiis.Professional/Features/PropertyManagement/Payments/Pages/Edit.razor +++ b/5-Aquiis.Professional/Features/PropertyManagement/Payments/Pages/Edit.razor @@ -104,7 +104,7 @@ else

- + @payment.Invoice.InvoiceNumber

diff --git a/5-Aquiis.Professional/Features/PropertyManagement/Payments/Pages/Index.razor b/5-Aquiis.Professional/Features/PropertyManagement/Payments/Pages/Index.razor index 2eb80b5..47f7825 100644 --- a/5-Aquiis.Professional/Features/PropertyManagement/Payments/Pages/Index.razor +++ b/5-Aquiis.Professional/Features/PropertyManagement/Payments/Pages/Index.razor @@ -216,7 +216,7 @@ else @payment.PaidOn.ToString("MMM dd, yyyy") - + @payment.Invoice?.InvoiceNumber diff --git a/5-Aquiis.Professional/Features/PropertyManagement/Payments/Pages/View.razor b/5-Aquiis.Professional/Features/PropertyManagement/Payments/Pages/View.razor index 76fd559..2077f29 100644 --- a/5-Aquiis.Professional/Features/PropertyManagement/Payments/Pages/View.razor +++ b/5-Aquiis.Professional/Features/PropertyManagement/Payments/Pages/View.razor @@ -88,7 +88,7 @@ else

- + @payment.Invoice.InvoiceNumber

@@ -171,7 +171,7 @@ else

- + @payment.Invoice.Lease.Property?.Address

@@ -179,7 +179,7 @@ else

- + @payment.Invoice.Lease.Tenant?.FullName

@@ -256,18 +256,18 @@ else Download Receipt } - + View Invoice @if (payment.Invoice?.Lease != null) { - + View Lease - + View Property - + View Tenant } diff --git a/5-Aquiis.Professional/Features/PropertyManagement/Properties/Pages/Index.razor b/5-Aquiis.Professional/Features/PropertyManagement/Properties/Pages/Index.razor index 894ad5b..5bc5acb 100644 --- a/5-Aquiis.Professional/Features/PropertyManagement/Properties/Pages/Index.razor +++ b/5-Aquiis.Professional/Features/PropertyManagement/Properties/Pages/Index.razor @@ -1,6 +1,7 @@ @page "/propertymanagement/properties" @using Aquiis.Professional.Features.PropertyManagement @using Aquiis.Core.Constants +@using Aquiis.UI.Shared.Features.PropertiesManagement.Properties @using Microsoft.AspNetCore.Authorization @inject NavigationManager Navigation @inject PropertyService PropertyService diff --git a/5-Aquiis.Professional/Features/PropertyManagement/Properties/Pages/View.razor b/5-Aquiis.Professional/Features/PropertyManagement/Properties/Pages/View.razor index 99d3de8..804944d 100644 --- a/5-Aquiis.Professional/Features/PropertyManagement/Properties/Pages/View.razor +++ b/5-Aquiis.Professional/Features/PropertyManagement/Properties/Pages/View.razor @@ -279,7 +279,7 @@ else { var lastInspection = propertyInspections.Where(i => i.CompletedOn == property.LastRoutineInspectionDate.Value && i.InspectionType == "Routine").FirstOrDefault(); - + View Last Routine Inspection diff --git a/5-Aquiis.Professional/Features/PropertyManagement/Prospects/Index.razor b/5-Aquiis.Professional/Features/PropertyManagement/Prospects/Index.razor index aadecb9..e8462d5 100644 --- a/5-Aquiis.Professional/Features/PropertyManagement/Prospects/Index.razor +++ b/5-Aquiis.Professional/Features/PropertyManagement/Prospects/Index.razor @@ -1,5 +1,4 @@ @page "/PropertyManagement/ProspectiveTenants" - @using Aquiis.Core.Entities @using Aquiis.Application.Services @using Aquiis.Professional.Shared.Services diff --git a/5-Aquiis.Professional/Features/PropertyManagement/Prospects/View.razor b/5-Aquiis.Professional/Features/PropertyManagement/Prospects/View.razor index 8193f8b..da60794 100644 --- a/5-Aquiis.Professional/Features/PropertyManagement/Prospects/View.razor +++ b/5-Aquiis.Professional/Features/PropertyManagement/Prospects/View.razor @@ -8,7 +8,6 @@ @using Aquiis.Core.Utilities @using System.ComponentModel.DataAnnotations @using Microsoft.AspNetCore.Authorization -@using System.Runtime.Serialization @attribute [OrganizationAuthorize("Owner", "Administrator", "PropertyManager")] @inject ProspectiveTenantService ProspectiveTenantService @@ -692,7 +691,7 @@ { if (application != null) { - Navigation.NavigateTo($"/propertymanagement/applications/{application.Id}"); + Navigation.NavigateTo($"/propertymanagement/applications/{application.Id}/view"); } } diff --git a/5-Aquiis.Professional/Features/PropertyManagement/SecurityDeposits/Pages/SecurityDeposits.razor b/5-Aquiis.Professional/Features/PropertyManagement/SecurityDeposits/Pages/SecurityDeposits.razor index 5e2c026..d1a177e 100644 --- a/5-Aquiis.Professional/Features/PropertyManagement/SecurityDeposits/Pages/SecurityDeposits.razor +++ b/5-Aquiis.Professional/Features/PropertyManagement/SecurityDeposits/Pages/SecurityDeposits.razor @@ -160,7 +160,7 @@ @if (deposit.Lease?.Property != null) { - + @deposit.Lease.Property.Address
@deposit.Lease.Property.City, @deposit.Lease.Property.State
@@ -169,7 +169,7 @@ @if (deposit.Tenant != null) { - + @deposit.Tenant.FirstName @deposit.Tenant.LastName } diff --git a/5-Aquiis.Professional/Features/PropertyManagement/Tenants/Pages/Create.razor b/5-Aquiis.Professional/Features/PropertyManagement/Tenants/Pages/Create.razor index e7b4871..7599aca 100644 --- a/5-Aquiis.Professional/Features/PropertyManagement/Tenants/Pages/Create.razor +++ b/5-Aquiis.Professional/Features/PropertyManagement/Tenants/Pages/Create.razor @@ -138,7 +138,7 @@ if (existingTenant != null) { errorMessage = $"A tenant with identification number {tenantModel.IdentificationNumber} already exists. " + - $"View existing tenant: {existingTenant.FullName}"; + $"View existing tenant: {existingTenant.FullName}"; ToastService.ShowWarning($"Duplicate identification number found for {existingTenant.FullName}"); return; } diff --git a/5-Aquiis.Professional/Features/PropertyManagement/Tenants/Pages/Edit.razor b/5-Aquiis.Professional/Features/PropertyManagement/Tenants/Pages/Edit.razor index 211d029..2768da8 100644 --- a/5-Aquiis.Professional/Features/PropertyManagement/Tenants/Pages/Edit.razor +++ b/5-Aquiis.Professional/Features/PropertyManagement/Tenants/Pages/Edit.razor @@ -1,4 +1,4 @@ -@page "/propertymanagement/tenants/{Id:guid}/edit" +@page "/propertymanagement/tenants/edit/{Id:guid}" @using Aquiis.Core.Entities @using System.ComponentModel.DataAnnotations @using Microsoft.AspNetCore.Authorization diff --git a/5-Aquiis.Professional/Features/PropertyManagement/Tenants/Pages/View.razor b/5-Aquiis.Professional/Features/PropertyManagement/Tenants/Pages/View.razor index c5e9367..2e5021f 100644 --- a/5-Aquiis.Professional/Features/PropertyManagement/Tenants/Pages/View.razor +++ b/5-Aquiis.Professional/Features/PropertyManagement/Tenants/Pages/View.razor @@ -1,4 +1,4 @@ -@page "/propertymanagement/tenants/{Id:guid}" +@page "/propertymanagement/tenants/view/{Id:guid}" @using Aquiis.Core.Entities @using Microsoft.AspNetCore.Authorization @inject NavigationManager NavigationManager diff --git a/5-Aquiis.Professional/Features/PropertyManagement/Tours/Index.razor b/5-Aquiis.Professional/Features/PropertyManagement/Tours/Index.razor index 2ef2076..c28d592 100644 --- a/5-Aquiis.Professional/Features/PropertyManagement/Tours/Index.razor +++ b/5-Aquiis.Professional/Features/PropertyManagement/Tours/Index.razor @@ -316,7 +316,7 @@ // Navigate to the property tour checklist to complete it if (tour.ChecklistId.HasValue) { - Navigation.NavigateTo($"/PropertyManagement/Checklists/{tour.ChecklistId.Value}"); + Navigation.NavigateTo($"/PropertyManagement/Checklists/{tour.ChecklistId.Value}/complete"); } else { diff --git a/5-Aquiis.Professional/GlobalUsings.cs b/5-Aquiis.Professional/GlobalUsings.cs deleted file mode 100644 index ca183d9..0000000 --- a/5-Aquiis.Professional/GlobalUsings.cs +++ /dev/null @@ -1,3 +0,0 @@ -// Global using directives -global using Aquiis.Professional.Entities; -global using ApplicationDbContext = Aquiis.Infrastructure.Data.ApplicationDbContext; diff --git a/5-Aquiis.Professional/Properties/launchSettings.json b/5-Aquiis.Professional/Properties/launchSettings.json index 4e83bb2..621c493 100644 --- a/5-Aquiis.Professional/Properties/launchSettings.json +++ b/5-Aquiis.Professional/Properties/launchSettings.json @@ -1,23 +1,23 @@ { "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "http://localhost:5105", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "https://localhost:7198;http://localhost:5105", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5105", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7087;http://localhost:5105", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" } } } +} diff --git a/5-Aquiis.Professional/Shared/Authorization/OrganizationRoleAuthorizationHandler.cs b/5-Aquiis.Professional/Shared/Authorization/OrganizationRoleAuthorizationHandler copy.cs similarity index 98% rename from 5-Aquiis.Professional/Shared/Authorization/OrganizationRoleAuthorizationHandler.cs rename to 5-Aquiis.Professional/Shared/Authorization/OrganizationRoleAuthorizationHandler copy.cs index 9b69f71..1da3a22 100644 --- a/5-Aquiis.Professional/Shared/Authorization/OrganizationRoleAuthorizationHandler.cs +++ b/5-Aquiis.Professional/Shared/Authorization/OrganizationRoleAuthorizationHandler copy.cs @@ -5,6 +5,7 @@ using Aquiis.Infrastructure.Data; using Aquiis.Core.Constants; using System.Security.Claims; +using Aquiis.Professional.Entities; namespace Aquiis.Professional.Shared.Authorization; diff --git a/5-Aquiis.Professional/Shared/Components/Account/AccountConstants.cs b/5-Aquiis.Professional/Shared/Components/Account/AccountConstants.cs index 6209cca..35ef0ec 100644 --- a/5-Aquiis.Professional/Shared/Components/Account/AccountConstants.cs +++ b/5-Aquiis.Professional/Shared/Components/Account/AccountConstants.cs @@ -1,3 +1,4 @@ +using Aquiis.Professional.Entities; namespace Aquiis.Professional.Shared.Components.Account { public static class AccountConstants diff --git a/5-Aquiis.Professional/Shared/Components/Account/IdentityComponentsEndpointRouteBuilderExtensions.cs b/5-Aquiis.Professional/Shared/Components/Account/IdentityComponentsEndpointRouteBuilderExtensions.cs index 64879ba..f16be87 100644 --- a/5-Aquiis.Professional/Shared/Components/Account/IdentityComponentsEndpointRouteBuilderExtensions.cs +++ b/5-Aquiis.Professional/Shared/Components/Account/IdentityComponentsEndpointRouteBuilderExtensions.cs @@ -1,3 +1,4 @@ +using Aquiis.Professional.Entities; using System.Security.Claims; using System.Text.Json; using Microsoft.AspNetCore.Authentication; diff --git a/5-Aquiis.Professional/Shared/Components/Account/IdentityNoOpEmailSender.cs b/5-Aquiis.Professional/Shared/Components/Account/IdentityNoOpEmailSender.cs index 7ec79d8..78c81de 100644 --- a/5-Aquiis.Professional/Shared/Components/Account/IdentityNoOpEmailSender.cs +++ b/5-Aquiis.Professional/Shared/Components/Account/IdentityNoOpEmailSender.cs @@ -1,3 +1,4 @@ +using Aquiis.Professional.Entities; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.UI.Services; using Aquiis.Infrastructure.Data; diff --git a/5-Aquiis.Professional/Shared/Components/Account/IdentityRedirectManager.cs b/5-Aquiis.Professional/Shared/Components/Account/IdentityRedirectManager.cs index 3a90f23..c5ae6f6 100644 --- a/5-Aquiis.Professional/Shared/Components/Account/IdentityRedirectManager.cs +++ b/5-Aquiis.Professional/Shared/Components/Account/IdentityRedirectManager.cs @@ -1,3 +1,4 @@ +using Aquiis.Professional.Entities; using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Components; diff --git a/5-Aquiis.Professional/Shared/Components/Account/IdentityRevalidatingAuthenticationStateProvider.cs b/5-Aquiis.Professional/Shared/Components/Account/IdentityRevalidatingAuthenticationStateProvider.cs index 41062ee..69c7834 100644 --- a/5-Aquiis.Professional/Shared/Components/Account/IdentityRevalidatingAuthenticationStateProvider.cs +++ b/5-Aquiis.Professional/Shared/Components/Account/IdentityRevalidatingAuthenticationStateProvider.cs @@ -1,3 +1,4 @@ +using Aquiis.Professional.Entities; using System.Security.Claims; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Server; diff --git a/5-Aquiis.Professional/Shared/Components/Account/IdentityUserAccessor.cs b/5-Aquiis.Professional/Shared/Components/Account/IdentityUserAccessor.cs index eca758b..d9ea6f5 100644 --- a/5-Aquiis.Professional/Shared/Components/Account/IdentityUserAccessor.cs +++ b/5-Aquiis.Professional/Shared/Components/Account/IdentityUserAccessor.cs @@ -1,3 +1,4 @@ +using Aquiis.Professional.Entities; using Microsoft.AspNetCore.Identity; using Aquiis.Infrastructure.Data; diff --git a/5-Aquiis.Professional/Shared/Components/Account/Pages/ConfirmEmail.razor b/5-Aquiis.Professional/Shared/Components/Account/Pages/ConfirmEmail.razor index e8a70ed..34da62e 100644 --- a/5-Aquiis.Professional/Shared/Components/Account/Pages/ConfirmEmail.razor +++ b/5-Aquiis.Professional/Shared/Components/Account/Pages/ConfirmEmail.razor @@ -26,6 +26,9 @@ protected override async Task OnInitializedAsync() { + + await base.OnInitializedAsync(); + if (UserId is null || Code is null) { RedirectManager.RedirectTo(""); diff --git a/5-Aquiis.Professional/Shared/Components/Account/Pages/ConfirmEmailChange.razor b/5-Aquiis.Professional/Shared/Components/Account/Pages/ConfirmEmailChange.razor index 0edf8ae..96413e8 100644 --- a/5-Aquiis.Professional/Shared/Components/Account/Pages/ConfirmEmailChange.razor +++ b/5-Aquiis.Professional/Shared/Components/Account/Pages/ConfirmEmailChange.razor @@ -31,6 +31,8 @@ protected override async Task OnInitializedAsync() { + await base.OnInitializedAsync(); + if (UserId is null || Email is null || Code is null) { RedirectManager.RedirectToWithStatus( diff --git a/5-Aquiis.Professional/Shared/Components/Account/Pages/ExternalLogin.razor b/5-Aquiis.Professional/Shared/Components/Account/Pages/ExternalLogin.razor index 2a5adf0..8e1ae9e 100644 --- a/5-Aquiis.Professional/Shared/Components/Account/Pages/ExternalLogin.razor +++ b/5-Aquiis.Professional/Shared/Components/Account/Pages/ExternalLogin.razor @@ -68,8 +68,10 @@ protected override async Task OnInitializedAsync() { - Input = Input ?? new InputModel(); + await base.OnInitializedAsync(); + Input = Input ?? new InputModel(); + if (RemoteError is not null) { RedirectManager.RedirectToWithStatus("Account/Login", $"Error from external provider: {RemoteError}", HttpContext); diff --git a/5-Aquiis.Professional/Shared/Components/Account/Pages/ForgotPassword.razor b/5-Aquiis.Professional/Shared/Components/Account/Pages/ForgotPassword.razor index 1e127a6..e6709ec 100644 --- a/5-Aquiis.Professional/Shared/Components/Account/Pages/ForgotPassword.razor +++ b/5-Aquiis.Professional/Shared/Components/Account/Pages/ForgotPassword.razor @@ -36,11 +36,14 @@ [SupplyParameterFromForm] private InputModel Input { get; set; } = default!; - protected override Task OnParametersSetAsync() + protected override async Task OnInitializedAsync() { + await base.OnInitializedAsync(); + Input = Input ?? new InputModel(); - return base.OnParametersSetAsync(); + await Task.CompletedTask; } + private async Task OnValidSubmitAsync() { var user = await UserManager.FindByEmailAsync(Input.Email); diff --git a/5-Aquiis.Professional/Shared/Components/Account/Pages/Login.razor b/5-Aquiis.Professional/Shared/Components/Account/Pages/Login.razor index d335e19..160895f 100644 --- a/5-Aquiis.Professional/Shared/Components/Account/Pages/Login.razor +++ b/5-Aquiis.Professional/Shared/Components/Account/Pages/Login.razor @@ -77,6 +77,10 @@ protected override async Task OnInitializedAsync() { + await base.OnInitializedAsync(); + + Input = Input ?? new InputModel(); + if (HttpMethods.IsGet(HttpContext.Request.Method)) { // Clear the existing external cookie to ensure a clean login process @@ -84,13 +88,6 @@ } } - protected override Task OnParametersSetAsync() - { - ReturnUrl ??= NavigationManager.BaseUri; - Input = Input ?? new InputModel(); - return base.OnParametersSetAsync(); - } - public async Task LoginUser() { // This doesn't count login failures towards account lockout diff --git a/5-Aquiis.Professional/Shared/Components/Account/Pages/LoginWith2fa.razor b/5-Aquiis.Professional/Shared/Components/Account/Pages/LoginWith2fa.razor index 56e6de6..a9c2f6b 100644 --- a/5-Aquiis.Professional/Shared/Components/Account/Pages/LoginWith2fa.razor +++ b/5-Aquiis.Professional/Shared/Components/Account/Pages/LoginWith2fa.razor @@ -58,17 +58,15 @@ protected override async Task OnInitializedAsync() { + await base.OnInitializedAsync(); + + Input = Input ?? new InputModel(); + // Ensure the user has gone through the username & password screen first user = await SignInManager.GetTwoFactorAuthenticationUserAsync() ?? throw new InvalidOperationException("Unable to load two-factor authentication user."); } - protected override Task OnParametersSetAsync() - { - Input = Input ?? new InputModel(); - return base.OnParametersSetAsync(); - } - private async Task OnValidSubmitAsync() { var authenticatorCode = Input.TwoFactorCode!.Replace(" ", string.Empty).Replace("-", string.Empty); diff --git a/5-Aquiis.Professional/Shared/Components/Account/Pages/LoginWithRecoveryCode.razor b/5-Aquiis.Professional/Shared/Components/Account/Pages/LoginWithRecoveryCode.razor index dd7cf29..2fff562 100644 --- a/5-Aquiis.Professional/Shared/Components/Account/Pages/LoginWithRecoveryCode.razor +++ b/5-Aquiis.Professional/Shared/Components/Account/Pages/LoginWithRecoveryCode.razor @@ -44,17 +44,15 @@ protected override async Task OnInitializedAsync() { + await base.OnInitializedAsync(); + + Input = Input ?? new InputModel(); + // Ensure the user has gone through the username & password screen first user = await SignInManager.GetTwoFactorAuthenticationUserAsync() ?? throw new InvalidOperationException("Unable to load two-factor authentication user."); } - protected override Task OnParametersSetAsync() - { - Input = Input ?? new InputModel(); - return base.OnParametersSetAsync(); - } - private async Task OnValidSubmitAsync() { var recoveryCode = Input.RecoveryCode.Replace(" ", string.Empty); diff --git a/5-Aquiis.Professional/Shared/Components/Account/Pages/Manage/ChangePassword.razor b/5-Aquiis.Professional/Shared/Components/Account/Pages/Manage/ChangePassword.razor index 082cd71..36a6ad6 100644 --- a/5-Aquiis.Professional/Shared/Components/Account/Pages/Manage/ChangePassword.razor +++ b/5-Aquiis.Professional/Shared/Components/Account/Pages/Manage/ChangePassword.razor @@ -51,12 +51,16 @@ protected override async Task OnInitializedAsync() { + await base.OnInitializedAsync(); + + Input = Input ?? new InputModel(); user = await UserAccessor.GetRequiredUserAsync(HttpContext); hasPassword = await UserManager.HasPasswordAsync(user); if (!hasPassword) { RedirectManager.RedirectTo("Account/Manage/SetPassword"); } + } private async Task OnValidSubmitAsync() diff --git a/5-Aquiis.Professional/Shared/Components/Account/Pages/Manage/DeletePersonalData.razor b/5-Aquiis.Professional/Shared/Components/Account/Pages/Manage/DeletePersonalData.razor index 0970813..1ee6dea 100644 --- a/5-Aquiis.Professional/Shared/Components/Account/Pages/Manage/DeletePersonalData.razor +++ b/5-Aquiis.Professional/Shared/Components/Account/Pages/Manage/DeletePersonalData.razor @@ -50,8 +50,12 @@ protected override async Task OnInitializedAsync() { + await base.OnInitializedAsync(); + + Input = Input ?? new InputModel(); user = await UserAccessor.GetRequiredUserAsync(HttpContext); requirePassword = await UserManager.HasPasswordAsync(user); + } private async Task OnValidSubmitAsync() diff --git a/5-Aquiis.Professional/Shared/Components/Account/Pages/Manage/Disable2fa.razor b/5-Aquiis.Professional/Shared/Components/Account/Pages/Manage/Disable2fa.razor index ad461c8..a9e6da7 100644 --- a/5-Aquiis.Professional/Shared/Components/Account/Pages/Manage/Disable2fa.razor +++ b/5-Aquiis.Professional/Shared/Components/Account/Pages/Manage/Disable2fa.razor @@ -35,12 +35,15 @@ protected override async Task OnInitializedAsync() { + await base.OnInitializedAsync(); + user = await UserAccessor.GetRequiredUserAsync(HttpContext); if (HttpMethods.IsGet(HttpContext.Request.Method) && !await UserManager.GetTwoFactorEnabledAsync(user)) { throw new InvalidOperationException("Cannot disable 2FA for user as it's not currently enabled."); } + } private async Task OnSubmitAsync() diff --git a/5-Aquiis.Professional/Shared/Components/Account/Pages/Manage/Email.razor b/5-Aquiis.Professional/Shared/Components/Account/Pages/Manage/Email.razor index 434373d..e7e8001 100644 --- a/5-Aquiis.Professional/Shared/Components/Account/Pages/Manage/Email.razor +++ b/5-Aquiis.Professional/Shared/Components/Account/Pages/Manage/Email.razor @@ -65,11 +65,15 @@ protected override async Task OnInitializedAsync() { + await base.OnInitializedAsync(); + + Input = Input ?? new InputModel(); user = await UserAccessor.GetRequiredUserAsync(HttpContext); email = await UserManager.GetEmailAsync(user); isEmailConfirmed = await UserManager.IsEmailConfirmedAsync(user); Input.NewEmail ??= email; + } private async Task OnValidSubmitAsync() diff --git a/5-Aquiis.Professional/Shared/Components/Account/Pages/Manage/EnableAuthenticator.razor b/5-Aquiis.Professional/Shared/Components/Account/Pages/Manage/EnableAuthenticator.razor index 0ffa261..421337f 100644 --- a/5-Aquiis.Professional/Shared/Components/Account/Pages/Manage/EnableAuthenticator.razor +++ b/5-Aquiis.Professional/Shared/Components/Account/Pages/Manage/EnableAuthenticator.razor @@ -82,9 +82,12 @@ else protected override async Task OnInitializedAsync() { + await base.OnInitializedAsync(); + user = await UserAccessor.GetRequiredUserAsync(HttpContext); await LoadSharedKeyAndQrCodeUriAsync(user); + } private async Task OnValidSubmitAsync() diff --git a/5-Aquiis.Professional/Shared/Components/Account/Pages/Manage/ExternalLogins.razor b/5-Aquiis.Professional/Shared/Components/Account/Pages/Manage/ExternalLogins.razor index 5f772dc..f6f3f0e 100644 --- a/5-Aquiis.Professional/Shared/Components/Account/Pages/Manage/ExternalLogins.razor +++ b/5-Aquiis.Professional/Shared/Components/Account/Pages/Manage/ExternalLogins.razor @@ -82,6 +82,8 @@ protected override async Task OnInitializedAsync() { + await base.OnInitializedAsync(); + user = await UserAccessor.GetRequiredUserAsync(HttpContext); currentLogins = await UserManager.GetLoginsAsync(user); otherLogins = (await SignInManager.GetExternalAuthenticationSchemesAsync()) @@ -100,6 +102,7 @@ { await OnGetLinkLoginCallbackAsync(); } + } private async Task OnSubmitAsync() diff --git a/5-Aquiis.Professional/Shared/Components/Account/Pages/Manage/Index.razor b/5-Aquiis.Professional/Shared/Components/Account/Pages/Manage/Index.razor index 5fc0b84..0ae4a5c 100644 --- a/5-Aquiis.Professional/Shared/Components/Account/Pages/Manage/Index.razor +++ b/5-Aquiis.Professional/Shared/Components/Account/Pages/Manage/Index.razor @@ -55,6 +55,9 @@ protected override async Task OnInitializedAsync() { + await base.OnInitializedAsync(); + + Input = Input ?? new InputModel(); user = await UserAccessor.GetRequiredUserAsync(HttpContext); // Reload user from database to ensure we have the latest values diff --git a/5-Aquiis.Professional/Shared/Components/Account/Pages/Manage/SetPassword.razor b/5-Aquiis.Professional/Shared/Components/Account/Pages/Manage/SetPassword.razor index a3ae060..d61efee 100644 --- a/5-Aquiis.Professional/Shared/Components/Account/Pages/Manage/SetPassword.razor +++ b/5-Aquiis.Professional/Shared/Components/Account/Pages/Manage/SetPassword.razor @@ -48,6 +48,9 @@ protected override async Task OnInitializedAsync() { + await base.OnInitializedAsync(); + + Input = Input ?? new InputModel(); user = await UserAccessor.GetRequiredUserAsync(HttpContext); var hasPassword = await UserManager.HasPasswordAsync(user); diff --git a/5-Aquiis.Professional/Shared/Components/Account/Pages/Manage/TwoFactorAuthentication.razor b/5-Aquiis.Professional/Shared/Components/Account/Pages/Manage/TwoFactorAuthentication.razor index 0084aea..1dcb9d9 100644 --- a/5-Aquiis.Professional/Shared/Components/Account/Pages/Manage/TwoFactorAuthentication.razor +++ b/5-Aquiis.Professional/Shared/Components/Account/Pages/Manage/TwoFactorAuthentication.razor @@ -80,6 +80,8 @@ else protected override async Task OnInitializedAsync() { + await base.OnInitializedAsync(); + var user = await UserAccessor.GetRequiredUserAsync(HttpContext); canTrack = HttpContext.Features.Get()?.CanTrack ?? true; hasAuthenticator = await UserManager.GetAuthenticatorKeyAsync(user) is not null; diff --git a/5-Aquiis.Professional/Shared/Components/Account/Pages/Register.razor b/5-Aquiis.Professional/Shared/Components/Account/Pages/Register.razor index 53ad596..a602d9b 100644 --- a/5-Aquiis.Professional/Shared/Components/Account/Pages/Register.razor +++ b/5-Aquiis.Professional/Shared/Components/Account/Pages/Register.razor @@ -105,23 +105,20 @@ else protected override async Task OnInitializedAsync() { + await base.OnInitializedAsync(); // Check if this is the first user (excluding system user) var users = await UserManager.Users - .Where(u => u.Id != ApplicationConstants.SystemUser.Id) - .ToListAsync(); - + .Where(u => u.Id != ApplicationConstants.SystemUser.Id) + .ToListAsync(); + var userCount = users.Count; _isFirstUser = userCount == 0; _allowRegistration = _isFirstUser; // Only allow registration if this is the first user - } - protected override Task OnParametersSetAsync() - { - ReturnUrl ??= NavigationManager.BaseUri; Input = Input ?? new InputModel(); - return base.OnParametersSetAsync(); + StateHasChanged(); } public async Task RegisterUser(EditContext editContext) diff --git a/5-Aquiis.Professional/Shared/Components/Account/Pages/RegisterConfirmation.razor b/5-Aquiis.Professional/Shared/Components/Account/Pages/RegisterConfirmation.razor index 13430bc..d8f7061 100644 --- a/5-Aquiis.Professional/Shared/Components/Account/Pages/RegisterConfirmation.razor +++ b/5-Aquiis.Professional/Shared/Components/Account/Pages/RegisterConfirmation.razor @@ -42,6 +42,8 @@ else protected override async Task OnInitializedAsync() { + await base.OnInitializedAsync(); + if (Email is null) { RedirectManager.RedirectTo(""); diff --git a/5-Aquiis.Professional/Shared/Components/Account/Pages/ResendEmailConfirmation.razor b/5-Aquiis.Professional/Shared/Components/Account/Pages/ResendEmailConfirmation.razor index 278b6c6..4e2b83b 100644 --- a/5-Aquiis.Professional/Shared/Components/Account/Pages/ResendEmailConfirmation.razor +++ b/5-Aquiis.Professional/Shared/Components/Account/Pages/ResendEmailConfirmation.razor @@ -38,8 +38,15 @@ [SupplyParameterFromForm] private InputModel Input { get; set; } = default!; + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + + Input = Input ?? new InputModel(); + } private async Task OnValidSubmitAsync() { + Input = Input ?? new InputModel(); var user = await UserManager.FindByEmailAsync(Input.Email!); if (user is null) { diff --git a/5-Aquiis.Professional/Shared/Components/Account/Pages/ResetPassword.razor b/5-Aquiis.Professional/Shared/Components/Account/Pages/ResetPassword.razor index fc7c209..5dc8d72 100644 --- a/5-Aquiis.Professional/Shared/Components/Account/Pages/ResetPassword.razor +++ b/5-Aquiis.Professional/Shared/Components/Account/Pages/ResetPassword.razor @@ -52,19 +52,19 @@ private string? Message => identityErrors is null ? null : $"Error: {string.Join(", ", identityErrors.Select(error => error.Description))}"; - protected override Task OnParametersSetAsync() - { - Input = Input ?? new InputModel(); - return base.OnParametersSetAsync(); - } protected override void OnInitialized() { + base.OnInitialized(); + + Input = Input ?? new InputModel(); + if (Code is null) { RedirectManager.RedirectTo("Account/InvalidPasswordReset"); } Input.Code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(Code)); + base.OnInitialized(); } private async Task OnValidSubmitAsync() diff --git a/5-Aquiis.Professional/Shared/Components/Pages/TestSharedComponents.razor b/5-Aquiis.Professional/Shared/Components/Pages/TestSharedComponents.razor new file mode 100644 index 0000000..cc4009d --- /dev/null +++ b/5-Aquiis.Professional/Shared/Components/Pages/TestSharedComponents.razor @@ -0,0 +1,143 @@ +@page "/test/shared-components" +@using Aquiis.UI.Shared.Components.Common + +Shared Components Test + +

Shared UI Components Test Page

+ +
+
+

Modal Component

+ + + + +

This is the modal body content.

+

The modal supports different sizes and positions.

+
+ + + + +
+
+
+ +
+
+

Card Component

+ +

This is a card with a title.

+

Cards support headers, bodies, and footers.

+
+
+ +
+ + +
+
Custom Header
+ Active +
+
+ +

This card has a custom header with a badge.

+
+ + + +
+
+ +
+ + +
Styled Card
+
+ +

This card has custom styling on the header.

+
+
+
+
+ +
+
+

DataTable Component

+ + + ID + Name + Status + Actions + + + @context.Id + @context.Name + @(context.IsActive ? "Active" : "Inactive") + + + + + + +
+
+ +
+
+

FormField Component

+ + + + + + + + + + + + + + + +
+
+ +@code { + private bool showModal = false; + + private class TestItem + { + public int Id { get; set; } + public string Name { get; set; } = ""; + public bool IsActive { get; set; } + } + + private List testItems = new() + { + new TestItem { Id = 1, Name = "Item One", IsActive = true }, + new TestItem { Id = 2, Name = "Item Two", IsActive = true }, + new TestItem { Id = 3, Name = "Item Three", IsActive = false }, + new TestItem { Id = 4, Name = "Item Four", IsActive = true }, + }; + + private class FormModel + { + public string Username { get; set; } = ""; + public string Email { get; set; } = ""; + public string Description { get; set; } = ""; + } + + private FormModel formModel = new(); + + private void HandleSubmit() + { + // Form submitted + } +} diff --git a/5-Aquiis.Professional/Shared/Layout/NavMenu.razor b/5-Aquiis.Professional/Shared/Layout/NavMenu.razor index 844ca00..acfb10a 100644 --- a/5-Aquiis.Professional/Shared/Layout/NavMenu.razor +++ b/5-Aquiis.Professional/Shared/Layout/NavMenu.razor @@ -41,7 +41,7 @@
@@ -51,7 +51,7 @@
diff --git a/5-Aquiis.Professional/Shared/Services/EntityRouteHelper.cs b/5-Aquiis.Professional/Shared/Services/EntityRouteHelper.cs index 7cd55e6..6d2d882 100644 --- a/5-Aquiis.Professional/Shared/Services/EntityRouteHelper.cs +++ b/5-Aquiis.Professional/Shared/Services/EntityRouteHelper.cs @@ -4,11 +4,16 @@ namespace Aquiis.Professional.Shared.Services; /// /// Provides centralized mapping between entity types and their navigation routes. +/// Follows RESTful routing conventions: /resource/{id} for details, /resource/{id}/action for specific actions. /// This ensures consistent URL generation across the application when navigating to entity details. -/// RESTful pattern: /resource/{id} for view, /resource/{id}/edit for edit, /resource for list /// public static class EntityRouteHelper { + /// + /// RESTful route mapping: entity type to base resource path. + /// Detail view: {basePath}/{id} + /// Actions: {basePath}/{id}/{action} + /// private static readonly Dictionary RouteMap = new() { { "Lease", "/propertymanagement/leases" }, @@ -18,15 +23,11 @@ public static class EntityRouteHelper { "Application", "/propertymanagement/applications" }, { "Property", "/propertymanagement/properties" }, { "Tenant", "/propertymanagement/tenants" }, - { "Prospect", "/PropertyManagement/ProspectiveTenants" }, - { "Inspection", "/propertymanagement/inspections" }, - { "LeaseOffer", "/propertymanagement/leaseoffers" }, - { "Checklist", "/propertymanagement/checklists" }, - { "Organization", "/administration/organizations" } + { "Prospect", "/propertymanagement/prospects" } }; /// - /// Gets the full navigation route for viewing an entity (RESTful: /resource/{id}) + /// Gets the detail view route for a given entity (RESTful: /resource/{id}). /// /// The type of entity (e.g., "Lease", "Payment", "Maintenance") /// The unique identifier of the entity @@ -48,15 +49,15 @@ public static string GetEntityRoute(string? entityType, Guid entityId) } /// - /// Gets the route for an entity action (RESTful: /resource/{id}/action) + /// Gets the route for a specific action on an entity (RESTful: /resource/{id}/{action}). /// /// The type of entity /// The unique identifier of the entity - /// The action (e.g., "edit", "delete", "approve") - /// The full route path, or "/" if not mapped + /// The action to perform (e.g., "edit", "accept", "approve", "submit-application") + /// The full route path including the entity ID and action public static string GetEntityActionRoute(string? entityType, Guid entityId, string action) { - if (string.IsNullOrWhiteSpace(entityType)) + if (string.IsNullOrWhiteSpace(entityType) || string.IsNullOrWhiteSpace(action)) { return "/"; } @@ -66,14 +67,14 @@ public static string GetEntityActionRoute(string? entityType, Guid entityId, str return $"{route}/{entityId}/{action}"; } - return "/"; + return "/"; } /// - /// Gets the list route for an entity type (RESTful: /resource) + /// Gets the list view route for a given entity type (RESTful: /resource). /// /// The type of entity - /// The list route path, or "/" if not mapped + /// The list view route path public static string GetListRoute(string? entityType) { if (string.IsNullOrWhiteSpace(entityType)) @@ -86,14 +87,14 @@ public static string GetListRoute(string? entityType) return route; } - return "/"; + return "/"; } /// - /// Gets the create route for an entity type (RESTful: /resource/create) + /// Gets the create route for a given entity type (RESTful: /resource/create). /// /// The type of entity - /// The create route path, or "/" if not mapped + /// The create route path public static string GetCreateRoute(string? entityType) { if (string.IsNullOrWhiteSpace(entityType)) @@ -106,7 +107,7 @@ public static string GetCreateRoute(string? entityType) return $"{route}/create"; } - return "/"; + return "/"; } /// @@ -127,4 +128,4 @@ public static IEnumerable GetSupportedEntityTypes() { return RouteMap.Keys; } -} \ No newline at end of file +} diff --git a/5-Aquiis.Professional/Shared/_Imports.razor b/5-Aquiis.Professional/Shared/_Imports.razor index d84320c..21d583f 100644 --- a/5-Aquiis.Professional/Shared/_Imports.razor +++ b/5-Aquiis.Professional/Shared/_Imports.razor @@ -15,5 +15,3 @@ @using Aquiis.Professional.Shared.Components.Shared @using Aquiis.Core.Entities @using Aquiis.Core.Constants -@using Aquiis.UI.Shared.Features.Notifications -@using Aquiis.UI.Shared.Components.Common diff --git a/5-Aquiis.Professional/_Imports.razor b/5-Aquiis.Professional/_Imports.razor new file mode 100644 index 0000000..27e8645 --- /dev/null +++ b/5-Aquiis.Professional/_Imports.razor @@ -0,0 +1,18 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using Aquiis.Professional +@using Aquiis.Professional.Shared.Components +@using Aquiis.Professional.Entities +@using Aquiis.Application.Services +@using Aquiis.Core.Entities +@using Aquiis.Core.Interfaces +@using Aquiis.UI.Shared.Features.Notifications +@using Aquiis.UI.Shared.Components.Common diff --git a/6-Tests/Aquiis.Application.Tests/Services/LeaseWorkflowService.Tests.cs b/6-Tests/Aquiis.Application.Tests/Services/LeaseWorkflowService.Tests.cs index 13b4443..64427ac 100644 --- a/6-Tests/Aquiis.Application.Tests/Services/LeaseWorkflowService.Tests.cs +++ b/6-Tests/Aquiis.Application.Tests/Services/LeaseWorkflowService.Tests.cs @@ -9,6 +9,9 @@ using Aquiis.Infrastructure.Data; using Aquiis.SimpleStart.Entities; using Aquiis.Application.Services.Workflows; +using Aquiis.Application.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace Aquiis.Application.Tests; @@ -62,7 +65,26 @@ private static async Task CreateTestContextAsync() await context.SaveChangesAsync(); var noteService = new Application.Services.NoteService(context, mockUserContext.Object); - var workflowService = new LeaseWorkflowService(context, mockUserContext.Object, noteService); + // In CreateTestContextAsync() + var mockEmailService = new Mock(); + var mockSmsService = new Mock(); + + var notificationService = new NotificationService( + context, + mockUserContext.Object, + mockEmailService.Object, + mockSmsService.Object, + Options.Create(new ApplicationSettings { SoftDeleteEnabled = true }), + Mock.Of>() + ); + + var workflowService = new LeaseWorkflowService( + context, + mockUserContext.Object, + noteService, + notificationService + ); + // var workflowService = new LeaseWorkflowService(context, mockUserContext.Object, noteService); return new TestContext { @@ -1060,7 +1082,7 @@ public void GetInvalidTransitionReason_ReturnsHelpfulMessage() private static LeaseWorkflowService CreateWorkflowServiceForStateMachineTests() { // Create minimal dependencies for state machine tests (doesn't need DB) - return new LeaseWorkflowService(null!, null!, null!); + return new LeaseWorkflowService(null!, null!, null!, null!); } #endregion diff --git a/6-Tests/Aquiis.Application.Tests/Services/PaymentServiceTests.cs b/6-Tests/Aquiis.Application.Tests/Services/PaymentServiceTests.cs index f198d64..4cc310b 100644 --- a/6-Tests/Aquiis.Application.Tests/Services/PaymentServiceTests.cs +++ b/6-Tests/Aquiis.Application.Tests/Services/PaymentServiceTests.cs @@ -2,7 +2,6 @@ using Aquiis.Core.Constants; using Aquiis.Core.Entities; using Aquiis.Core.Interfaces.Services; -using Aquiis.SimpleStart.Entities; using Aquiis.Infrastructure.Data; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; @@ -10,6 +9,7 @@ using Microsoft.Extensions.Options; using Moq; using PaymentService = Aquiis.Application.Services.PaymentService; +using Aquiis.Application.Services; namespace Aquiis.Application.Tests { @@ -153,11 +153,25 @@ public PaymentServiceTests() SoftDeleteEnabled = true }); + // In CreateTestContextAsync() + var mockEmailService = new Mock(); + var mockSmsService = new Mock(); + + var notificationService = new NotificationService( + _context, + _mockUserContext.Object, + mockEmailService.Object, + mockSmsService.Object, + Options.Create(new ApplicationSettings { SoftDeleteEnabled = true }), + Mock.Of>() + ); + // Create service instance _service = new PaymentService( _context, _mockLogger.Object, _mockUserContext.Object, + notificationService, _mockSettings); } diff --git a/6-Tests/Aquiis.Application.Tests/Workflows/ApplicationWorkflowService.EdgeCaseTests.cs b/6-Tests/Aquiis.Application.Tests/Workflows/ApplicationWorkflowService.EdgeCaseTests.cs index 305e211..633f086 100644 --- a/6-Tests/Aquiis.Application.Tests/Workflows/ApplicationWorkflowService.EdgeCaseTests.cs +++ b/6-Tests/Aquiis.Application.Tests/Workflows/ApplicationWorkflowService.EdgeCaseTests.cs @@ -5,7 +5,9 @@ using Aquiis.Infrastructure.Data; using Microsoft.EntityFrameworkCore; using Moq; -using Aquiis.SimpleStart.Entities; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Aquiis.Application.Services; namespace Aquiis.Application.Tests; /// @@ -53,10 +55,40 @@ private static async Task CreateTestContextAsync() var org = new Organization { Id = orgId, Name = "TestOrg", OwnerId = testUserId, CreatedBy = testUserId, CreatedOn = DateTime.UtcNow }; context.Organizations.Add(org); + + // Add UserOrganization relationship so notifications can find users + var userOrg = new UserOrganization + { + UserId = testUserId, + OrganizationId = orgId, + IsActive = true, + CreatedBy = testUserId, + CreatedOn = DateTime.UtcNow + }; + context.UserOrganizations.Add(userOrg); + await context.SaveChangesAsync(); var noteService = new Application.Services.NoteService(context, mockUserContext.Object); - var workflowService = new ApplicationWorkflowService(context, mockUserContext.Object, noteService); + // In CreateTestContextAsync() + var mockEmailService = new Mock(); + var mockSmsService = new Mock(); + + var notificationService = new NotificationService( + context, + mockUserContext.Object, + mockEmailService.Object, + mockSmsService.Object, + Options.Create(new ApplicationSettings { SoftDeleteEnabled = true }), + Mock.Of>() + ); + + var workflowService = new ApplicationWorkflowService( + context, + mockUserContext.Object, + noteService, + notificationService + ); return new TestContext { @@ -179,6 +211,21 @@ public async Task DenyApplication_UpdatesStatusAndProspect() Assert.Equal("Credit score below minimum threshold", dbApp.DenialReason); Assert.NotNull(dbApp.DecidedOn); Assert.Equal(ApplicationConstants.ProspectiveStatuses.Denied, dbApp.ProspectiveTenant!.Status); + + // Verify notifications were created + var allNotifications = await ctx.Context.Notifications.ToListAsync(); + + // Should have 2 notifications: one from Submit, one from Deny + Assert.True(allNotifications.Count >= 1, + $"Expected at least one notification. Found: {allNotifications.Count}"); + + // Find the denial notification (most recent one) + var denyNotification = allNotifications + .OrderByDescending(n => n.CreatedOn) + .FirstOrDefault(n => n.Title.Contains("Denied")); + + Assert.NotNull(denyNotification); + Assert.Contains("Application Denied", denyNotification.Title); } [Fact] diff --git a/6-Tests/Aquiis.Application.Tests/Workflows/ApplicationWorkflowService.LeaseLifecycleTests.cs b/6-Tests/Aquiis.Application.Tests/Workflows/ApplicationWorkflowService.LeaseLifecycleTests.cs index 1408b64..b5f2280 100644 --- a/6-Tests/Aquiis.Application.Tests/Workflows/ApplicationWorkflowService.LeaseLifecycleTests.cs +++ b/6-Tests/Aquiis.Application.Tests/Workflows/ApplicationWorkflowService.LeaseLifecycleTests.cs @@ -4,8 +4,10 @@ using Microsoft.EntityFrameworkCore; using Moq; using Aquiis.Infrastructure.Data; -using Aquiis.SimpleStart.Entities; using Aquiis.Application.Services.Workflows; +using Aquiis.Application.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace Aquiis.Application.Tests; @@ -54,7 +56,26 @@ public async Task GenerateAndAcceptLeaseOffer_CreatesLeaseAndTenant_UpdatesPrope await context.SaveChangesAsync(); var noteService = new Application.Services.NoteService(context, mockUserContext.Object); - var workflowService = new ApplicationWorkflowService(context, mockUserContext.Object, noteService); + + // In CreateTestContextAsync() + var mockEmailService = new Mock(); + var mockSmsService = new Mock(); + + var notificationService = new NotificationService( + context, + mockUserContext.Object, + mockEmailService.Object, + mockSmsService.Object, + Options.Create(new ApplicationSettings { SoftDeleteEnabled = true }), + Mock.Of>() + ); + + var workflowService = new ApplicationWorkflowService( + context, + mockUserContext.Object, + noteService, + notificationService + ); // Submit application var submissionModel = new ApplicationSubmissionModel diff --git a/6-Tests/Aquiis.Application.Tests/Workflows/ApplicationWorkflowServiceTests.cs b/6-Tests/Aquiis.Application.Tests/Workflows/ApplicationWorkflowServiceTests.cs index ea543a3..d10a6e1 100644 --- a/6-Tests/Aquiis.Application.Tests/Workflows/ApplicationWorkflowServiceTests.cs +++ b/6-Tests/Aquiis.Application.Tests/Workflows/ApplicationWorkflowServiceTests.cs @@ -6,6 +6,9 @@ using Aquiis.Infrastructure.Data; using Aquiis.SimpleStart.Entities; using Aquiis.Application.Services.Workflows; +using Aquiis.Application.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace Aquiis.Application.Tests; @@ -57,7 +60,26 @@ public async Task GetApplicationWorkflowStateAsync_ReturnsExpectedState() // Create NoteService (not used heavily in this test) var noteService = new Application.Services.NoteService(context, mockUserContext.Object); - var workflowService = new ApplicationWorkflowService(context, mockUserContext.Object, noteService); + // Create ApplicationWorkflowService + // In CreateTestContextAsync() + var mockEmailService = new Mock(); + var mockSmsService = new Mock(); + + var notificationService = new NotificationService( + context, + mockUserContext.Object, + mockEmailService.Object, + mockSmsService.Object, + Options.Create(new ApplicationSettings { SoftDeleteEnabled = true }), + Mock.Of>() + ); + + var workflowService = new ApplicationWorkflowService( + context, + mockUserContext.Object, + noteService, + notificationService + ); // Act - submit application then initiate screening var submissionModel = new ApplicationSubmissionModel diff --git a/6-Tests/Aquiis.UI.Professional.Tests/NewSetupUITests.cs b/6-Tests/Aquiis.UI.Professional.Tests/NewSetupUITests.cs index 4a2912b..37fa703 100644 --- a/6-Tests/Aquiis.UI.Professional.Tests/NewSetupUITests.cs +++ b/6-Tests/Aquiis.UI.Professional.Tests/NewSetupUITests.cs @@ -198,7 +198,7 @@ public async Task ScheduleAndCompleteTour() await Page.WaitForLoadStateAsync(LoadState.DOMContentLoaded); - await Page.GetByRole(AriaRole.Button, new() { Name = "๏“‹ Continue Editing" }).ClickAsync(); + //await Page.GetByRole(AriaRole.Button, new() { Name = "๏“‹ Continue Editing" }).ClickAsync(); await Page.GetByRole(AriaRole.Button, new() { Name = "๏‰ฉ Check All" }).First.ClickAsync(); await Page.GetByRole(AriaRole.Button, new() { Name = "๏‰ฉ Check All" }).Nth(1).ClickAsync(); await Page.GetByRole(AriaRole.Button, new() { Name = "๏‰ฉ Check All" }).Nth(2).ClickAsync(); diff --git a/6-Tests/Aquiis.UI.SimpleStart.Tests/NewSetupUITests.cs b/6-Tests/Aquiis.UI.SimpleStart.Tests/NewSetupUITests.cs index 527cfad..19d579b 100644 --- a/6-Tests/Aquiis.UI.SimpleStart.Tests/NewSetupUITests.cs +++ b/6-Tests/Aquiis.UI.SimpleStart.Tests/NewSetupUITests.cs @@ -198,7 +198,7 @@ public async Task ScheduleAndCompleteTour() await Page.WaitForLoadStateAsync(LoadState.DOMContentLoaded); - await Page.GetByRole(AriaRole.Button, new() { Name = "๏“‹ Continue Editing" }).ClickAsync(); + // await Page.GetByRole(AriaRole.Button, new() { Name = "๏“‹ Continue Editing" }).ClickAsync(); await Page.GetByRole(AriaRole.Button, new() { Name = "๏‰ฉ Check All" }).First.ClickAsync(); await Page.GetByRole(AriaRole.Button, new() { Name = "๏‰ฉ Check All" }).Nth(1).ClickAsync(); await Page.GetByRole(AriaRole.Button, new() { Name = "๏‰ฉ Check All" }).Nth(2).ClickAsync();