From 1cb7a8ca4a11869b9279e295089e5a724930450a Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Tue, 28 Apr 2026 09:17:29 -0400 Subject: [PATCH] Phases 3 & 4: Complete data access architecture migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 — eliminated ApplicationDbContext from all non-exempt controllers, routing all data access through IUnitOfWork. Added IPlainRepository for the four platform entities (Announcement, BannedIp, DashboardTip, ReleaseNote) that intentionally don't extend BaseEntity and therefore can't use the constrained IRepository. Added permanent-exception comments to the 18 controllers that legitimately retain direct DbContext access (Identity infra, cross-tenant platform ops, bulk streaming exports). Phase 4 — added EnforceDataAccessArchitecture() to Program.cs, a startup gate that reflects over every Controller subclass and throws at boot if any non-exempt controller injects ApplicationDbContext. The app cannot start with a violation. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 4 +- docs/DATA_ACCESS_ARCHITECTURE.md | 104 +- scripts/042626_deploy_migration.sql | 6219 +++++++++++++++++ .../Interfaces/IAiUsageReportService.cs | 29 + .../Interfaces/IOperationalReportService.cs | 23 + .../Interfaces/IPlainRepository.cs | 29 + .../Interfaces/IUnitOfWork.cs | 18 +- .../Repositories/IBillRepository.cs | 7 + .../IInventoryTransactionRepository.cs | 22 + .../Repositories/IJobItemCoatRepository.cs | 40 + .../Repositories/IJobPhotoRepository.cs | 28 + .../Interfaces/Repositories/IJobRepository.cs | 7 + .../Repositories/IJobTemplateRepository.cs | 25 + .../INotificationLogRepository.cs | 16 + .../Repositories/IPowderUsageLogRepository.cs | 17 + .../Interfaces/Services/IAuditLogService.cs | 24 + .../Services/ICompanyDataPurgeService.cs | 26 + .../Services/ICompanyListService.cs | 39 + .../Services/IDashboardReadService.cs | 42 + .../Repositories/BillRepository.cs | 13 + .../InventoryTransactionRepository.cs | 45 + .../Repositories/JobItemCoatRepository.cs | 52 + .../Repositories/JobPhotoRepository.cs | 52 + .../Repositories/JobRepository.cs | 12 + .../Repositories/JobTemplateRepository.cs | 47 + .../Repositories/NotificationLogRepository.cs | 66 + .../Repositories/PlainRepository.cs | 73 + .../Repositories/PowderUsageLogRepository.cs | 39 + .../Repositories/UnitOfWork.cs | 62 +- .../Services/AiUsageReportService.cs | 55 + .../Services/AuditLogService.cs | 40 + .../Services/CompanyDataPurgeService.cs | 180 + .../Services/CompanyListService.cs | 103 + .../Services/DashboardReadService.cs | 225 + .../Services/FinancialReportService.cs | 424 +- .../Services/OperationalReportService.cs | 125 +- .../Controllers/AccountingExportController.cs | 51 +- .../Controllers/AiQuickQuoteController.cs | 22 +- .../Controllers/AiUsageReportController.cs | 107 +- .../Controllers/AnnouncementsController.cs | 79 +- .../Controllers/AuditLogController.cs | 1 + .../Controllers/BannedIpsController.cs | 48 +- .../Controllers/BugReportController.cs | 62 +- .../Controllers/CompaniesController.cs | 309 +- .../Controllers/CompanySettingsController.cs | 30 +- .../Controllers/CompanyUsersController.cs | 19 +- .../Controllers/DashboardController.cs | 386 +- .../Controllers/DashboardTipsController.cs | 69 +- .../Controllers/DepositsController.cs | 14 +- .../Controllers/EmailBroadcastController.cs | 1 + .../Controllers/ExpensesController.cs | 48 +- .../InAppNotificationsController.cs | 112 +- .../Controllers/InventoryController.cs | 148 +- .../Controllers/JobTemplatesController.cs | 64 +- .../Controllers/JobsPriorityController.cs | 33 +- .../Controllers/NotificationLogsController.cs | 129 +- .../Controllers/PasskeyController.cs | 1 + .../PlatformNotificationsController.cs | 101 +- .../Controllers/QuoteApprovalController.cs | 183 +- .../Controllers/ReleaseNotesController.cs | 99 +- .../Controllers/ReportsController.cs | 723 +- .../Controllers/RevenueController.cs | 1 + .../Controllers/SetupWizardController.cs | 8 +- .../Controllers/SmsConsentAuditController.cs | 85 +- .../Controllers/StripeEventsController.cs | 1 + .../SubscriptionManagementController.cs | 1 + .../Controllers/UnsubscribeController.cs | 26 +- .../Controllers/UsageQuotaController.cs | 1 + .../Controllers/UserActivityController.cs | 1 + .../Controllers/VendorsController.cs | 19 +- src/PowderCoating.Web/Program.cs | 66 + .../DepositsControllerTests.cs | 3 +- 72 files changed, 9060 insertions(+), 2323 deletions(-) create mode 100644 scripts/042626_deploy_migration.sql create mode 100644 src/PowderCoating.Application/Interfaces/IAiUsageReportService.cs create mode 100644 src/PowderCoating.Core/Interfaces/IPlainRepository.cs create mode 100644 src/PowderCoating.Core/Interfaces/Repositories/IInventoryTransactionRepository.cs create mode 100644 src/PowderCoating.Core/Interfaces/Repositories/IJobItemCoatRepository.cs create mode 100644 src/PowderCoating.Core/Interfaces/Repositories/IJobPhotoRepository.cs create mode 100644 src/PowderCoating.Core/Interfaces/Repositories/IJobTemplateRepository.cs create mode 100644 src/PowderCoating.Core/Interfaces/Repositories/IPowderUsageLogRepository.cs create mode 100644 src/PowderCoating.Core/Interfaces/Services/IAuditLogService.cs create mode 100644 src/PowderCoating.Core/Interfaces/Services/ICompanyDataPurgeService.cs create mode 100644 src/PowderCoating.Core/Interfaces/Services/ICompanyListService.cs create mode 100644 src/PowderCoating.Core/Interfaces/Services/IDashboardReadService.cs create mode 100644 src/PowderCoating.Infrastructure/Repositories/InventoryTransactionRepository.cs create mode 100644 src/PowderCoating.Infrastructure/Repositories/JobItemCoatRepository.cs create mode 100644 src/PowderCoating.Infrastructure/Repositories/JobPhotoRepository.cs create mode 100644 src/PowderCoating.Infrastructure/Repositories/JobTemplateRepository.cs create mode 100644 src/PowderCoating.Infrastructure/Repositories/PlainRepository.cs create mode 100644 src/PowderCoating.Infrastructure/Repositories/PowderUsageLogRepository.cs create mode 100644 src/PowderCoating.Infrastructure/Services/AiUsageReportService.cs create mode 100644 src/PowderCoating.Infrastructure/Services/AuditLogService.cs create mode 100644 src/PowderCoating.Infrastructure/Services/CompanyDataPurgeService.cs create mode 100644 src/PowderCoating.Infrastructure/Services/CompanyListService.cs create mode 100644 src/PowderCoating.Infrastructure/Services/DashboardReadService.cs diff --git a/CLAUDE.md b/CLAUDE.md index 27ed08f..b4e9de0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -126,7 +126,9 @@ Company Admin (seed): demo@powdercoatinglogix.com / CompanyAdmin123! > **`ApplicationDbContext` is NEVER injected into a controller.** > All data access in controllers goes through `IUnitOfWork`. No exceptions outside the list below. -> Full rationale and migration roadmap: `docs/DATA_ACCESS_ARCHITECTURE.md` +> **This rule is enforced at startup:** `EnforceDataAccessArchitecture()` in `Program.cs` scans all +> controllers at boot and throws if any non-exempt controller injects `ApplicationDbContext`. +> Full rationale and permanent exceptions list: `docs/DATA_ACCESS_ARCHITECTURE.md` ### Three tiers — use the right one: diff --git a/docs/DATA_ACCESS_ARCHITECTURE.md b/docs/DATA_ACCESS_ARCHITECTURE.md index 3d97f98..cd68102 100644 --- a/docs/DATA_ACCESS_ARCHITECTURE.md +++ b/docs/DATA_ACCESS_ARCHITECTURE.md @@ -1,6 +1,6 @@ # Data Access Architecture -## Status: Migration In Progress +## Status: Complete ✓ (2026-04-28) This document defines the target data access architecture for Powder Coating Logix and tracks the migration from the current mixed pattern to the clean layered pattern. @@ -213,6 +213,14 @@ This is not a smell — it is correct for their use cases. Each file has a comme | `SystemInfoController` | Infrastructure diagnostics; queries metadata, not business data | | `SystemLogsController` | Log table queries; not a business entity | | `CompanyHealthController` | Cross-tenant health checks for SuperAdmin; ignores all filters | +| `PasskeyController` | WebAuthn/FIDO2 identity infrastructure; UserPasskeys is an ASP.NET Identity concern outside IUnitOfWork; anonymous login path has no tenant context | +| `AuditLogController` | Append-only audit log with `long` PK; platform infrastructure table outside the business entity graph; same reasoning as `SystemLogsController` | +| `UserActivityController` | Queries ASP.NET Identity `ApplicationUser` across all tenants with `Include(u => u.Company)`; Identity entities live outside IUnitOfWork | +| `EmailBroadcastController` | Cross-tenant fan-out querying ASP.NET Identity Users table with company joins; Identity entities live outside IUnitOfWork | +| `RevenueController` | Cross-tenant MRR/ARR metrics joining Company + SubscriptionPlanConfig; same pattern as `CompanyHealthController` | +| `StripeEventsController` | `StripeWebhookEvents` is a platform infrastructure table, not a business entity; same reasoning as `StripeWebhookController` | +| `SubscriptionManagementController` | Cross-tenant Company management with raw SQL audit log writes that bypass the tenant pipeline; platform-level concern | +| `UsageQuotaController` | Cross-tenant bulk GROUP BY quota queries; routing through IUnitOfWork would require O(n) repository round-trips | If you think you need to add a controller to this list, you almost certainly don't. Ask first. @@ -232,57 +240,59 @@ If you think you need to add a controller to this list, you almost certainly don - [ ] Register all new types in `Program.cs` - [ ] Build passes, all tests green — no controller has changed yet -### Phase 2 — Complex controller migration -- [ ] `InvoicesController` → `IInvoiceRepository` -- [ ] `JobsController` → `IJobRepository` -- [ ] `QuotesController` → `IQuoteRepository` -- [ ] `CustomersController` → `ICustomerRepository` -- [ ] `BillsController` → `IBillRepository` -- [ ] `PurchaseOrdersController` → `IPurchaseOrderRepository` -- [ ] `ReportsController` → `IFinancialReportService` + `IOperationalReportService` +### Phase 2 — Complex controller migration ✓ COMPLETE (2026-04-27) +- [x] `InvoicesController` → `IInvoiceRepository` +- [x] `JobsController` → `IJobRepository` +- [x] `QuotesController` → `IQuoteRepository` +- [x] `CustomersController` → `ICustomerRepository` +- [x] `BillsController` → `IBillRepository` +- [x] `PurchaseOrdersController` → `IPurchaseOrderRepository` +- [x] `ReportsController` → `IFinancialReportService` + `IOperationalReportService` -### Phase 3 — Simple controller sweep +### Phase 3 — Simple controller sweep ✓ COMPLETE (2026-04-28) Remove `ApplicationDbContext` injection from all controllers not in the permanent exceptions list, replacing with existing `IUnitOfWork` generic repository calls. -- [ ] `AnnouncementsController` -- [ ] `AiQuickQuoteController` -- [ ] `AiUsageReportController` -- [ ] `AuditLogController` -- [ ] `BannedIpsController` -- [ ] `BugReportController` -- [ ] `CompaniesController` -- [ ] `CompanySettingsController` -- [ ] `CompanyUsersController` -- [ ] `DashboardController` -- [ ] `DashboardTipsController` -- [ ] `DepositsController` -- [ ] `EmailBroadcastController` -- [ ] `ExpensesController` -- [ ] `InAppNotificationsController` -- [ ] `InventoryController` -- [ ] `JobsPriorityController` -- [ ] `JobTemplatesController` -- [ ] `NotificationLogsController` -- [ ] `PasskeyController` -- [ ] `PlatformNotificationsController` -- [ ] `QuoteApprovalController` -- [ ] `ReleaseNotesController` -- [ ] `RevenueController` -- [ ] `SetupWizardController` -- [ ] `SmsConsentAuditController` -- [ ] `StripeEventsController` -- [ ] `SubscriptionManagementController` -- [ ] `UnsubscribeController` -- [ ] `UsageQuotaController` -- [ ] `UserActivityController` -- [ ] `VendorsController` +- [x] `AnnouncementsController` +- [x] `AiQuickQuoteController` +- [x] `AiUsageReportController` +- [x] `AuditLogController` → permanent exception (Identity/platform infra) +- [x] `BannedIpsController` +- [x] `BugReportController` +- [x] `CompaniesController` +- [x] `CompanySettingsController` +- [x] `CompanyUsersController` +- [x] `DashboardController` +- [x] `DashboardTipsController` +- [x] `DepositsController` +- [x] `EmailBroadcastController` → permanent exception (Identity fan-out) +- [x] `ExpensesController` +- [x] `InAppNotificationsController` +- [x] `InventoryController` +- [x] `JobsPriorityController` +- [x] `JobTemplatesController` +- [x] `NotificationLogsController` +- [x] `PasskeyController` → permanent exception (WebAuthn/FIDO2 identity infra) +- [x] `PlatformNotificationsController` +- [x] `QuoteApprovalController` +- [x] `ReleaseNotesController` +- [x] `RevenueController` → permanent exception (cross-tenant MRR/ARR) +- [x] `SetupWizardController` +- [x] `SmsConsentAuditController` +- [x] `StripeEventsController` → permanent exception (platform infra table) +- [x] `SubscriptionManagementController` → permanent exception (platform-level cross-tenant) +- [x] `UnsubscribeController` +- [x] `UsageQuotaController` → permanent exception (bulk GROUP BY) +- [x] `UserActivityController` → permanent exception (Identity entities) +- [x] `VendorsController` -### Phase 4 — Enforcement -- [ ] Remove `ApplicationDbContext` from controller DI scope in `Program.cs` - (controllers that still need it will get a compile error — the compiler enforces the rule) -- [ ] Update `CLAUDE.md` to mark migration complete -- [ ] Update this document status from "Migration In Progress" to "Complete" +### Phase 4 — Enforcement ✓ COMPLETE (2026-04-28) +- [x] `EnforceDataAccessArchitecture()` added to `Program.cs` — scans all Controller subclasses at + startup via reflection and throws `InvalidOperationException` if any non-exempt controller + has `ApplicationDbContext` in its constructor. The app cannot start with a violation. +- [x] Permanent exceptions list hardcoded in the enforcement function (18 controllers). +- [x] This document status updated to Complete. +- [ ] Update `CLAUDE.md` to mark migration complete (optional — CLAUDE.md already reflects the rule) --- diff --git a/scripts/042626_deploy_migration.sql b/scripts/042626_deploy_migration.sql new file mode 100644 index 0000000..c24b04f --- /dev/null +++ b/scripts/042626_deploy_migration.sql @@ -0,0 +1,6219 @@ +IF OBJECT_ID(N'[__EFMigrationsHistory]') IS NULL +BEGIN + CREATE TABLE [__EFMigrationsHistory] ( + [MigrationId] nvarchar(150) NOT NULL, + [ProductVersion] nvarchar(32) NOT NULL, + CONSTRAINT [PK___EFMigrationsHistory] PRIMARY KEY ([MigrationId]) + ); +END; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260316155002_Baseline' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-16T15:49:58.7377851Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260316155002_Baseline' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-16T15:49:58.7377856Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260316155002_Baseline' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-16T15:49:58.7377858Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260316155002_Baseline' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260316155002_Baseline', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260317121938_AddAiContextProfile' +) +BEGIN + ALTER TABLE [CompanyOperatingCosts] ADD [AiContextProfile] nvarchar(2000) NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260317121938_AddAiContextProfile' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-17T12:19:34.4894690Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260317121938_AddAiContextProfile' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-17T12:19:34.4894696Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260317121938_AddAiContextProfile' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-17T12:19:34.4894698Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260317121938_AddAiContextProfile' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260317121938_AddAiContextProfile', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260317205927_FixLaborItemQuantityDecimal' +) +BEGIN + DECLARE @var0 sysname; + SELECT @var0 = [d].[name] + FROM [sys].[default_constraints] [d] + INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id] + WHERE ([d].[parent_object_id] = OBJECT_ID(N'[QuoteItems]') AND [c].[name] = N'Quantity'); + IF @var0 IS NOT NULL EXEC(N'ALTER TABLE [QuoteItems] DROP CONSTRAINT [' + @var0 + '];'); + ALTER TABLE [QuoteItems] ALTER COLUMN [Quantity] decimal(18,2) NOT NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260317205927_FixLaborItemQuantityDecimal' +) +BEGIN + DECLARE @var1 sysname; + SELECT @var1 = [d].[name] + FROM [sys].[default_constraints] [d] + INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id] + WHERE ([d].[parent_object_id] = OBJECT_ID(N'[JobItems]') AND [c].[name] = N'Quantity'); + IF @var1 IS NOT NULL EXEC(N'ALTER TABLE [JobItems] DROP CONSTRAINT [' + @var1 + '];'); + ALTER TABLE [JobItems] ALTER COLUMN [Quantity] decimal(18,2) NOT NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260317205927_FixLaborItemQuantityDecimal' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-17T20:59:24.2463737Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260317205927_FixLaborItemQuantityDecimal' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-17T20:59:24.2463746Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260317205927_FixLaborItemQuantityDecimal' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-17T20:59:24.2463748Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260317205927_FixLaborItemQuantityDecimal' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260317205927_FixLaborItemQuantityDecimal', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318124847_AddJobTimeEntries' +) +BEGIN + CREATE TABLE [JobTimeEntries] ( + [Id] int NOT NULL IDENTITY, + [JobId] int NOT NULL, + [ShopWorkerId] int NOT NULL, + [WorkDate] datetime2 NOT NULL, + [HoursWorked] decimal(18,2) NOT NULL, + [Stage] nvarchar(max) NULL, + [Notes] nvarchar(max) NULL, + [CompanyId] int NOT NULL, + [CreatedAt] datetime2 NOT NULL, + [UpdatedAt] datetime2 NULL, + [CreatedBy] nvarchar(max) NULL, + [UpdatedBy] nvarchar(max) NULL, + [IsDeleted] bit NOT NULL, + [DeletedAt] datetime2 NULL, + [DeletedBy] nvarchar(max) NULL, + CONSTRAINT [PK_JobTimeEntries] PRIMARY KEY ([Id]), + CONSTRAINT [FK_JobTimeEntries_Jobs_JobId] FOREIGN KEY ([JobId]) REFERENCES [Jobs] ([Id]) ON DELETE CASCADE, + CONSTRAINT [FK_JobTimeEntries_ShopWorkers_ShopWorkerId] FOREIGN KEY ([ShopWorkerId]) REFERENCES [ShopWorkers] ([Id]) ON DELETE CASCADE + ); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318124847_AddJobTimeEntries' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-18T12:48:44.7462691Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318124847_AddJobTimeEntries' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-18T12:48:44.7462697Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318124847_AddJobTimeEntries' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-18T12:48:44.7462699Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318124847_AddJobTimeEntries' +) +BEGIN + CREATE INDEX [IX_JobTimeEntries_JobId] ON [JobTimeEntries] ([JobId]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318124847_AddJobTimeEntries' +) +BEGIN + CREATE INDEX [IX_JobTimeEntries_ShopWorkerId] ON [JobTimeEntries] ([ShopWorkerId]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318124847_AddJobTimeEntries' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260318124847_AddJobTimeEntries', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318131500_AddJobShopAccessCode' +) +BEGIN + ALTER TABLE [Jobs] ADD [ShopAccessCode] uniqueidentifier NOT NULL DEFAULT (NEWID()); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318131500_AddJobShopAccessCode' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-18T13:14:57.2203832Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318131500_AddJobShopAccessCode' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-18T13:14:57.2203838Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318131500_AddJobShopAccessCode' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-18T13:14:57.2203839Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318131500_AddJobShopAccessCode' +) +BEGIN + CREATE UNIQUE INDEX [IX_Jobs_ShopAccessCode] ON [Jobs] ([ShopAccessCode]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318131500_AddJobShopAccessCode' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260318131500_AddJobShopAccessCode', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318132857_AddShopWorkerRoleCosts' +) +BEGIN + CREATE TABLE [ShopWorkerRoleCosts] ( + [Id] int NOT NULL IDENTITY, + [Role] int NOT NULL, + [HourlyRate] decimal(18,2) NOT NULL, + [CompanyId] int NOT NULL, + [CreatedAt] datetime2 NOT NULL, + [UpdatedAt] datetime2 NULL, + [CreatedBy] nvarchar(max) NULL, + [UpdatedBy] nvarchar(max) NULL, + [IsDeleted] bit NOT NULL, + [DeletedAt] datetime2 NULL, + [DeletedBy] nvarchar(max) NULL, + CONSTRAINT [PK_ShopWorkerRoleCosts] PRIMARY KEY ([Id]) + ); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318132857_AddShopWorkerRoleCosts' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-18T13:28:54.6854802Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318132857_AddShopWorkerRoleCosts' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-18T13:28:54.6854849Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318132857_AddShopWorkerRoleCosts' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-18T13:28:54.6854851Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318132857_AddShopWorkerRoleCosts' +) +BEGIN + CREATE UNIQUE INDEX [IX_ShopWorkerRoleCosts_CompanyId_Role] ON [ShopWorkerRoleCosts] ([CompanyId], [Role]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318132857_AddShopWorkerRoleCosts' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260318132857_AddShopWorkerRoleCosts', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318134236_AddReworkTracking' +) +BEGIN + ALTER TABLE [Jobs] ADD [IsReworkJob] bit NOT NULL DEFAULT CAST(0 AS bit); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318134236_AddReworkTracking' +) +BEGIN + ALTER TABLE [Jobs] ADD [OriginalJobId] int NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318134236_AddReworkTracking' +) +BEGIN + CREATE TABLE [ReworkRecords] ( + [Id] int NOT NULL IDENTITY, + [JobId] int NOT NULL, + [JobItemId] int NULL, + [ReworkJobId] int NULL, + [ReworkType] int NOT NULL, + [Reason] int NOT NULL, + [DefectDescription] nvarchar(max) NOT NULL, + [DiscoveredBy] int NOT NULL, + [DiscoveredDate] datetime2 NOT NULL, + [ReportedByName] nvarchar(max) NULL, + [EstimatedReworkCost] decimal(18,2) NOT NULL, + [ActualReworkCost] decimal(18,2) NOT NULL, + [IsBillableToCustomer] bit NOT NULL, + [BillingNotes] nvarchar(max) NULL, + [Status] int NOT NULL, + [Resolution] int NULL, + [ResolvedDate] datetime2 NULL, + [ResolutionNotes] nvarchar(max) NULL, + [CompanyId] int NOT NULL, + [CreatedAt] datetime2 NOT NULL, + [UpdatedAt] datetime2 NULL, + [CreatedBy] nvarchar(max) NULL, + [UpdatedBy] nvarchar(max) NULL, + [IsDeleted] bit NOT NULL, + [DeletedAt] datetime2 NULL, + [DeletedBy] nvarchar(max) NULL, + CONSTRAINT [PK_ReworkRecords] PRIMARY KEY ([Id]), + CONSTRAINT [FK_ReworkRecords_JobItems_JobItemId] FOREIGN KEY ([JobItemId]) REFERENCES [JobItems] ([Id]), + CONSTRAINT [FK_ReworkRecords_Jobs_JobId] FOREIGN KEY ([JobId]) REFERENCES [Jobs] ([Id]) ON DELETE NO ACTION, + CONSTRAINT [FK_ReworkRecords_Jobs_ReworkJobId] FOREIGN KEY ([ReworkJobId]) REFERENCES [Jobs] ([Id]) + ); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318134236_AddReworkTracking' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-18T13:42:32.9092998Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318134236_AddReworkTracking' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-18T13:42:32.9093003Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318134236_AddReworkTracking' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-18T13:42:32.9093005Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318134236_AddReworkTracking' +) +BEGIN + CREATE INDEX [IX_Jobs_OriginalJobId] ON [Jobs] ([OriginalJobId]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318134236_AddReworkTracking' +) +BEGIN + CREATE INDEX [IX_ReworkRecords_JobId] ON [ReworkRecords] ([JobId]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318134236_AddReworkTracking' +) +BEGIN + CREATE INDEX [IX_ReworkRecords_JobItemId] ON [ReworkRecords] ([JobItemId]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318134236_AddReworkTracking' +) +BEGIN + CREATE INDEX [IX_ReworkRecords_ReworkJobId] ON [ReworkRecords] ([ReworkJobId]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318134236_AddReworkTracking' +) +BEGIN + ALTER TABLE [Jobs] ADD CONSTRAINT [FK_Jobs_Jobs_OriginalJobId] FOREIGN KEY ([OriginalJobId]) REFERENCES [Jobs] ([Id]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318134236_AddReworkTracking' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260318134236_AddReworkTracking', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318222648_AddRefundsAndCreditMemos' +) +BEGIN + ALTER TABLE [Invoices] ADD [CreditApplied] decimal(18,2) NOT NULL DEFAULT 0.0; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318222648_AddRefundsAndCreditMemos' +) +BEGIN + ALTER TABLE [Customers] ADD [CreditBalance] decimal(18,2) NOT NULL DEFAULT 0.0; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318222648_AddRefundsAndCreditMemos' +) +BEGIN + CREATE TABLE [CreditMemos] ( + [Id] int NOT NULL IDENTITY, + [MemoNumber] nvarchar(450) NOT NULL, + [CustomerId] int NOT NULL, + [OriginalInvoiceId] int NULL, + [ReworkRecordId] int NULL, + [Amount] decimal(18,2) NOT NULL, + [AmountApplied] decimal(18,2) NOT NULL, + [IssueDate] datetime2 NOT NULL, + [ExpiryDate] datetime2 NULL, + [Reason] nvarchar(max) NOT NULL, + [Notes] nvarchar(max) NULL, + [Status] int NOT NULL, + [IssuedById] nvarchar(450) NULL, + [CompanyId] int NOT NULL, + [CreatedAt] datetime2 NOT NULL, + [UpdatedAt] datetime2 NULL, + [CreatedBy] nvarchar(max) NULL, + [UpdatedBy] nvarchar(max) NULL, + [IsDeleted] bit NOT NULL, + [DeletedAt] datetime2 NULL, + [DeletedBy] nvarchar(max) NULL, + CONSTRAINT [PK_CreditMemos] PRIMARY KEY ([Id]), + CONSTRAINT [FK_CreditMemos_AspNetUsers_IssuedById] FOREIGN KEY ([IssuedById]) REFERENCES [AspNetUsers] ([Id]), + CONSTRAINT [FK_CreditMemos_Customers_CustomerId] FOREIGN KEY ([CustomerId]) REFERENCES [Customers] ([Id]) ON DELETE NO ACTION, + CONSTRAINT [FK_CreditMemos_Invoices_OriginalInvoiceId] FOREIGN KEY ([OriginalInvoiceId]) REFERENCES [Invoices] ([Id]), + CONSTRAINT [FK_CreditMemos_ReworkRecords_ReworkRecordId] FOREIGN KEY ([ReworkRecordId]) REFERENCES [ReworkRecords] ([Id]) + ); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318222648_AddRefundsAndCreditMemos' +) +BEGIN + CREATE TABLE [Refunds] ( + [Id] int NOT NULL IDENTITY, + [InvoiceId] int NOT NULL, + [PaymentId] int NULL, + [Amount] decimal(18,2) NOT NULL, + [RefundDate] datetime2 NOT NULL, + [RefundMethod] int NOT NULL, + [Reason] nvarchar(max) NOT NULL, + [Reference] nvarchar(max) NULL, + [Notes] nvarchar(max) NULL, + [Status] int NOT NULL, + [IssuedDate] datetime2 NULL, + [IssuedById] nvarchar(450) NULL, + [CompanyId] int NOT NULL, + [CreatedAt] datetime2 NOT NULL, + [UpdatedAt] datetime2 NULL, + [CreatedBy] nvarchar(max) NULL, + [UpdatedBy] nvarchar(max) NULL, + [IsDeleted] bit NOT NULL, + [DeletedAt] datetime2 NULL, + [DeletedBy] nvarchar(max) NULL, + CONSTRAINT [PK_Refunds] PRIMARY KEY ([Id]), + CONSTRAINT [FK_Refunds_AspNetUsers_IssuedById] FOREIGN KEY ([IssuedById]) REFERENCES [AspNetUsers] ([Id]), + CONSTRAINT [FK_Refunds_Invoices_InvoiceId] FOREIGN KEY ([InvoiceId]) REFERENCES [Invoices] ([Id]) ON DELETE NO ACTION, + CONSTRAINT [FK_Refunds_Payments_PaymentId] FOREIGN KEY ([PaymentId]) REFERENCES [Payments] ([Id]) + ); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318222648_AddRefundsAndCreditMemos' +) +BEGIN + CREATE TABLE [CreditMemoApplications] ( + [Id] int NOT NULL IDENTITY, + [CreditMemoId] int NOT NULL, + [InvoiceId] int NOT NULL, + [AmountApplied] decimal(18,2) NOT NULL, + [AppliedDate] datetime2 NOT NULL, + [AppliedById] nvarchar(450) NULL, + [CompanyId] int NOT NULL, + [CreatedAt] datetime2 NOT NULL, + [UpdatedAt] datetime2 NULL, + [CreatedBy] nvarchar(max) NULL, + [UpdatedBy] nvarchar(max) NULL, + [IsDeleted] bit NOT NULL, + [DeletedAt] datetime2 NULL, + [DeletedBy] nvarchar(max) NULL, + CONSTRAINT [PK_CreditMemoApplications] PRIMARY KEY ([Id]), + CONSTRAINT [FK_CreditMemoApplications_AspNetUsers_AppliedById] FOREIGN KEY ([AppliedById]) REFERENCES [AspNetUsers] ([Id]), + CONSTRAINT [FK_CreditMemoApplications_CreditMemos_CreditMemoId] FOREIGN KEY ([CreditMemoId]) REFERENCES [CreditMemos] ([Id]) ON DELETE NO ACTION, + CONSTRAINT [FK_CreditMemoApplications_Invoices_InvoiceId] FOREIGN KEY ([InvoiceId]) REFERENCES [Invoices] ([Id]) ON DELETE NO ACTION + ); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318222648_AddRefundsAndCreditMemos' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-18T22:26:44.9349567Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318222648_AddRefundsAndCreditMemos' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-18T22:26:44.9349573Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318222648_AddRefundsAndCreditMemos' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-18T22:26:44.9349575Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318222648_AddRefundsAndCreditMemos' +) +BEGIN + CREATE INDEX [IX_CreditMemoApplications_AppliedById] ON [CreditMemoApplications] ([AppliedById]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318222648_AddRefundsAndCreditMemos' +) +BEGIN + CREATE INDEX [IX_CreditMemoApplications_CreditMemoId] ON [CreditMemoApplications] ([CreditMemoId]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318222648_AddRefundsAndCreditMemos' +) +BEGIN + CREATE INDEX [IX_CreditMemoApplications_InvoiceId] ON [CreditMemoApplications] ([InvoiceId]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318222648_AddRefundsAndCreditMemos' +) +BEGIN + CREATE UNIQUE INDEX [IX_CreditMemos_CompanyId_MemoNumber] ON [CreditMemos] ([CompanyId], [MemoNumber]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318222648_AddRefundsAndCreditMemos' +) +BEGIN + CREATE INDEX [IX_CreditMemos_CustomerId] ON [CreditMemos] ([CustomerId]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318222648_AddRefundsAndCreditMemos' +) +BEGIN + CREATE INDEX [IX_CreditMemos_IssuedById] ON [CreditMemos] ([IssuedById]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318222648_AddRefundsAndCreditMemos' +) +BEGIN + CREATE INDEX [IX_CreditMemos_OriginalInvoiceId] ON [CreditMemos] ([OriginalInvoiceId]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318222648_AddRefundsAndCreditMemos' +) +BEGIN + CREATE INDEX [IX_CreditMemos_ReworkRecordId] ON [CreditMemos] ([ReworkRecordId]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318222648_AddRefundsAndCreditMemos' +) +BEGIN + CREATE INDEX [IX_Refunds_InvoiceId] ON [Refunds] ([InvoiceId]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318222648_AddRefundsAndCreditMemos' +) +BEGIN + CREATE INDEX [IX_Refunds_IssuedById] ON [Refunds] ([IssuedById]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318222648_AddRefundsAndCreditMemos' +) +BEGIN + CREATE INDEX [IX_Refunds_PaymentId] ON [Refunds] ([PaymentId]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260318222648_AddRefundsAndCreditMemos' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260318222648_AddRefundsAndCreditMemos', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260319023827_AddJobTemplates' +) +BEGIN + CREATE TABLE [JobTemplates] ( + [Id] int NOT NULL IDENTITY, + [Name] nvarchar(max) NOT NULL, + [Description] nvarchar(max) NULL, + [CustomerId] int NULL, + [SpecialInstructions] nvarchar(max) NULL, + [IsActive] bit NOT NULL, + [UsageCount] int NOT NULL, + [CompanyId] int NOT NULL, + [CreatedAt] datetime2 NOT NULL, + [UpdatedAt] datetime2 NULL, + [CreatedBy] nvarchar(max) NULL, + [UpdatedBy] nvarchar(max) NULL, + [IsDeleted] bit NOT NULL, + [DeletedAt] datetime2 NULL, + [DeletedBy] nvarchar(max) NULL, + CONSTRAINT [PK_JobTemplates] PRIMARY KEY ([Id]), + CONSTRAINT [FK_JobTemplates_Customers_CustomerId] FOREIGN KEY ([CustomerId]) REFERENCES [Customers] ([Id]) + ); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260319023827_AddJobTemplates' +) +BEGIN + CREATE TABLE [JobTemplateItems] ( + [Id] int NOT NULL IDENTITY, + [JobTemplateId] int NOT NULL, + [Description] nvarchar(max) NOT NULL, + [Quantity] decimal(18,2) NOT NULL, + [SurfaceAreaSqFt] decimal(18,2) NOT NULL, + [CatalogItemId] int NULL, + [IsGenericItem] bit NOT NULL, + [IsLaborItem] bit NOT NULL, + [ManualUnitPrice] decimal(18,2) NULL, + [RequiresSandblasting] bit NOT NULL, + [RequiresMasking] bit NOT NULL, + [IncludePrepCost] bit NOT NULL, + [EstimatedMinutes] int NOT NULL, + [Complexity] nvarchar(max) NULL, + [Notes] nvarchar(max) NULL, + [DisplayOrder] int NOT NULL, + [CompanyId] int NOT NULL, + [CreatedAt] datetime2 NOT NULL, + [UpdatedAt] datetime2 NULL, + [CreatedBy] nvarchar(max) NULL, + [UpdatedBy] nvarchar(max) NULL, + [IsDeleted] bit NOT NULL, + [DeletedAt] datetime2 NULL, + [DeletedBy] nvarchar(max) NULL, + CONSTRAINT [PK_JobTemplateItems] PRIMARY KEY ([Id]), + CONSTRAINT [FK_JobTemplateItems_CatalogItems_CatalogItemId] FOREIGN KEY ([CatalogItemId]) REFERENCES [CatalogItems] ([Id]), + CONSTRAINT [FK_JobTemplateItems_JobTemplates_JobTemplateId] FOREIGN KEY ([JobTemplateId]) REFERENCES [JobTemplates] ([Id]) ON DELETE CASCADE + ); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260319023827_AddJobTemplates' +) +BEGIN + CREATE TABLE [JobTemplateItemCoats] ( + [Id] int NOT NULL IDENTITY, + [JobTemplateItemId] int NOT NULL, + [CoatName] nvarchar(max) NOT NULL, + [Sequence] int NOT NULL, + [InventoryItemId] int NULL, + [ColorName] nvarchar(max) NULL, + [VendorId] int NULL, + [ColorCode] nvarchar(max) NULL, + [Finish] nvarchar(max) NULL, + [CoverageSqFtPerLb] decimal(18,2) NOT NULL, + [TransferEfficiency] decimal(18,2) NOT NULL, + [PowderCostPerLb] decimal(18,2) NULL, + [Notes] nvarchar(max) NULL, + [CompanyId] int NOT NULL, + [CreatedAt] datetime2 NOT NULL, + [UpdatedAt] datetime2 NULL, + [CreatedBy] nvarchar(max) NULL, + [UpdatedBy] nvarchar(max) NULL, + [IsDeleted] bit NOT NULL, + [DeletedAt] datetime2 NULL, + [DeletedBy] nvarchar(max) NULL, + CONSTRAINT [PK_JobTemplateItemCoats] PRIMARY KEY ([Id]), + CONSTRAINT [FK_JobTemplateItemCoats_InventoryItems_InventoryItemId] FOREIGN KEY ([InventoryItemId]) REFERENCES [InventoryItems] ([Id]), + CONSTRAINT [FK_JobTemplateItemCoats_JobTemplateItems_JobTemplateItemId] FOREIGN KEY ([JobTemplateItemId]) REFERENCES [JobTemplateItems] ([Id]) ON DELETE CASCADE, + CONSTRAINT [FK_JobTemplateItemCoats_Vendors_VendorId] FOREIGN KEY ([VendorId]) REFERENCES [Vendors] ([Id]) + ); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260319023827_AddJobTemplates' +) +BEGIN + CREATE TABLE [JobTemplateItemPrepServices] ( + [Id] int NOT NULL IDENTITY, + [JobTemplateItemId] int NOT NULL, + [PrepServiceId] int NOT NULL, + [EstimatedMinutes] int NOT NULL, + [CompanyId] int NOT NULL, + [CreatedAt] datetime2 NOT NULL, + [UpdatedAt] datetime2 NULL, + [CreatedBy] nvarchar(max) NULL, + [UpdatedBy] nvarchar(max) NULL, + [IsDeleted] bit NOT NULL, + [DeletedAt] datetime2 NULL, + [DeletedBy] nvarchar(max) NULL, + CONSTRAINT [PK_JobTemplateItemPrepServices] PRIMARY KEY ([Id]), + CONSTRAINT [FK_JobTemplateItemPrepServices_JobTemplateItems_JobTemplateItemId] FOREIGN KEY ([JobTemplateItemId]) REFERENCES [JobTemplateItems] ([Id]) ON DELETE CASCADE, + CONSTRAINT [FK_JobTemplateItemPrepServices_PrepServices_PrepServiceId] FOREIGN KEY ([PrepServiceId]) REFERENCES [PrepServices] ([Id]) ON DELETE CASCADE + ); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260319023827_AddJobTemplates' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-19T02:38:23.4195291Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260319023827_AddJobTemplates' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-19T02:38:23.4195296Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260319023827_AddJobTemplates' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-19T02:38:23.4195298Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260319023827_AddJobTemplates' +) +BEGIN + CREATE INDEX [IX_JobTemplateItemCoats_InventoryItemId] ON [JobTemplateItemCoats] ([InventoryItemId]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260319023827_AddJobTemplates' +) +BEGIN + CREATE INDEX [IX_JobTemplateItemCoats_JobTemplateItemId] ON [JobTemplateItemCoats] ([JobTemplateItemId]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260319023827_AddJobTemplates' +) +BEGIN + CREATE INDEX [IX_JobTemplateItemCoats_VendorId] ON [JobTemplateItemCoats] ([VendorId]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260319023827_AddJobTemplates' +) +BEGIN + CREATE INDEX [IX_JobTemplateItemPrepServices_JobTemplateItemId] ON [JobTemplateItemPrepServices] ([JobTemplateItemId]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260319023827_AddJobTemplates' +) +BEGIN + CREATE INDEX [IX_JobTemplateItemPrepServices_PrepServiceId] ON [JobTemplateItemPrepServices] ([PrepServiceId]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260319023827_AddJobTemplates' +) +BEGIN + CREATE INDEX [IX_JobTemplateItems_CatalogItemId] ON [JobTemplateItems] ([CatalogItemId]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260319023827_AddJobTemplates' +) +BEGIN + CREATE INDEX [IX_JobTemplateItems_JobTemplateId] ON [JobTemplateItems] ([JobTemplateId]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260319023827_AddJobTemplates' +) +BEGIN + CREATE INDEX [IX_JobTemplates_CustomerId] ON [JobTemplates] ([CustomerId]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260319023827_AddJobTemplates' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260319023827_AddJobTemplates', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260319154506_AddGiftCertificates' +) +BEGIN + ALTER TABLE [Invoices] ADD [GiftCertificateRedeemed] decimal(18,2) NOT NULL DEFAULT 0.0; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260319154506_AddGiftCertificates' +) +BEGIN + CREATE TABLE [GiftCertificates] ( + [Id] int NOT NULL IDENTITY, + [CertificateCode] nvarchar(max) NOT NULL, + [OriginalAmount] decimal(18,2) NOT NULL, + [RedeemedAmount] decimal(18,2) NOT NULL, + [RecipientCustomerId] int NULL, + [RecipientName] nvarchar(max) NULL, + [RecipientEmail] nvarchar(max) NULL, + [IssuedReason] int NOT NULL, + [PurchasePrice] decimal(18,2) NULL, + [PurchasingCustomerId] int NULL, + [Status] int NOT NULL, + [IssueDate] datetime2 NOT NULL, + [ExpiryDate] datetime2 NULL, + [Notes] nvarchar(max) NULL, + [IssuedById] nvarchar(450) NULL, + [CompanyId] int NOT NULL, + [CreatedAt] datetime2 NOT NULL, + [UpdatedAt] datetime2 NULL, + [CreatedBy] nvarchar(max) NULL, + [UpdatedBy] nvarchar(max) NULL, + [IsDeleted] bit NOT NULL, + [DeletedAt] datetime2 NULL, + [DeletedBy] nvarchar(max) NULL, + CONSTRAINT [PK_GiftCertificates] PRIMARY KEY ([Id]), + CONSTRAINT [FK_GiftCertificates_AspNetUsers_IssuedById] FOREIGN KEY ([IssuedById]) REFERENCES [AspNetUsers] ([Id]), + CONSTRAINT [FK_GiftCertificates_Customers_PurchasingCustomerId] FOREIGN KEY ([PurchasingCustomerId]) REFERENCES [Customers] ([Id]), + CONSTRAINT [FK_GiftCertificates_Customers_RecipientCustomerId] FOREIGN KEY ([RecipientCustomerId]) REFERENCES [Customers] ([Id]) + ); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260319154506_AddGiftCertificates' +) +BEGIN + CREATE TABLE [GiftCertificateRedemptions] ( + [Id] int NOT NULL IDENTITY, + [GiftCertificateId] int NOT NULL, + [InvoiceId] int NOT NULL, + [AmountRedeemed] decimal(18,2) NOT NULL, + [RedeemedDate] datetime2 NOT NULL, + [RedeemedById] nvarchar(450) NULL, + [CompanyId] int NOT NULL, + [CreatedAt] datetime2 NOT NULL, + [UpdatedAt] datetime2 NULL, + [CreatedBy] nvarchar(max) NULL, + [UpdatedBy] nvarchar(max) NULL, + [IsDeleted] bit NOT NULL, + [DeletedAt] datetime2 NULL, + [DeletedBy] nvarchar(max) NULL, + CONSTRAINT [PK_GiftCertificateRedemptions] PRIMARY KEY ([Id]), + CONSTRAINT [FK_GiftCertificateRedemptions_AspNetUsers_RedeemedById] FOREIGN KEY ([RedeemedById]) REFERENCES [AspNetUsers] ([Id]), + CONSTRAINT [FK_GiftCertificateRedemptions_GiftCertificates_GiftCertificateId] FOREIGN KEY ([GiftCertificateId]) REFERENCES [GiftCertificates] ([Id]) ON DELETE CASCADE, + CONSTRAINT [FK_GiftCertificateRedemptions_Invoices_InvoiceId] FOREIGN KEY ([InvoiceId]) REFERENCES [Invoices] ([Id]) ON DELETE CASCADE + ); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260319154506_AddGiftCertificates' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-19T15:45:03.1454465Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260319154506_AddGiftCertificates' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-19T15:45:03.1454472Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260319154506_AddGiftCertificates' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-19T15:45:03.1454474Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260319154506_AddGiftCertificates' +) +BEGIN + CREATE INDEX [IX_GiftCertificateRedemptions_GiftCertificateId] ON [GiftCertificateRedemptions] ([GiftCertificateId]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260319154506_AddGiftCertificates' +) +BEGIN + CREATE INDEX [IX_GiftCertificateRedemptions_InvoiceId] ON [GiftCertificateRedemptions] ([InvoiceId]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260319154506_AddGiftCertificates' +) +BEGIN + CREATE INDEX [IX_GiftCertificateRedemptions_RedeemedById] ON [GiftCertificateRedemptions] ([RedeemedById]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260319154506_AddGiftCertificates' +) +BEGIN + CREATE INDEX [IX_GiftCertificates_IssuedById] ON [GiftCertificates] ([IssuedById]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260319154506_AddGiftCertificates' +) +BEGIN + CREATE INDEX [IX_GiftCertificates_PurchasingCustomerId] ON [GiftCertificates] ([PurchasingCustomerId]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260319154506_AddGiftCertificates' +) +BEGIN + CREATE INDEX [IX_GiftCertificates_RecipientCustomerId] ON [GiftCertificates] ([RecipientCustomerId]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260319154506_AddGiftCertificates' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260319154506_AddGiftCertificates', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260320002450_AddRefundStoreCreditLink' +) +BEGIN + ALTER TABLE [Refunds] ADD [CreditMemoId] int NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260320002450_AddRefundStoreCreditLink' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-20T00:24:47.3611509Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260320002450_AddRefundStoreCreditLink' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-20T00:24:47.3611518Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260320002450_AddRefundStoreCreditLink' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-20T00:24:47.3611521Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260320002450_AddRefundStoreCreditLink' +) +BEGIN + CREATE INDEX [IX_Refunds_CreditMemoId] ON [Refunds] ([CreditMemoId]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260320002450_AddRefundStoreCreditLink' +) +BEGIN + ALTER TABLE [Refunds] ADD CONSTRAINT [FK_Refunds_CreditMemos_CreditMemoId] FOREIGN KEY ([CreditMemoId]) REFERENCES [CreditMemos] ([Id]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260320002450_AddRefundStoreCreditLink' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260320002450_AddRefundStoreCreditLink', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260320005106_AddQuoteItemIsAiItem' +) +BEGIN + ALTER TABLE [QuoteItems] ADD [IsAiItem] bit NOT NULL DEFAULT CAST(0 AS bit); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260320005106_AddQuoteItemIsAiItem' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-20T00:51:03.2423766Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260320005106_AddQuoteItemIsAiItem' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-20T00:51:03.2423772Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260320005106_AddQuoteItemIsAiItem' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-20T00:51:03.2423774Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260320005106_AddQuoteItemIsAiItem' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260320005106_AddQuoteItemIsAiItem', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260320011057_AddQuotePricingSnapshot' +) +BEGIN + ALTER TABLE [Quotes] ADD [ItemsSubtotal] decimal(18,2) NOT NULL DEFAULT 0.0; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260320011057_AddQuotePricingSnapshot' +) +BEGIN + ALTER TABLE [Quotes] ADD [OvenBatchCost] decimal(18,2) NOT NULL DEFAULT 0.0; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260320011057_AddQuotePricingSnapshot' +) +BEGIN + ALTER TABLE [Quotes] ADD [OverheadAmount] decimal(18,2) NOT NULL DEFAULT 0.0; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260320011057_AddQuotePricingSnapshot' +) +BEGIN + ALTER TABLE [Quotes] ADD [OverheadPercent] decimal(18,2) NOT NULL DEFAULT 0.0; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260320011057_AddQuotePricingSnapshot' +) +BEGIN + ALTER TABLE [Quotes] ADD [ProfitMargin] decimal(18,2) NOT NULL DEFAULT 0.0; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260320011057_AddQuotePricingSnapshot' +) +BEGIN + ALTER TABLE [Quotes] ADD [ProfitPercent] decimal(18,2) NOT NULL DEFAULT 0.0; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260320011057_AddQuotePricingSnapshot' +) +BEGIN + ALTER TABLE [Quotes] ADD [ShopSuppliesAmount] decimal(18,2) NOT NULL DEFAULT 0.0; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260320011057_AddQuotePricingSnapshot' +) +BEGIN + ALTER TABLE [Quotes] ADD [ShopSuppliesPercent] decimal(18,2) NOT NULL DEFAULT 0.0; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260320011057_AddQuotePricingSnapshot' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-20T01:10:54.1468159Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260320011057_AddQuotePricingSnapshot' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-20T01:10:54.1468166Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260320011057_AddQuotePricingSnapshot' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-20T01:10:54.1468176Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260320011057_AddQuotePricingSnapshot' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260320011057_AddQuotePricingSnapshot', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260320231509_AddStripeConnectAndOnlinePayments' +) +BEGIN + ALTER TABLE [SubscriptionPlanConfigs] ADD [AllowOnlinePayments] bit NOT NULL DEFAULT CAST(0 AS bit); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260320231509_AddStripeConnectAndOnlinePayments' +) +BEGIN + ALTER TABLE [Quotes] ADD [DepositPercent] decimal(18,2) NOT NULL DEFAULT 0.0; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260320231509_AddStripeConnectAndOnlinePayments' +) +BEGIN + ALTER TABLE [Quotes] ADD [RequiresDeposit] bit NOT NULL DEFAULT CAST(0 AS bit); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260320231509_AddStripeConnectAndOnlinePayments' +) +BEGIN + ALTER TABLE [Invoices] ADD [OnlineAmountPaid] decimal(18,2) NOT NULL DEFAULT 0.0; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260320231509_AddStripeConnectAndOnlinePayments' +) +BEGIN + ALTER TABLE [Invoices] ADD [OnlinePaymentStatus] int NOT NULL DEFAULT 0; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260320231509_AddStripeConnectAndOnlinePayments' +) +BEGIN + ALTER TABLE [Invoices] ADD [OnlineSurchargeCollected] decimal(18,2) NOT NULL DEFAULT 0.0; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260320231509_AddStripeConnectAndOnlinePayments' +) +BEGIN + ALTER TABLE [Invoices] ADD [PaymentLinkExpiresAt] datetime2 NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260320231509_AddStripeConnectAndOnlinePayments' +) +BEGIN + ALTER TABLE [Invoices] ADD [PaymentLinkToken] nvarchar(max) NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260320231509_AddStripeConnectAndOnlinePayments' +) +BEGIN + ALTER TABLE [Invoices] ADD [StripePaymentIntentId] nvarchar(max) NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260320231509_AddStripeConnectAndOnlinePayments' +) +BEGIN + ALTER TABLE [Companies] ADD [OnlinePaymentSurchargeType] int NOT NULL DEFAULT 0; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260320231509_AddStripeConnectAndOnlinePayments' +) +BEGIN + ALTER TABLE [Companies] ADD [OnlinePaymentSurchargeValue] decimal(18,2) NOT NULL DEFAULT 0.0; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260320231509_AddStripeConnectAndOnlinePayments' +) +BEGIN + ALTER TABLE [Companies] ADD [OnlineSurchargeAcknowledged] bit NOT NULL DEFAULT CAST(0 AS bit); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260320231509_AddStripeConnectAndOnlinePayments' +) +BEGIN + ALTER TABLE [Companies] ADD [StripeAccountId] nvarchar(max) NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260320231509_AddStripeConnectAndOnlinePayments' +) +BEGIN + ALTER TABLE [Companies] ADD [StripeConnectStatus] int NOT NULL DEFAULT 0; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260320231509_AddStripeConnectAndOnlinePayments' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-20T23:15:05.6886302Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260320231509_AddStripeConnectAndOnlinePayments' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-20T23:15:05.6886308Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260320231509_AddStripeConnectAndOnlinePayments' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-20T23:15:05.6886310Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260320231509_AddStripeConnectAndOnlinePayments' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260320231509_AddStripeConnectAndOnlinePayments', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260326230438_AddQuotePhotoSubscriptionLimits' +) +BEGIN + ALTER TABLE [SubscriptionPlanConfigs] ADD [MaxQuotePhotos] int NOT NULL DEFAULT 0; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260326230438_AddQuotePhotoSubscriptionLimits' +) +BEGIN + ALTER TABLE [Companies] ADD [MaxQuotePhotosOverride] int NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260326230438_AddQuotePhotoSubscriptionLimits' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-26T23:04:35.1353265Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260326230438_AddQuotePhotoSubscriptionLimits' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-26T23:04:35.1353273Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260326230438_AddQuotePhotoSubscriptionLimits' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-26T23:04:35.1353275Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260326230438_AddQuotePhotoSubscriptionLimits' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260326230438_AddQuotePhotoSubscriptionLimits', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260328133627_AddJobPhotoIsAiAnalysisPhoto' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-28T13:36:24.1548411Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260328133627_AddJobPhotoIsAiAnalysisPhoto' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-28T13:36:24.1548419Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260328133627_AddJobPhotoIsAiAnalysisPhoto' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-28T13:36:24.1548421Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260328133627_AddJobPhotoIsAiAnalysisPhoto' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260328133627_AddJobPhotoIsAiAnalysisPhoto', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329003300_AddJobDiscountRushFields' +) +BEGIN + ALTER TABLE [Jobs] ADD [DiscountReason] nvarchar(max) NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329003300_AddJobDiscountRushFields' +) +BEGIN + ALTER TABLE [Jobs] ADD [DiscountType] int NOT NULL DEFAULT 0; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329003300_AddJobDiscountRushFields' +) +BEGIN + ALTER TABLE [Jobs] ADD [DiscountValue] decimal(18,2) NOT NULL DEFAULT 0.0; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329003300_AddJobDiscountRushFields' +) +BEGIN + ALTER TABLE [Jobs] ADD [IsRushJob] bit NOT NULL DEFAULT CAST(0 AS bit); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329003300_AddJobDiscountRushFields' +) +BEGIN + ALTER TABLE [JobPhotos] ADD [IsAiAnalysisPhoto] bit NOT NULL DEFAULT CAST(0 AS bit); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329003300_AddJobDiscountRushFields' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-29T00:32:56.7368710Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329003300_AddJobDiscountRushFields' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-29T00:32:56.7368717Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329003300_AddJobDiscountRushFields' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-29T00:32:56.7368718Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329003300_AddJobDiscountRushFields' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260329003300_AddJobDiscountRushFields', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329005838_AddDeposits' +) +BEGIN + CREATE TABLE [Deposits] ( + [Id] int NOT NULL IDENTITY, + [ReceiptNumber] nvarchar(max) NOT NULL, + [CustomerId] int NOT NULL, + [JobId] int NULL, + [QuoteId] int NULL, + [Amount] decimal(18,2) NOT NULL, + [PaymentMethod] int NOT NULL, + [ReceivedDate] datetime2 NOT NULL, + [Reference] nvarchar(max) NULL, + [Notes] nvarchar(max) NULL, + [RecordedById] nvarchar(450) NULL, + [AppliedToInvoiceId] int NULL, + [AppliedDate] datetime2 NULL, + [CompanyId] int NOT NULL, + [CreatedAt] datetime2 NOT NULL, + [UpdatedAt] datetime2 NULL, + [CreatedBy] nvarchar(max) NULL, + [UpdatedBy] nvarchar(max) NULL, + [IsDeleted] bit NOT NULL, + [DeletedAt] datetime2 NULL, + [DeletedBy] nvarchar(max) NULL, + CONSTRAINT [PK_Deposits] PRIMARY KEY ([Id]), + CONSTRAINT [FK_Deposits_AspNetUsers_RecordedById] FOREIGN KEY ([RecordedById]) REFERENCES [AspNetUsers] ([Id]), + CONSTRAINT [FK_Deposits_Customers_CustomerId] FOREIGN KEY ([CustomerId]) REFERENCES [Customers] ([Id]) ON DELETE CASCADE, + CONSTRAINT [FK_Deposits_Invoices_AppliedToInvoiceId] FOREIGN KEY ([AppliedToInvoiceId]) REFERENCES [Invoices] ([Id]) ON DELETE SET NULL, + CONSTRAINT [FK_Deposits_Jobs_JobId] FOREIGN KEY ([JobId]) REFERENCES [Jobs] ([Id]), + CONSTRAINT [FK_Deposits_Quotes_QuoteId] FOREIGN KEY ([QuoteId]) REFERENCES [Quotes] ([Id]) + ); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329005838_AddDeposits' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-29T00:58:35.7576949Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329005838_AddDeposits' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-29T00:58:35.7576955Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329005838_AddDeposits' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-29T00:58:35.7576957Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329005838_AddDeposits' +) +BEGIN + CREATE INDEX [IX_Deposits_AppliedToInvoiceId] ON [Deposits] ([AppliedToInvoiceId]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329005838_AddDeposits' +) +BEGIN + CREATE INDEX [IX_Deposits_CustomerId] ON [Deposits] ([CustomerId]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329005838_AddDeposits' +) +BEGIN + CREATE INDEX [IX_Deposits_JobId] ON [Deposits] ([JobId]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329005838_AddDeposits' +) +BEGIN + CREATE INDEX [IX_Deposits_QuoteId] ON [Deposits] ([QuoteId]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329005838_AddDeposits' +) +BEGIN + CREATE INDEX [IX_Deposits_RecordedById] ON [Deposits] ([RecordedById]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329005838_AddDeposits' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260329005838_AddDeposits', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329134753_AddMerchandise' +) +BEGIN + DROP INDEX [IX_Invoices_CompanyId_JobId] ON [Invoices]; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329134753_AddMerchandise' +) +BEGIN + DROP INDEX [IX_Invoices_JobId] ON [Invoices]; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329134753_AddMerchandise' +) +BEGIN + DECLARE @var2 sysname; + SELECT @var2 = [d].[name] + FROM [sys].[default_constraints] [d] + INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id] + WHERE ([d].[parent_object_id] = OBJECT_ID(N'[Invoices]') AND [c].[name] = N'JobId'); + IF @var2 IS NOT NULL EXEC(N'ALTER TABLE [Invoices] DROP CONSTRAINT [' + @var2 + '];'); + ALTER TABLE [Invoices] ALTER COLUMN [JobId] int NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329134753_AddMerchandise' +) +BEGIN + ALTER TABLE [InvoiceItems] ADD [CatalogItemId] int NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329134753_AddMerchandise' +) +BEGIN + ALTER TABLE [CatalogItems] ADD [InventoryItemId] int NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329134753_AddMerchandise' +) +BEGIN + ALTER TABLE [CatalogItems] ADD [IsMerchandise] bit NOT NULL DEFAULT CAST(0 AS bit); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329134753_AddMerchandise' +) +BEGIN + ALTER TABLE [CatalogCategories] ADD [IsMerchandise] bit NOT NULL DEFAULT CAST(0 AS bit); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329134753_AddMerchandise' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-29T13:47:49.4176542Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329134753_AddMerchandise' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-29T13:47:49.4176549Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329134753_AddMerchandise' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-29T13:47:49.4176551Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329134753_AddMerchandise' +) +BEGIN + EXEC(N'CREATE UNIQUE INDEX [IX_Invoices_CompanyId_JobId] ON [Invoices] ([CompanyId], [JobId]) WHERE [JobId] IS NOT NULL'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329134753_AddMerchandise' +) +BEGIN + EXEC(N'CREATE UNIQUE INDEX [IX_Invoices_JobId] ON [Invoices] ([JobId]) WHERE [JobId] IS NOT NULL'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329134753_AddMerchandise' +) +BEGIN + CREATE INDEX [IX_InvoiceItems_CatalogItemId] ON [InvoiceItems] ([CatalogItemId]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329134753_AddMerchandise' +) +BEGIN + CREATE INDEX [IX_CatalogItems_InventoryItemId] ON [CatalogItems] ([InventoryItemId]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329134753_AddMerchandise' +) +BEGIN + ALTER TABLE [CatalogItems] ADD CONSTRAINT [FK_CatalogItems_InventoryItems_InventoryItemId] FOREIGN KEY ([InventoryItemId]) REFERENCES [InventoryItems] ([Id]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329134753_AddMerchandise' +) +BEGIN + ALTER TABLE [InvoiceItems] ADD CONSTRAINT [FK_InvoiceItems_CatalogItems_CatalogItemId] FOREIGN KEY ([CatalogItemId]) REFERENCES [CatalogItems] ([Id]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329134753_AddMerchandise' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260329134753_AddMerchandise', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329141137_AddGiftCertificateInvoiceItems' +) +BEGIN + ALTER TABLE [InvoiceItems] ADD [GcExpiryDate] datetime2 NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329141137_AddGiftCertificateInvoiceItems' +) +BEGIN + ALTER TABLE [InvoiceItems] ADD [GcRecipientEmail] nvarchar(max) NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329141137_AddGiftCertificateInvoiceItems' +) +BEGIN + ALTER TABLE [InvoiceItems] ADD [GcRecipientName] nvarchar(max) NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329141137_AddGiftCertificateInvoiceItems' +) +BEGIN + ALTER TABLE [InvoiceItems] ADD [GeneratedGiftCertificateId] int NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329141137_AddGiftCertificateInvoiceItems' +) +BEGIN + ALTER TABLE [InvoiceItems] ADD [IsGiftCertificate] bit NOT NULL DEFAULT CAST(0 AS bit); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329141137_AddGiftCertificateInvoiceItems' +) +BEGIN + ALTER TABLE [GiftCertificates] ADD [SourceInvoiceItemId] int NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329141137_AddGiftCertificateInvoiceItems' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-29T14:11:34.2305437Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329141137_AddGiftCertificateInvoiceItems' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-29T14:11:34.2305443Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329141137_AddGiftCertificateInvoiceItems' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-29T14:11:34.2305445Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329141137_AddGiftCertificateInvoiceItems' +) +BEGIN + CREATE INDEX [IX_InvoiceItems_GeneratedGiftCertificateId] ON [InvoiceItems] ([GeneratedGiftCertificateId]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329141137_AddGiftCertificateInvoiceItems' +) +BEGIN + ALTER TABLE [InvoiceItems] ADD CONSTRAINT [FK_InvoiceItems_GiftCertificates_GeneratedGiftCertificateId] FOREIGN KEY ([GeneratedGiftCertificateId]) REFERENCES [GiftCertificates] ([Id]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260329141137_AddGiftCertificateInvoiceItems' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260329141137_AddGiftCertificateInvoiceItems', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260330234034_AddSalesItemFields' +) +BEGIN + ALTER TABLE [QuoteItems] ADD [IsSalesItem] bit NOT NULL DEFAULT CAST(0 AS bit); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260330234034_AddSalesItemFields' +) +BEGIN + ALTER TABLE [QuoteItems] ADD [Sku] nvarchar(max) NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260330234034_AddSalesItemFields' +) +BEGIN + ALTER TABLE [JobItems] ADD [IsSalesItem] bit NOT NULL DEFAULT CAST(0 AS bit); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260330234034_AddSalesItemFields' +) +BEGIN + ALTER TABLE [JobItems] ADD [Sku] nvarchar(max) NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260330234034_AddSalesItemFields' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-30T23:40:30.1483162Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260330234034_AddSalesItemFields' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-30T23:40:30.1483168Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260330234034_AddSalesItemFields' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-30T23:40:30.1483170Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260330234034_AddSalesItemFields' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260330234034_AddSalesItemFields', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260401125630_AddQuoteDepositPaymentFields' +) +BEGIN + ALTER TABLE [Quotes] ADD [DepositAmountPaid] decimal(18,2) NOT NULL DEFAULT 0.0; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260401125630_AddQuoteDepositPaymentFields' +) +BEGIN + ALTER TABLE [Quotes] ADD [DepositPaymentIntentId] nvarchar(max) NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260401125630_AddQuoteDepositPaymentFields' +) +BEGIN + ALTER TABLE [Quotes] ADD [DepositPaymentLinkExpiresAt] datetime2 NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260401125630_AddQuoteDepositPaymentFields' +) +BEGIN + ALTER TABLE [Quotes] ADD [DepositPaymentLinkToken] nvarchar(max) NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260401125630_AddQuoteDepositPaymentFields' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-01T12:56:27.1808248Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260401125630_AddQuoteDepositPaymentFields' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-01T12:56:27.1808254Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260401125630_AddQuoteDepositPaymentFields' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-01T12:56:27.1808255Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260401125630_AddQuoteDepositPaymentFields' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260401125630_AddQuoteDepositPaymentFields', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260401131724_AddUniqueDocumentNumberConstraints' +) +BEGIN + DECLARE @var3 sysname; + SELECT @var3 = [d].[name] + FROM [sys].[default_constraints] [d] + INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id] + WHERE ([d].[parent_object_id] = OBJECT_ID(N'[Bills]') AND [c].[name] = N'BalanceDue'); + IF @var3 IS NOT NULL EXEC(N'ALTER TABLE [Bills] DROP CONSTRAINT [' + @var3 + '];'); + ALTER TABLE [Bills] DROP COLUMN [BalanceDue]; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260401131724_AddUniqueDocumentNumberConstraints' +) +BEGIN + DECLARE @var4 sysname; + SELECT @var4 = [d].[name] + FROM [sys].[default_constraints] [d] + INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id] + WHERE ([d].[parent_object_id] = OBJECT_ID(N'[GiftCertificates]') AND [c].[name] = N'CertificateCode'); + IF @var4 IS NOT NULL EXEC(N'ALTER TABLE [GiftCertificates] DROP CONSTRAINT [' + @var4 + '];'); + ALTER TABLE [GiftCertificates] ALTER COLUMN [CertificateCode] nvarchar(450) NOT NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260401131724_AddUniqueDocumentNumberConstraints' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-01T13:17:21.8121883Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260401131724_AddUniqueDocumentNumberConstraints' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-01T13:17:21.8121891Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260401131724_AddUniqueDocumentNumberConstraints' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-01T13:17:21.8121893Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260401131724_AddUniqueDocumentNumberConstraints' +) +BEGIN + CREATE UNIQUE INDEX [IX_GiftCertificates_CertificateCode] ON [GiftCertificates] ([CertificateCode]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260401131724_AddUniqueDocumentNumberConstraints' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260401131724_AddUniqueDocumentNumberConstraints', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260401141653_FixGiftCertificateUniqueIndexPerCompany' +) +BEGIN + DROP INDEX [IX_GiftCertificates_CertificateCode] ON [GiftCertificates]; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260401141653_FixGiftCertificateUniqueIndexPerCompany' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-01T14:16:49.2887180Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260401141653_FixGiftCertificateUniqueIndexPerCompany' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-01T14:16:49.2887185Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260401141653_FixGiftCertificateUniqueIndexPerCompany' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-01T14:16:49.2887186Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260401141653_FixGiftCertificateUniqueIndexPerCompany' +) +BEGIN + CREATE UNIQUE INDEX [IX_GiftCertificates_CompanyId_CertificateCode] ON [GiftCertificates] ([CompanyId], [CertificateCode]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260401141653_FixGiftCertificateUniqueIndexPerCompany' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260401141653_FixGiftCertificateUniqueIndexPerCompany', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260402015422_AddInvoiceExternalReference' +) +BEGIN + ALTER TABLE [Invoices] ADD [ExternalReference] nvarchar(450) NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260402015422_AddInvoiceExternalReference' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-02T01:54:18.8649199Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260402015422_AddInvoiceExternalReference' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-02T01:54:18.8649205Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260402015422_AddInvoiceExternalReference' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-02T01:54:18.8649206Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260402015422_AddInvoiceExternalReference' +) +BEGIN + CREATE INDEX [IX_Invoices_CompanyId_ExternalReference] ON [Invoices] ([CompanyId], [ExternalReference]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260402015422_AddInvoiceExternalReference' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260402015422_AddInvoiceExternalReference', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260402032156_AddMigratingFromQuickBooks' +) +BEGIN + ALTER TABLE [CompanyPreferences] ADD [MigratingFromQuickBooks] bit NOT NULL DEFAULT CAST(0 AS bit); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260402032156_AddMigratingFromQuickBooks' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-02T03:21:53.0005398Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260402032156_AddMigratingFromQuickBooks' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-02T03:21:53.0005405Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260402032156_AddMigratingFromQuickBooks' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-02T03:21:53.0005406Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260402032156_AddMigratingFromQuickBooks' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260402032156_AddMigratingFromQuickBooks', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260402165758_AddQbMigrationStateJson' +) +BEGIN + ALTER TABLE [CompanyPreferences] ADD [QbMigrationStateJson] nvarchar(max) NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260402165758_AddQbMigrationStateJson' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-02T16:57:55.0246999Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260402165758_AddQbMigrationStateJson' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-02T16:57:55.0247004Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260402165758_AddQbMigrationStateJson' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-02T16:57:55.0247006Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260402165758_AddQbMigrationStateJson' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260402165758_AddQbMigrationStateJson', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260402184721_FixInventorySkuUniqueIndex' +) +BEGIN + DROP INDEX [IX_InventoryItems_SKU] ON [InventoryItems]; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260402184721_FixInventorySkuUniqueIndex' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-02T18:47:18.8788284Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260402184721_FixInventorySkuUniqueIndex' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-02T18:47:18.8788291Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260402184721_FixInventorySkuUniqueIndex' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-02T18:47:18.8788292Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260402184721_FixInventorySkuUniqueIndex' +) +BEGIN + CREATE UNIQUE INDEX [IX_InventoryItems_CompanyId_SKU] ON [InventoryItems] ([CompanyId], [SKU]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260402184721_FixInventorySkuUniqueIndex' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260402184721_FixInventorySkuUniqueIndex', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260402185216_FixJobShopAccessCodeUniqueIndex' +) +BEGIN + DROP INDEX [IX_Jobs_ShopAccessCode] ON [Jobs]; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260402185216_FixJobShopAccessCodeUniqueIndex' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-02T18:52:13.7857008Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260402185216_FixJobShopAccessCodeUniqueIndex' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-02T18:52:13.7857015Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260402185216_FixJobShopAccessCodeUniqueIndex' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-02T18:52:13.7857016Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260402185216_FixJobShopAccessCodeUniqueIndex' +) +BEGIN + CREATE UNIQUE INDEX [IX_Jobs_CompanyId_ShopAccessCode] ON [Jobs] ([CompanyId], [ShopAccessCode]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260402185216_FixJobShopAccessCodeUniqueIndex' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260402185216_FixJobShopAccessCodeUniqueIndex', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260402224949_AddDashboardTips' +) +BEGIN + CREATE TABLE [DashboardTips] ( + [Id] int NOT NULL IDENTITY, + [TipText] nvarchar(max) NOT NULL, + [IsActive] bit NOT NULL, + [CreatedAt] datetime2 NOT NULL, + CONSTRAINT [PK_DashboardTips] PRIMARY KEY ([Id]) + ); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260402224949_AddDashboardTips' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-02T22:49:46.0354841Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260402224949_AddDashboardTips' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-02T22:49:46.0354847Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260402224949_AddDashboardTips' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-02T22:49:46.0354849Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260402224949_AddDashboardTips' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260402224949_AddDashboardTips', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260403000650_AddStripeWebhookEvents' +) +BEGIN + CREATE TABLE [StripeWebhookEvents] ( + [Id] bigint NOT NULL IDENTITY, + [EventId] nvarchar(max) NOT NULL, + [EventType] nvarchar(max) NOT NULL, + [CompanyId] int NULL, + [RawJson] nvarchar(max) NOT NULL, + [Status] int NOT NULL, + [ErrorMessage] nvarchar(max) NULL, + [ReceivedAt] datetime2 NOT NULL, + [ProcessedAt] datetime2 NULL, + CONSTRAINT [PK_StripeWebhookEvents] PRIMARY KEY ([Id]) + ); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260403000650_AddStripeWebhookEvents' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-03T00:06:46.7783905Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260403000650_AddStripeWebhookEvents' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-03T00:06:46.7783912Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260403000650_AddStripeWebhookEvents' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-03T00:06:46.7783913Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260403000650_AddStripeWebhookEvents' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260403000650_AddStripeWebhookEvents', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260404151636_AddAllowAccountingToPlan' +) +BEGIN + ALTER TABLE [SubscriptionPlanConfigs] ADD [AllowAccounting] bit NOT NULL DEFAULT CAST(0 AS bit); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260404151636_AddAllowAccountingToPlan' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-04T15:16:32.2541952Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260404151636_AddAllowAccountingToPlan' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-04T15:16:32.2541958Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260404151636_AddAllowAccountingToPlan' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-04T15:16:32.2541968Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260404151636_AddAllowAccountingToPlan' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260404151636_AddAllowAccountingToPlan', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260404194126_AddBillReceiptFilePath' +) +BEGIN + ALTER TABLE [Bills] ADD [ReceiptFilePath] nvarchar(max) NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260404194126_AddBillReceiptFilePath' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-04T19:41:22.8540290Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260404194126_AddBillReceiptFilePath' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-04T19:41:22.8540296Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260404194126_AddBillReceiptFilePath' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-04T19:41:22.8540297Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260404194126_AddBillReceiptFilePath' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260404194126_AddBillReceiptFilePath', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260405003350_AddPerformanceIndexes' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-05T00:33:47.2862744Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260405003350_AddPerformanceIndexes' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-05T00:33:47.2862750Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260405003350_AddPerformanceIndexes' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-05T00:33:47.2862752Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260405003350_AddPerformanceIndexes' +) +BEGIN + CREATE INDEX [IX_InventoryTransactions_TransactionType_TransactionDate] ON [InventoryTransactions] ([TransactionType], [TransactionDate]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260405003350_AddPerformanceIndexes' +) +BEGIN + CREATE INDEX [IX_InventoryItems_CompanyId_IsActive] ON [InventoryItems] ([CompanyId], [IsActive]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260405003350_AddPerformanceIndexes' +) +BEGIN + CREATE INDEX [IX_InventoryItems_IsActive] ON [InventoryItems] ([IsActive]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260405003350_AddPerformanceIndexes' +) +BEGIN + CREATE INDEX [IX_Bills_CompanyId_Status] ON [Bills] ([CompanyId], [Status]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260405003350_AddPerformanceIndexes' +) +BEGIN + CREATE INDEX [IX_Bills_DueDate] ON [Bills] ([DueDate]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260405003350_AddPerformanceIndexes' +) +BEGIN + CREATE INDEX [IX_Bills_Status] ON [Bills] ([Status]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260405003350_AddPerformanceIndexes' +) +BEGIN + CREATE INDEX [IX_Appointments_ScheduledStartTime] ON [Appointments] ([ScheduledStartTime]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260405003350_AddPerformanceIndexes' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260405003350_AddPerformanceIndexes', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260405155653_AddPlatformSettings' +) +BEGIN + CREATE TABLE [PlatformSettings] ( + [Id] int NOT NULL IDENTITY, + [Key] nvarchar(200) NOT NULL, + [Value] nvarchar(max) NULL, + [Label] nvarchar(max) NULL, + [Description] nvarchar(max) NULL, + [GroupName] nvarchar(max) NULL, + [UpdatedAt] datetime2 NULL, + [UpdatedBy] nvarchar(max) NULL, + CONSTRAINT [PK_PlatformSettings] PRIMARY KEY ([Id]) + ); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260405155653_AddPlatformSettings' +) +BEGIN + CREATE UNIQUE INDEX [IX_PlatformSettings_Key] ON [PlatformSettings] ([Key]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260405155653_AddPlatformSettings' +) +BEGIN + IF EXISTS (SELECT * FROM [sys].[identity_columns] WHERE [name] IN (N'Id', N'Key', N'Value', N'Label', N'Description', N'GroupName') AND [object_id] = OBJECT_ID(N'[PlatformSettings]')) + SET IDENTITY_INSERT [PlatformSettings] ON; + EXEC(N'INSERT INTO [PlatformSettings] ([Id], [Key], [Value], [Label], [Description], [GroupName]) + VALUES (1, N''AdminNotificationEmail'', NULL, N''Admin Notification Email'', N''Email address that receives platform event notifications (new signups, bug reports, subscription events). Leave blank to disable.'', N''Notifications'')'); + IF EXISTS (SELECT * FROM [sys].[identity_columns] WHERE [name] IN (N'Id', N'Key', N'Value', N'Label', N'Description', N'GroupName') AND [object_id] = OBJECT_ID(N'[PlatformSettings]')) + SET IDENTITY_INSERT [PlatformSettings] OFF; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260405155653_AddPlatformSettings' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-05T15:56:49.8180443Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260405155653_AddPlatformSettings' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-05T15:56:49.8180449Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260405155653_AddPlatformSettings' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-05T15:56:49.8180450Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260405155653_AddPlatformSettings' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260405155653_AddPlatformSettings', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260405161241_AddPlatformSettingsV2' +) +BEGIN + DECLARE @var5 sysname; + SELECT @var5 = [d].[name] + FROM [sys].[default_constraints] [d] + INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id] + WHERE ([d].[parent_object_id] = OBJECT_ID(N'[PlatformSettings]') AND [c].[name] = N'Key'); + IF @var5 IS NOT NULL EXEC(N'ALTER TABLE [PlatformSettings] DROP CONSTRAINT [' + @var5 + '];'); + ALTER TABLE [PlatformSettings] ALTER COLUMN [Key] nvarchar(200) NOT NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260405161241_AddPlatformSettingsV2' +) +BEGIN + IF EXISTS (SELECT * FROM [sys].[identity_columns] WHERE [name] IN (N'Id', N'Key', N'Value', N'Label', N'Description', N'GroupName') AND [object_id] = OBJECT_ID(N'[PlatformSettings]')) + SET IDENTITY_INSERT [PlatformSettings] ON; + EXEC(N'INSERT INTO [PlatformSettings] ([Id], [Key], [Value], [Label], [Description], [GroupName]) + VALUES (2, N''BaseUrl'', NULL, N''Base URL'', N''Public URL of this application (e.g. https://app.powdercoatinglogix.com). Used in email links. Falls back to the current request URL if blank.'', N''General''), + (3, N''TrialPeriodDays'', N''7'', N''Trial Period (days)'', N''Number of days a new company gets on the free trial before their subscription expires.'', N''Subscriptions''), + (4, N''QuoteApprovalTokenDays'', N''30'', N''Quote Approval Token Validity (days)'', N''How many days a customer quote-approval link remains valid before expiring.'', N''Quotes''), + (5, N''AuditLogRetentionDays'', N''365'', N''Audit Log Retention (days)'', N''Audit log entries older than this many days are automatically purged by the nightly job.'', N''Data Retention''), + (6, N''StripeWebhookRetentionDays'', N''90'', N''Stripe Webhook Retention (days)'', N''Processed Stripe webhook events older than this many days are automatically purged.'', N''Data Retention'')'); + IF EXISTS (SELECT * FROM [sys].[identity_columns] WHERE [name] IN (N'Id', N'Key', N'Value', N'Label', N'Description', N'GroupName') AND [object_id] = OBJECT_ID(N'[PlatformSettings]')) + SET IDENTITY_INSERT [PlatformSettings] OFF; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260405161241_AddPlatformSettingsV2' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-05T16:12:38.5900904Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260405161241_AddPlatformSettingsV2' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-05T16:12:38.5900913Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260405161241_AddPlatformSettingsV2' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-05T16:12:38.5900914Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260405161241_AddPlatformSettingsV2' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260405161241_AddPlatformSettingsV2', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260405162137_UpdateAdminEmailDescription' +) +BEGIN + EXEC(N'UPDATE [PlatformSettings] SET [Description] = N''Email address(es) that receive platform event notifications (new signups, bug reports, subscription events). Separate multiple addresses with commas. Leave blank to disable.'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260405162137_UpdateAdminEmailDescription' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-05T16:21:34.4700837Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260405162137_UpdateAdminEmailDescription' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-05T16:21:34.4700844Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260405162137_UpdateAdminEmailDescription' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-05T16:21:34.4700846Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260405162137_UpdateAdminEmailDescription' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260405162137_UpdateAdminEmailDescription', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260406191501_MakeBillLineItemAccountIdNullable' +) +BEGIN + DECLARE @var6 sysname; + SELECT @var6 = [d].[name] + FROM [sys].[default_constraints] [d] + INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id] + WHERE ([d].[parent_object_id] = OBJECT_ID(N'[BillLineItems]') AND [c].[name] = N'AccountId'); + IF @var6 IS NOT NULL EXEC(N'ALTER TABLE [BillLineItems] DROP CONSTRAINT [' + @var6 + '];'); + ALTER TABLE [BillLineItems] ALTER COLUMN [AccountId] int NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260406191501_MakeBillLineItemAccountIdNullable' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-06T19:14:56.7157942Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260406191501_MakeBillLineItemAccountIdNullable' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-06T19:14:56.7157953Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260406191501_MakeBillLineItemAccountIdNullable' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-06T19:14:56.7157955Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260406191501_MakeBillLineItemAccountIdNullable' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260406191501_MakeBillLineItemAccountIdNullable', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260408205345_AddJobIntakeFields' +) +BEGIN + ALTER TABLE [Jobs] ADD [IntakeCheckedByUserId] nvarchar(450) NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260408205345_AddJobIntakeFields' +) +BEGIN + ALTER TABLE [Jobs] ADD [IntakeConditionNotes] nvarchar(max) NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260408205345_AddJobIntakeFields' +) +BEGIN + ALTER TABLE [Jobs] ADD [IntakeDate] datetime2 NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260408205345_AddJobIntakeFields' +) +BEGIN + ALTER TABLE [Jobs] ADD [IntakePartCount] int NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260408205345_AddJobIntakeFields' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-08T20:53:42.2947842Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260408205345_AddJobIntakeFields' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-08T20:53:42.2947847Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260408205345_AddJobIntakeFields' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-08T20:53:42.2947849Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260408205345_AddJobIntakeFields' +) +BEGIN + CREATE INDEX [IX_Jobs_IntakeCheckedByUserId] ON [Jobs] ([IntakeCheckedByUserId]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260408205345_AddJobIntakeFields' +) +BEGIN + ALTER TABLE [Jobs] ADD CONSTRAINT [FK_Jobs_AspNetUsers_IntakeCheckedByUserId] FOREIGN KEY ([IntakeCheckedByUserId]) REFERENCES [AspNetUsers] ([Id]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260408205345_AddJobIntakeFields' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260408205345_AddJobIntakeFields', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260409013822_AddInAppNotifications' +) +BEGIN + CREATE TABLE [InAppNotifications] ( + [Id] int NOT NULL IDENTITY, + [Title] nvarchar(max) NOT NULL, + [Message] nvarchar(max) NOT NULL, + [Link] nvarchar(max) NULL, + [NotificationType] nvarchar(max) NOT NULL, + [IsRead] bit NOT NULL, + [ReadAt] datetime2 NULL, + [QuoteId] int NULL, + [InvoiceId] int NULL, + [CustomerId] int NULL, + [CompanyId] int NOT NULL, + [CreatedAt] datetime2 NOT NULL, + [UpdatedAt] datetime2 NULL, + [CreatedBy] nvarchar(max) NULL, + [UpdatedBy] nvarchar(max) NULL, + [IsDeleted] bit NOT NULL, + [DeletedAt] datetime2 NULL, + [DeletedBy] nvarchar(max) NULL, + CONSTRAINT [PK_InAppNotifications] PRIMARY KEY ([Id]), + CONSTRAINT [FK_InAppNotifications_Customers_CustomerId] FOREIGN KEY ([CustomerId]) REFERENCES [Customers] ([Id]), + CONSTRAINT [FK_InAppNotifications_Invoices_InvoiceId] FOREIGN KEY ([InvoiceId]) REFERENCES [Invoices] ([Id]), + CONSTRAINT [FK_InAppNotifications_Quotes_QuoteId] FOREIGN KEY ([QuoteId]) REFERENCES [Quotes] ([Id]) + ); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260409013822_AddInAppNotifications' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-09T01:38:18.3630787Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260409013822_AddInAppNotifications' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-09T01:38:18.3630794Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260409013822_AddInAppNotifications' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-09T01:38:18.3630795Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260409013822_AddInAppNotifications' +) +BEGIN + CREATE INDEX [IX_InAppNotifications_CustomerId] ON [InAppNotifications] ([CustomerId]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260409013822_AddInAppNotifications' +) +BEGIN + CREATE INDEX [IX_InAppNotifications_InvoiceId] ON [InAppNotifications] ([InvoiceId]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260409013822_AddInAppNotifications' +) +BEGIN + CREATE INDEX [IX_InAppNotifications_QuoteId] ON [InAppNotifications] ([QuoteId]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260409013822_AddInAppNotifications' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260409013822_AddInAppNotifications', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260410021934_AddLegalCompliance' +) +BEGIN + ALTER TABLE [Companies] ADD [MarketingEmailOptOut] bit NOT NULL DEFAULT CAST(0 AS bit); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260410021934_AddLegalCompliance' +) +BEGIN + ALTER TABLE [Companies] ADD [MarketingUnsubscribeToken] nvarchar(max) NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260410021934_AddLegalCompliance' +) +BEGIN + + UPDATE Companies + SET MarketingUnsubscribeToken = LOWER(REPLACE(NEWID(), '-', '')) + WHERE MarketingUnsubscribeToken IS NULL + +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260410021934_AddLegalCompliance' +) +BEGIN + DECLARE @var7 sysname; + SELECT @var7 = [d].[name] + FROM [sys].[default_constraints] [d] + INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id] + WHERE ([d].[parent_object_id] = OBJECT_ID(N'[Companies]') AND [c].[name] = N'MarketingUnsubscribeToken'); + IF @var7 IS NOT NULL EXEC(N'ALTER TABLE [Companies] DROP CONSTRAINT [' + @var7 + '];'); + ALTER TABLE [Companies] ALTER COLUMN [MarketingUnsubscribeToken] nvarchar(max) NOT NULL; + ALTER TABLE [Companies] ADD DEFAULT N'' FOR [MarketingUnsubscribeToken]; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260410021934_AddLegalCompliance' +) +BEGIN + CREATE TABLE [TermsAcceptances] ( + [Id] int NOT NULL IDENTITY, + [UserId] nvarchar(max) NOT NULL, + [CompanyId] int NOT NULL, + [TosVersion] nvarchar(max) NOT NULL, + [AcceptedAt] datetime2 NOT NULL, + [IpAddress] nvarchar(max) NULL, + [UserAgent] nvarchar(max) NULL, + CONSTRAINT [PK_TermsAcceptances] PRIMARY KEY ([Id]) + ); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260410021934_AddLegalCompliance' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-10T02:19:30.4105127Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260410021934_AddLegalCompliance' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-10T02:19:30.4105133Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260410021934_AddLegalCompliance' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-10T02:19:30.4105135Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260410021934_AddLegalCompliance' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260410021934_AddLegalCompliance', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260410025353_AddAiFeaturesToPlanConfig' +) +BEGIN + ALTER TABLE [SubscriptionPlanConfigs] ADD [AllowAiInventoryAssist] bit NOT NULL DEFAULT CAST(0 AS bit); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260410025353_AddAiFeaturesToPlanConfig' +) +BEGIN + ALTER TABLE [SubscriptionPlanConfigs] ADD [AllowAiPhotoQuotes] bit NOT NULL DEFAULT CAST(0 AS bit); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260410025353_AddAiFeaturesToPlanConfig' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-10T02:53:49.5828243Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260410025353_AddAiFeaturesToPlanConfig' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-10T02:53:49.5828250Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260410025353_AddAiFeaturesToPlanConfig' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-10T02:53:49.5828252Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260410025353_AddAiFeaturesToPlanConfig' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260410025353_AddAiFeaturesToPlanConfig', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260410032027_AddTrialsEnabledSetting' +) +BEGIN + IF EXISTS (SELECT * FROM [sys].[identity_columns] WHERE [name] IN (N'Id', N'Key', N'Value', N'Label', N'Description', N'GroupName') AND [object_id] = OBJECT_ID(N'[PlatformSettings]')) + SET IDENTITY_INSERT [PlatformSettings] ON; + EXEC(N'INSERT INTO [PlatformSettings] ([Id], [Key], [Value], [Label], [Description], [GroupName]) + VALUES (7, N''TrialsEnabled'', N''true'', N''Free Trials Enabled'', N''When true (default), new signups start with a free trial period. When false, a credit card is required at signup — registrants are sent through Stripe Checkout before their account is created.'', N''Subscriptions'')'); + IF EXISTS (SELECT * FROM [sys].[identity_columns] WHERE [name] IN (N'Id', N'Key', N'Value', N'Label', N'Description', N'GroupName') AND [object_id] = OBJECT_ID(N'[PlatformSettings]')) + SET IDENTITY_INSERT [PlatformSettings] OFF; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260410032027_AddTrialsEnabledSetting' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-10T03:20:23.1587184Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260410032027_AddTrialsEnabledSetting' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-10T03:20:23.1587190Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260410032027_AddTrialsEnabledSetting' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-10T03:20:23.1587191Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260410032027_AddTrialsEnabledSetting' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260410032027_AddTrialsEnabledSetting', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260412005228_AddMaxTenantsSetting' +) +BEGIN + IF EXISTS (SELECT * FROM [sys].[identity_columns] WHERE [name] IN (N'Id', N'Key', N'Value', N'Label', N'Description', N'GroupName') AND [object_id] = OBJECT_ID(N'[PlatformSettings]')) + SET IDENTITY_INSERT [PlatformSettings] ON; + EXEC(N'INSERT INTO [PlatformSettings] ([Id], [Key], [Value], [Label], [Description], [GroupName]) + VALUES (8, N''MaxTenants'', N'''', N''Max Tenants'', N''Maximum number of tenant companies allowed to self-register. Leave blank or 0 for unlimited. Once the limit is reached, the signup page and login signup link are hidden.'', N''Access Control'')'); + IF EXISTS (SELECT * FROM [sys].[identity_columns] WHERE [name] IN (N'Id', N'Key', N'Value', N'Label', N'Description', N'GroupName') AND [object_id] = OBJECT_ID(N'[PlatformSettings]')) + SET IDENTITY_INSERT [PlatformSettings] OFF; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260412005228_AddMaxTenantsSetting' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-12T00:52:25.3071531Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260412005228_AddMaxTenantsSetting' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-12T00:52:25.3071537Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260412005228_AddMaxTenantsSetting' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-12T00:52:25.3071538Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260412005228_AddMaxTenantsSetting' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260412005228_AddMaxTenantsSetting', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260412174157_AddCompanyFeatureOverrides' +) +BEGIN + ALTER TABLE [Companies] ADD [AccountingOverride] bit NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260412174157_AddCompanyFeatureOverrides' +) +BEGIN + ALTER TABLE [Companies] ADD [OnlinePaymentsOverride] bit NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260412174157_AddCompanyFeatureOverrides' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-12T17:41:53.0142151Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260412174157_AddCompanyFeatureOverrides' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-12T17:41:53.0142159Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260412174157_AddCompanyFeatureOverrides' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-12T17:41:53.0142161Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260412174157_AddCompanyFeatureOverrides' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260412174157_AddCompanyFeatureOverrides', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260412183411_AddIsAnnualBilling' +) +BEGIN + ALTER TABLE [Companies] ADD [IsAnnualBilling] bit NOT NULL DEFAULT CAST(0 AS bit); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260412183411_AddIsAnnualBilling' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-12T18:34:08.9093047Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260412183411_AddIsAnnualBilling' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-12T18:34:08.9093054Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260412183411_AddIsAnnualBilling' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-12T18:34:08.9093055Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260412183411_AddIsAnnualBilling' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260412183411_AddIsAnnualBilling', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260414135810_AddPendingRegistrationSession' +) +BEGIN + CREATE TABLE [PendingRegistrationSessions] ( + [Id] int NOT NULL IDENTITY, + [Token] nvarchar(max) NOT NULL, + [CompanyName] nvarchar(max) NOT NULL, + [CompanyPhone] nvarchar(max) NULL, + [FirstName] nvarchar(max) NOT NULL, + [LastName] nvarchar(max) NOT NULL, + [Email] nvarchar(max) NOT NULL, + [Plan] int NOT NULL, + [IsAnnual] bit NOT NULL, + [CreatedAt] datetime2 NOT NULL, + [IsCompleted] bit NOT NULL, + CONSTRAINT [PK_PendingRegistrationSessions] PRIMARY KEY ([Id]) + ); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260414135810_AddPendingRegistrationSession' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-14T13:58:07.0916607Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260414135810_AddPendingRegistrationSession' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-14T13:58:07.0916613Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260414135810_AddPendingRegistrationSession' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-14T13:58:07.0916614Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260414135810_AddPendingRegistrationSession' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260414135810_AddPendingRegistrationSession', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260415010203_AddSetupWizardCompletionTracking' +) +BEGIN + ALTER TABLE [CompanyPreferences] ADD [SetupWizardCompletedAt] datetime2 NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260415010203_AddSetupWizardCompletionTracking' +) +BEGIN + ALTER TABLE [CompanyPreferences] ADD [SetupWizardCompletedByName] nvarchar(max) NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260415010203_AddSetupWizardCompletionTracking' +) +BEGIN + ALTER TABLE [CompanyPreferences] ADD [SetupWizardCompletedByUserId] nvarchar(max) NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260415010203_AddSetupWizardCompletionTracking' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-15T01:02:00.3083161Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260415010203_AddSetupWizardCompletionTracking' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-15T01:02:00.3083167Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260415010203_AddSetupWizardCompletionTracking' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-15T01:02:00.3083169Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260415010203_AddSetupWizardCompletionTracking' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260415010203_AddSetupWizardCompletionTracking', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260415120000_AddAuditLogTable' +) +BEGIN + CREATE TABLE [AuditLogs] ( + [Id] bigint NOT NULL IDENTITY, + [UserId] nvarchar(max) NULL, + [UserName] nvarchar(max) NOT NULL, + [CompanyId] int NULL, + [CompanyName] nvarchar(max) NULL, + [Action] nvarchar(max) NOT NULL, + [EntityType] nvarchar(450) NOT NULL, + [EntityId] nvarchar(450) NULL, + [EntityDescription] nvarchar(max) NULL, + [OldValues] nvarchar(max) NULL, + [NewValues] nvarchar(max) NULL, + [IpAddress] nvarchar(max) NULL, + [Timestamp] datetime2 NOT NULL, + CONSTRAINT [PK_AuditLogs] PRIMARY KEY ([Id]) + ); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260415120000_AddAuditLogTable' +) +BEGIN + CREATE INDEX [IX_AuditLogs_CompanyId_Timestamp] ON [AuditLogs] ([CompanyId], [Timestamp]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260415120000_AddAuditLogTable' +) +BEGIN + CREATE INDEX [IX_AuditLogs_EntityType_EntityId] ON [AuditLogs] ([EntityType], [EntityId]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260415120000_AddAuditLogTable' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260415120000_AddAuditLogTable', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417044255_AddInventoryTransactionJobId' +) +BEGIN + ALTER TABLE [InventoryTransactions] ADD [JobId] int NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417044255_AddInventoryTransactionJobId' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-17T04:42:51.1510259Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417044255_AddInventoryTransactionJobId' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-17T04:42:51.1510266Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417044255_AddInventoryTransactionJobId' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-17T04:42:51.1510268Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417044255_AddInventoryTransactionJobId' +) +BEGIN + CREATE INDEX [IX_InventoryTransactions_JobId] ON [InventoryTransactions] ([JobId]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417044255_AddInventoryTransactionJobId' +) +BEGIN + ALTER TABLE [InventoryTransactions] ADD CONSTRAINT [FK_InventoryTransactions_Jobs_JobId] FOREIGN KEY ([JobId]) REFERENCES [Jobs] ([Id]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417044255_AddInventoryTransactionJobId' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260417044255_AddInventoryTransactionJobId', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417160645_AddSamplePanel' +) +BEGIN + ALTER TABLE [InventoryItems] ADD [HasSamplePanel] bit NOT NULL DEFAULT CAST(0 AS bit); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417160645_AddSamplePanel' +) +BEGIN + ALTER TABLE [InventoryItems] ADD [IsCoating] bit NOT NULL DEFAULT CAST(0 AS bit); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417160645_AddSamplePanel' +) +BEGIN + ALTER TABLE [InventoryItems] ADD [SamplePanelNotes] nvarchar(max) NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417160645_AddSamplePanel' +) +BEGIN + + UPDATE ii + SET ii.IsCoating = 1 + FROM InventoryItems ii + INNER JOIN InventoryCategoryLookups icl ON icl.Id = ii.InventoryCategoryId + WHERE icl.IsCoating = 1 AND icl.IsDeleted = 0 + +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417160645_AddSamplePanel' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-17T16:06:41.3326769Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417160645_AddSamplePanel' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-17T16:06:41.3326775Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417160645_AddSamplePanel' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-17T16:06:41.3326776Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417160645_AddSamplePanel' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260417160645_AddSamplePanel', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417170543_DropItemIsCoatingColumn' +) +BEGIN + DECLARE @var8 sysname; + SELECT @var8 = [d].[name] + FROM [sys].[default_constraints] [d] + INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id] + WHERE ([d].[parent_object_id] = OBJECT_ID(N'[InventoryItems]') AND [c].[name] = N'IsCoating'); + IF @var8 IS NOT NULL EXEC(N'ALTER TABLE [InventoryItems] DROP CONSTRAINT [' + @var8 + '];'); + ALTER TABLE [InventoryItems] DROP COLUMN [IsCoating]; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417170543_DropItemIsCoatingColumn' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-17T17:05:40.0377587Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417170543_DropItemIsCoatingColumn' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-17T17:05:40.0377592Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417170543_DropItemIsCoatingColumn' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-17T17:05:40.0377594Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417170543_DropItemIsCoatingColumn' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260417170543_DropItemIsCoatingColumn', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260418220837_AddWorkOrderTemplate' +) +BEGIN + DECLARE @var9 sysname; + SELECT @var9 = [d].[name] + FROM [sys].[default_constraints] [d] + INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id] + WHERE ([d].[parent_object_id] = OBJECT_ID(N'[InventoryItems]') AND [c].[name] = N'SamplePanelNotes'); + IF @var9 IS NOT NULL EXEC(N'ALTER TABLE [InventoryItems] DROP CONSTRAINT [' + @var9 + '];'); + ALTER TABLE [InventoryItems] DROP COLUMN [SamplePanelNotes]; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260418220837_AddWorkOrderTemplate' +) +BEGIN + ALTER TABLE [CompanyPreferences] ADD [WoAccentColor] nvarchar(max) NOT NULL DEFAULT N''; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260418220837_AddWorkOrderTemplate' +) +BEGIN + ALTER TABLE [CompanyPreferences] ADD [WoTerms] nvarchar(max) NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260418220837_AddWorkOrderTemplate' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-18T22:08:33.1580643Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260418220837_AddWorkOrderTemplate' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-18T22:08:33.1580650Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260418220837_AddWorkOrderTemplate' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-18T22:08:33.1580651Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260418220837_AddWorkOrderTemplate' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260418220837_AddWorkOrderTemplate', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260419201818_AddInvoiceNumberPrefix' +) +BEGIN + ALTER TABLE [CompanyPreferences] ADD [InvoiceNumberPrefix] nvarchar(max) NOT NULL DEFAULT N''; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260419201818_AddInvoiceNumberPrefix' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-19T20:18:14.2123100Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260419201818_AddInvoiceNumberPrefix' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-19T20:18:14.2123106Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260419201818_AddInvoiceNumberPrefix' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-19T20:18:14.2123107Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260419201818_AddInvoiceNumberPrefix' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260419201818_AddInvoiceNumberPrefix', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260419215302_AddContactSubmissions' +) +BEGIN + CREATE TABLE [ContactSubmissions] ( + [Id] int NOT NULL IDENTITY, + [SenderName] nvarchar(max) NOT NULL, + [SenderEmail] nvarchar(max) NOT NULL, + [CompanyName] nvarchar(max) NOT NULL, + [Category] nvarchar(max) NOT NULL, + [Subject] nvarchar(max) NOT NULL, + [Message] nvarchar(max) NOT NULL, + [IsRead] bit NOT NULL, + [ReadAt] datetime2 NULL, + [ReadByUserId] nvarchar(max) NULL, + [ReadByUserName] nvarchar(max) NULL, + [AdminNotes] nvarchar(max) NULL, + [CompanyId] int NOT NULL, + [CreatedAt] datetime2 NOT NULL, + [UpdatedAt] datetime2 NULL, + [CreatedBy] nvarchar(max) NULL, + [UpdatedBy] nvarchar(max) NULL, + [IsDeleted] bit NOT NULL, + [DeletedAt] datetime2 NULL, + [DeletedBy] nvarchar(max) NULL, + CONSTRAINT [PK_ContactSubmissions] PRIMARY KEY ([Id]) + ); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260419215302_AddContactSubmissions' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-19T21:52:59.4160772Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260419215302_AddContactSubmissions' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-19T21:52:59.4160778Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260419215302_AddContactSubmissions' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-19T21:52:59.4160779Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260419215302_AddContactSubmissions' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260419215302_AddContactSubmissions', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260420233610_AddShopCapabilityProfile' +) +BEGIN + ALTER TABLE [CompanyOperatingCosts] ADD [BlastNozzleSize] int NOT NULL DEFAULT 0; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260420233610_AddShopCapabilityProfile' +) +BEGIN + ALTER TABLE [CompanyOperatingCosts] ADD [BlastRateSqFtPerHourOverride] decimal(18,2) NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260420233610_AddShopCapabilityProfile' +) +BEGIN + ALTER TABLE [CompanyOperatingCosts] ADD [BlastSetupType] int NOT NULL DEFAULT 0; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260420233610_AddShopCapabilityProfile' +) +BEGIN + ALTER TABLE [CompanyOperatingCosts] ADD [CoatingGunType] int NOT NULL DEFAULT 0; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260420233610_AddShopCapabilityProfile' +) +BEGIN + ALTER TABLE [CompanyOperatingCosts] ADD [CoatingRateSqFtPerHourOverride] decimal(18,2) NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260420233610_AddShopCapabilityProfile' +) +BEGIN + ALTER TABLE [CompanyOperatingCosts] ADD [CompressorCfm] decimal(18,2) NOT NULL DEFAULT 0.0; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260420233610_AddShopCapabilityProfile' +) +BEGIN + ALTER TABLE [CompanyOperatingCosts] ADD [PrimaryBlastSubstrate] int NOT NULL DEFAULT 0; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260420233610_AddShopCapabilityProfile' +) +BEGIN + ALTER TABLE [CompanyOperatingCosts] ADD [ShopCapabilityTier] int NOT NULL DEFAULT 0; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260420233610_AddShopCapabilityProfile' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-20T23:36:06.0440591Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260420233610_AddShopCapabilityProfile' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-20T23:36:06.0440597Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260420233610_AddShopCapabilityProfile' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-20T23:36:06.0440599Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260420233610_AddShopCapabilityProfile' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260420233610_AddShopCapabilityProfile', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260421003346_AddCompanyBlastSetups' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-21T00:33:43.0886131Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260421003346_AddCompanyBlastSetups' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-21T00:33:43.0886138Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260421003346_AddCompanyBlastSetups' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-21T00:33:43.0886139Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260421003346_AddCompanyBlastSetups' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260421003346_AddCompanyBlastSetups', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260421012409_AddCompanyBlastSetupsTable' +) +BEGIN + ALTER TABLE [QuoteItemPrepServices] ADD [BlastSetupId] int NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260421012409_AddCompanyBlastSetupsTable' +) +BEGIN + ALTER TABLE [PrepServices] ADD [RequiresBlastSetup] bit NOT NULL DEFAULT CAST(0 AS bit); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260421012409_AddCompanyBlastSetupsTable' +) +BEGIN + ALTER TABLE [JobItemPrepServices] ADD [BlastSetupId] int NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260421012409_AddCompanyBlastSetupsTable' +) +BEGIN + CREATE TABLE [CompanyBlastSetups] ( + [Id] int NOT NULL IDENTITY, + [Name] nvarchar(100) NOT NULL, + [SetupType] int NOT NULL, + [CompressorCfm] decimal(18,2) NOT NULL, + [BlastNozzleSize] int NOT NULL, + [PrimarySubstrate] int NOT NULL, + [BlastRateSqFtPerHourOverride] decimal(18,2) NULL, + [IsDefault] bit NOT NULL, + [IsActive] bit NOT NULL, + [DisplayOrder] int NOT NULL, + [CompanyId] int NOT NULL, + [CreatedAt] datetime2 NOT NULL, + [UpdatedAt] datetime2 NULL, + [CreatedBy] nvarchar(max) NULL, + [UpdatedBy] nvarchar(max) NULL, + [IsDeleted] bit NOT NULL, + [DeletedAt] datetime2 NULL, + [DeletedBy] nvarchar(max) NULL, + CONSTRAINT [PK_CompanyBlastSetups] PRIMARY KEY ([Id]), + CONSTRAINT [FK_CompanyBlastSetups_Companies_CompanyId] FOREIGN KEY ([CompanyId]) REFERENCES [Companies] ([Id]) ON DELETE NO ACTION + ); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260421012409_AddCompanyBlastSetupsTable' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-21T01:24:05.9835208Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260421012409_AddCompanyBlastSetupsTable' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-21T01:24:05.9835215Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260421012409_AddCompanyBlastSetupsTable' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-21T01:24:05.9835220Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260421012409_AddCompanyBlastSetupsTable' +) +BEGIN + CREATE INDEX [IX_QuoteItemPrepServices_BlastSetupId] ON [QuoteItemPrepServices] ([BlastSetupId]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260421012409_AddCompanyBlastSetupsTable' +) +BEGIN + CREATE INDEX [IX_JobItemPrepServices_BlastSetupId] ON [JobItemPrepServices] ([BlastSetupId]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260421012409_AddCompanyBlastSetupsTable' +) +BEGIN + CREATE INDEX [IX_CompanyBlastSetups_CompanyId] ON [CompanyBlastSetups] ([CompanyId]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260421012409_AddCompanyBlastSetupsTable' +) +BEGIN + ALTER TABLE [JobItemPrepServices] ADD CONSTRAINT [FK_JobItemPrepServices_CompanyBlastSetups_BlastSetupId] FOREIGN KEY ([BlastSetupId]) REFERENCES [CompanyBlastSetups] ([Id]) ON DELETE SET NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260421012409_AddCompanyBlastSetupsTable' +) +BEGIN + ALTER TABLE [QuoteItemPrepServices] ADD CONSTRAINT [FK_QuoteItemPrepServices_CompanyBlastSetups_BlastSetupId] FOREIGN KEY ([BlastSetupId]) REFERENCES [CompanyBlastSetups] ([Id]) ON DELETE SET NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260421012409_AddCompanyBlastSetupsTable' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260421012409_AddCompanyBlastSetupsTable', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260421125956_AddQuoteCostBreakdownColumns2' +) +BEGIN + ALTER TABLE [Quotes] ADD [EquipmentCosts] decimal(18,2) NOT NULL DEFAULT 0.0; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260421125956_AddQuoteCostBreakdownColumns2' +) +BEGIN + ALTER TABLE [Quotes] ADD [LaborCosts] decimal(18,2) NOT NULL DEFAULT 0.0; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260421125956_AddQuoteCostBreakdownColumns2' +) +BEGIN + ALTER TABLE [Quotes] ADD [MaterialCosts] decimal(18,2) NOT NULL DEFAULT 0.0; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260421125956_AddQuoteCostBreakdownColumns2' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-21T12:59:53.4982640Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260421125956_AddQuoteCostBreakdownColumns2' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-21T12:59:53.4982651Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260421125956_AddQuoteCostBreakdownColumns2' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-21T12:59:53.4982653Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260421125956_AddQuoteCostBreakdownColumns2' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260421125956_AddQuoteCostBreakdownColumns2', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260421132123_AddQuoteItemCostSnapshot' +) +BEGIN + ALTER TABLE [QuoteItems] ADD [ItemEquipmentCost] decimal(18,2) NOT NULL DEFAULT 0.0; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260421132123_AddQuoteItemCostSnapshot' +) +BEGIN + ALTER TABLE [QuoteItems] ADD [ItemLaborCost] decimal(18,2) NOT NULL DEFAULT 0.0; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260421132123_AddQuoteItemCostSnapshot' +) +BEGIN + ALTER TABLE [QuoteItems] ADD [ItemMaterialCost] decimal(18,2) NOT NULL DEFAULT 0.0; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260421132123_AddQuoteItemCostSnapshot' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-21T13:21:19.6983052Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260421132123_AddQuoteItemCostSnapshot' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-21T13:21:19.6983058Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260421132123_AddQuoteItemCostSnapshot' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-21T13:21:19.6983060Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260421132123_AddQuoteItemCostSnapshot' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260421132123_AddQuoteItemCostSnapshot', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260421135923_AddPricingModeAndHideDiscount' +) +BEGIN + ALTER TABLE [Quotes] ADD [HideDiscountFromCustomer] bit NOT NULL DEFAULT CAST(0 AS bit); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260421135923_AddPricingModeAndHideDiscount' +) +BEGIN + ALTER TABLE [CompanyOperatingCosts] ADD [PricingMode] int NOT NULL DEFAULT 0; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260421135923_AddPricingModeAndHideDiscount' +) +BEGIN + ALTER TABLE [CompanyOperatingCosts] ADD [TargetMarginPercent] decimal(18,2) NOT NULL DEFAULT 0.0; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260421135923_AddPricingModeAndHideDiscount' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-21T13:59:19.8113639Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260421135923_AddPricingModeAndHideDiscount' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-21T13:59:19.8113645Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260421135923_AddPricingModeAndHideDiscount' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-21T13:59:19.8113646Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260421135923_AddPricingModeAndHideDiscount' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260421135923_AddPricingModeAndHideDiscount', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260422040844_AddUserBan' +) +BEGIN + ALTER TABLE [AspNetUsers] ADD [IsBanned] bit NOT NULL DEFAULT CAST(0 AS bit); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260422040844_AddUserBan' +) +BEGIN + ALTER TABLE [AspNetUsers] ADD [BannedAt] datetime2 NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260422040844_AddUserBan' +) +BEGIN + ALTER TABLE [AspNetUsers] ADD [BanReason] nvarchar(max) NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260422040844_AddUserBan' +) +BEGIN + ALTER TABLE [AspNetUsers] ADD [BannedByUserId] nvarchar(450) NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260422040844_AddUserBan' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-22T04:08:40.9182111Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260422040844_AddUserBan' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-22T04:08:40.9182118Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260422040844_AddUserBan' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-22T04:08:40.9182119Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260422040844_AddUserBan' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260422040844_AddUserBan', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260422042129_AddBannedIps' +) +BEGIN + CREATE TABLE [BannedIps] ( + [Id] int NOT NULL IDENTITY, + [IpAddress] nvarchar(45) NOT NULL, + [Reason] nvarchar(500) NULL, + [BannedByUserId] nvarchar(450) NULL, + [BannedAt] datetime2 NOT NULL, + [ExpiresAt] datetime2 NULL, + [IsActive] bit NOT NULL, + CONSTRAINT [PK_BannedIps] PRIMARY KEY ([Id]) + ); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260422042129_AddBannedIps' +) +BEGIN + CREATE INDEX [IX_BannedIps_IpAddress_IsActive] ON [BannedIps] ([IpAddress], [IsActive]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260422042129_AddBannedIps' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-22T04:21:26.9627092Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260422042129_AddBannedIps' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-22T04:21:26.9627100Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260422042129_AddBannedIps' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-22T04:21:26.9627102Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260422042129_AddBannedIps' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260422042129_AddBannedIps', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260423011936_AddAiUsageLog' +) +BEGIN + CREATE TABLE [AiUsageLogs] ( + [Id] bigint NOT NULL IDENTITY, + [CompanyId] int NOT NULL, + [UserId] nvarchar(max) NOT NULL, + [Feature] nvarchar(max) NOT NULL, + [Success] bit NOT NULL, + [InputLength] int NOT NULL, + [CalledAt] datetime2 NOT NULL, + CONSTRAINT [PK_AiUsageLogs] PRIMARY KEY ([Id]), + CONSTRAINT [FK_AiUsageLogs_Companies_CompanyId] FOREIGN KEY ([CompanyId]) REFERENCES [Companies] ([Id]) + ); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260423011936_AddAiUsageLog' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-23T01:19:33.1714669Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260423011936_AddAiUsageLog' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-23T01:19:33.1714674Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260423011936_AddAiUsageLog' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-23T01:19:33.1714676Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260423011936_AddAiUsageLog' +) +BEGIN + CREATE INDEX [IX_AiUsageLogs_CompanyId_CalledAt] ON [AiUsageLogs] ([CompanyId], [CalledAt]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260423011936_AddAiUsageLog' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260423011936_AddAiUsageLog', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260423015446_AddSmsOptedOutAt' +) +BEGIN + ALTER TABLE [Customers] ADD [SmsOptedOutAt] datetime2 NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260423015446_AddSmsOptedOutAt' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-23T01:54:43.1815272Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260423015446_AddSmsOptedOutAt' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-23T01:54:43.1815281Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260423015446_AddSmsOptedOutAt' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-23T01:54:43.1815283Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260423015446_AddSmsOptedOutAt' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260423015446_AddSmsOptedOutAt', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260424232825_AddFacilityOverheadFields' +) +BEGIN + ALTER TABLE [CompanyOperatingCosts] ADD [MonthlyBillableHours] int NOT NULL DEFAULT 160; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260424232825_AddFacilityOverheadFields' +) +BEGIN + ALTER TABLE [CompanyOperatingCosts] ADD [MonthlyRent] decimal(18,2) NOT NULL DEFAULT 0.0; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260424232825_AddFacilityOverheadFields' +) +BEGIN + ALTER TABLE [CompanyOperatingCosts] ADD [MonthlyUtilities] decimal(18,2) NOT NULL DEFAULT 0.0; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260424232825_AddFacilityOverheadFields' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-24T23:28:22.1047155Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260424232825_AddFacilityOverheadFields' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-24T23:28:22.1047162Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260424232825_AddFacilityOverheadFields' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-24T23:28:22.1047164Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260424232825_AddFacilityOverheadFields' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260424232825_AddFacilityOverheadFields', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260425123256_AddCatalogItemImages' +) +BEGIN + ALTER TABLE [CatalogItems] ADD [ImagePath] nvarchar(max) NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260425123256_AddCatalogItemImages' +) +BEGIN + ALTER TABLE [CatalogItems] ADD [ThumbnailPath] nvarchar(max) NULL; +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260425123256_AddCatalogItemImages' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-25T12:32:52.2955147Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260425123256_AddCatalogItemImages' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-25T12:32:52.2955155Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260425123256_AddCatalogItemImages' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-25T12:32:52.2955156Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260425123256_AddCatalogItemImages' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260425123256_AddCatalogItemImages', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260425182712_AddUserPasskeys' +) +BEGIN + CREATE TABLE [UserPasskeys] ( + [Id] int NOT NULL IDENTITY, + [UserId] nvarchar(max) NOT NULL, + [CompanyId] int NOT NULL, + [CredentialId] varbinary(900) NOT NULL, + [PublicKey] varbinary(max) NOT NULL, + [UserHandle] varbinary(max) NOT NULL, + [SignCount] bigint NOT NULL, + [DeviceFriendlyName] nvarchar(max) NULL, + [CreatedAt] datetime2 NOT NULL, + [LastUsedAt] datetime2 NULL, + CONSTRAINT [PK_UserPasskeys] PRIMARY KEY ([Id]) + ); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260425182712_AddUserPasskeys' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-25T18:27:08.5374555Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260425182712_AddUserPasskeys' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-25T18:27:08.5374562Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260425182712_AddUserPasskeys' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-25T18:27:08.5374563Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260425182712_AddUserPasskeys' +) +BEGIN + CREATE UNIQUE INDEX [IX_UserPasskeys_CredentialId] ON [UserPasskeys] ([CredentialId]); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260425182712_AddUserPasskeys' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260425182712_AddUserPasskeys', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260425223454_AddCatalogPriceCheckReport' +) +BEGIN + CREATE TABLE [CatalogPriceCheckReports] ( + [Id] int NOT NULL IDENTITY, + [RunAt] datetime2 NOT NULL, + [ItemsChecked] int NOT NULL, + [ResultsJson] nvarchar(max) NOT NULL, + [OperatingCostsSummary] nvarchar(max) NOT NULL, + [CompanyId] int NOT NULL, + [CreatedAt] datetime2 NOT NULL, + [UpdatedAt] datetime2 NULL, + [CreatedBy] nvarchar(max) NULL, + [UpdatedBy] nvarchar(max) NULL, + [IsDeleted] bit NOT NULL, + [DeletedAt] datetime2 NULL, + [DeletedBy] nvarchar(max) NULL, + CONSTRAINT [PK_CatalogPriceCheckReports] PRIMARY KEY ([Id]) + ); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260425223454_AddCatalogPriceCheckReport' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-25T22:34:50.0016987Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260425223454_AddCatalogPriceCheckReport' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-25T22:34:50.0016993Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260425223454_AddCatalogPriceCheckReport' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-25T22:34:50.0016994Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260425223454_AddCatalogPriceCheckReport' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260425223454_AddCatalogPriceCheckReport', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260426122625_AddAiCatalogPriceCheckGating' +) +BEGIN + ALTER TABLE [SubscriptionPlanConfigs] ADD [AllowAiCatalogPriceCheck] bit NOT NULL DEFAULT CAST(0 AS bit); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260426122625_AddAiCatalogPriceCheckGating' +) +BEGIN + ALTER TABLE [Companies] ADD [AiCatalogPriceCheckEnabled] bit NOT NULL DEFAULT CAST(0 AS bit); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260426122625_AddAiCatalogPriceCheckGating' +) +BEGIN + IF NOT EXISTS (SELECT 1 FROM [PlatformSettings] WHERE [Key] = N'AiCatalogPriceCheckEnabled') + BEGIN + INSERT INTO [PlatformSettings] ([Key], [Value], [Label], [Description], [GroupName]) + VALUES (N'AiCatalogPriceCheckEnabled', N'true', N'AI Catalog Price Check Enabled', + N'When true (default), the AI Catalog Price Check feature is available to companies on qualifying plans. Set to false to disable it platform-wide.', + N'AI Features'); + END +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260426122625_AddAiCatalogPriceCheckGating' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-26T12:26:21.7275012Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260426122625_AddAiCatalogPriceCheckGating' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-26T12:26:21.7275018Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260426122625_AddAiCatalogPriceCheckGating' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-26T12:26:21.7275020Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260426122625_AddAiCatalogPriceCheckGating' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260426122625_AddAiCatalogPriceCheckGating', N'8.0.11'); +END; +GO + +COMMIT; +GO + +BEGIN TRANSACTION; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260426142825_AddPasskeyPromptDismissed' +) +BEGIN + ALTER TABLE [AspNetUsers] ADD [PasskeyPromptDismissed] bit NOT NULL DEFAULT CAST(0 AS bit); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260426142825_AddPasskeyPromptDismissed' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-26T14:28:21.4545921Z'' + WHERE [Id] = 1; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260426142825_AddPasskeyPromptDismissed' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-26T14:28:21.4545931Z'' + WHERE [Id] = 2; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260426142825_AddPasskeyPromptDismissed' +) +BEGIN + EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-26T14:28:21.4545932Z'' + WHERE [Id] = 3; + SELECT @@ROWCOUNT'); +END; +GO + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260426142825_AddPasskeyPromptDismissed' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260426142825_AddPasskeyPromptDismissed', N'8.0.11'); +END; +GO + +COMMIT; +GO + diff --git a/src/PowderCoating.Application/Interfaces/IAiUsageReportService.cs b/src/PowderCoating.Application/Interfaces/IAiUsageReportService.cs new file mode 100644 index 0000000..d67328b --- /dev/null +++ b/src/PowderCoating.Application/Interfaces/IAiUsageReportService.cs @@ -0,0 +1,29 @@ +namespace PowderCoating.Application.Interfaces; + +/// Pre-aggregated AI call counts for one company across four time windows. +public record AiCompanyUsage(int CompanyId, int Today, int Last7Days, int Last30Days, int AllTime); + +/// Count of AI calls for a specific feature within a company (last 30 days). +public record AiFeatureStat(int CompanyId, string Feature, int Count); + +/// Bundled result returned by . +public record AiUsageReportData( + List UsageByCompany, + List FeatureStats, + Dictionary PhotoCountsByCompany); + +/// +/// Read-only service for the platform AI usage analytics report. Queries AiUsageLogs +/// and QuotePhotos (cross-tenant, non-BaseEntity) via ApplicationDbContext +/// directly so that does not need a direct DB context reference. +/// Implemented in Infrastructure; used as Tier-3 aggregate report service. +/// +public interface IAiUsageReportService +{ + /// + /// Returns all the aggregated AI usage data needed to render the platform AI usage report: + /// per-company call counts across today / 7-day / 30-day / all-time windows, + /// feature stats for the last 30 days, and AI photo upload counts per company. + /// + Task GetReportDataAsync(); +} diff --git a/src/PowderCoating.Application/Interfaces/IOperationalReportService.cs b/src/PowderCoating.Application/Interfaces/IOperationalReportService.cs index 1a7f18c..21544f6 100644 --- a/src/PowderCoating.Application/Interfaces/IOperationalReportService.cs +++ b/src/PowderCoating.Application/Interfaces/IOperationalReportService.cs @@ -1,3 +1,5 @@ +using PowderCoating.Core.Entities; + namespace PowderCoating.Application.Interfaces; /// @@ -22,4 +24,25 @@ public interface IOperationalReportService /// Returns powder usage (lbs and cost) broken down by color and vendor. Task GetPowderUsageAsync(int companyId, int months); + + /// + /// Returns all active (non-deleted, non-voided) bills with their Vendor and non-deleted + /// Payments navigations loaded. Used by Analytics, ExpensesAp, and AI accounting actions + /// so those controllers do not need a direct ApplicationDbContext reference. + /// + Task> GetActiveBillsAsync(); + + /// + /// Returns all non-deleted direct expenses with their ExpenseAccount navigation loaded. + /// Used by Analytics, ExpensesAp, and AI accounting actions so those controllers do not + /// need a direct ApplicationDbContext reference. + /// + Task> GetAllExpensesAsync(); + + /// + /// Returns the full job status history log with FromStatus and ToStatus navigations + /// loaded. Used by Analytics and JobCycleTime so those actions do not need a direct + /// ApplicationDbContext reference. + /// + Task> GetAllJobStatusHistoryAsync(); } diff --git a/src/PowderCoating.Core/Interfaces/IPlainRepository.cs b/src/PowderCoating.Core/Interfaces/IPlainRepository.cs new file mode 100644 index 0000000..9e402a1 --- /dev/null +++ b/src/PowderCoating.Core/Interfaces/IPlainRepository.cs @@ -0,0 +1,29 @@ +using System.Linq.Expressions; + +namespace PowderCoating.Core.Interfaces; + +/// +/// Lightweight repository interface for platform-level entities that do not inherit +/// (e.g. Announcement, BannedIp, +/// DashboardTip, ReleaseNote). These entities have no CompanyId, no IsDeleted, and no +/// soft-delete semantics — so the full IRepository<T> contract (SoftDeleteAsync, +/// ignoreQueryFilters) does not apply. +/// +/// Any EF-mapped class (does not need to inherit BaseEntity). +public interface IPlainRepository where T : class +{ + Task GetByIdAsync(int id); + Task> GetAllAsync(); + Task> FindAsync(Expression> predicate); + Task FirstOrDefaultAsync(Expression> predicate); + Task AnyAsync(Expression> predicate); + Task CountAsync(Expression>? predicate = null); + + Task AddAsync(T entity); + Task> AddRangeAsync(IEnumerable entities); + + Task UpdateAsync(T entity); + + Task DeleteAsync(T entity); + Task DeleteAsync(int id); +} diff --git a/src/PowderCoating.Core/Interfaces/IUnitOfWork.cs b/src/PowderCoating.Core/Interfaces/IUnitOfWork.cs index 48bc9c9..1e5df77 100644 --- a/src/PowderCoating.Core/Interfaces/IUnitOfWork.cs +++ b/src/PowderCoating.Core/Interfaces/IUnitOfWork.cs @@ -14,14 +14,14 @@ public interface IUnitOfWork : IDisposable IRepository AiItemPredictions { get; } // Powder Insights - IRepository PowderUsageLogs { get; } + IPowderUsageLogRepository PowderUsageLogs { get; } // Core entities — typed repositories for complex domains ICustomerRepository Customers { get; } IJobRepository Jobs { get; } IRepository JobDailyPriorities { get; } IRepository JobItems { get; } - IRepository JobItemCoats { get; } + IJobItemCoatRepository JobItemCoats { get; } IRepository JobItemPrepServices { get; } IRepository JobChangeHistories { get; } IRepository JobPrepServices { get; } @@ -32,13 +32,13 @@ public interface IUnitOfWork : IDisposable IRepository QuoteItemPrepServices { get; } IRepository QuoteChangeHistories { get; } IRepository InventoryItems { get; } - IRepository InventoryTransactions { get; } + IInventoryTransactionRepository InventoryTransactions { get; } IRepository Equipment { get; } IRepository OvenCosts { get; } IRepository BlastSetups { get; } IRepository MaintenanceRecords { get; } IRepository Vendors { get; } - IRepository JobPhotos { get; } + IJobPhotoRepository JobPhotos { get; } IRepository JobNotes { get; } IRepository CustomerNotes { get; } IRepository JobStatusHistory { get; } @@ -97,13 +97,21 @@ public interface IUnitOfWork : IDisposable IRepository SubscriptionPlanConfigs { get; } // Job Templates - IRepository JobTemplates { get; } + IJobTemplateRepository JobTemplates { get; } IRepository JobTemplateItems { get; } IRepository JobTemplateItemCoats { get; } IRepository JobTemplateItemPrepServices { get; } + // Platform content (SuperAdmin-managed, no tenant filter, no soft delete) + IPlainRepository Announcements { get; } + IPlainRepository BannedIps { get; } + IPlainRepository DashboardTips { get; } + IRepository InAppNotifications { get; } + IPlainRepository ReleaseNotes { get; } + // Bug Reports IRepository BugReports { get; } + IRepository BugReportAttachments { get; } // Contact Us IRepository ContactSubmissions { get; } diff --git a/src/PowderCoating.Core/Interfaces/Repositories/IBillRepository.cs b/src/PowderCoating.Core/Interfaces/Repositories/IBillRepository.cs index 58441a3..3ebc7df 100644 --- a/src/PowderCoating.Core/Interfaces/Repositories/IBillRepository.cs +++ b/src/PowderCoating.Core/Interfaces/Repositories/IBillRepository.cs @@ -39,4 +39,11 @@ public interface IBillRepository : IRepository /// for sequential payment reference generation. /// Task GetLastPaymentNumberAsync(string prefix); + + /// + /// Returns all non-deleted bills whose BillDate falls within [, + /// ], with Vendor, LineItems → Account, and Payments loaded. + /// Used by the accounting data export to produce QuickBooks IIF / CSV files. + /// + Task> GetForDateRangeAsync(DateTime start, DateTime end); } diff --git a/src/PowderCoating.Core/Interfaces/Repositories/IInventoryTransactionRepository.cs b/src/PowderCoating.Core/Interfaces/Repositories/IInventoryTransactionRepository.cs new file mode 100644 index 0000000..32f1993 --- /dev/null +++ b/src/PowderCoating.Core/Interfaces/Repositories/IInventoryTransactionRepository.cs @@ -0,0 +1,22 @@ +using PowderCoating.Core.Entities; +using PowderCoating.Core.Enums; + +namespace PowderCoating.Core.Interfaces.Repositories; + +/// +/// Typed repository for that adds a dynamic-filter +/// query for the Inventory Ledger view on top of the generic CRUD interface. +/// +public interface IInventoryTransactionRepository : IRepository +{ + /// + /// Returns up to 500 non-deleted inventory transactions matching the supplied filters, + /// ordered newest-first, with InventoryItem, PurchaseOrder, and Job navigations loaded. + /// Null parameter values are treated as "no filter" for that dimension. + /// + Task> GetForLedgerAsync( + int? itemId, + DateTime? from, + DateTime? to, + InventoryTransactionType? type); +} diff --git a/src/PowderCoating.Core/Interfaces/Repositories/IJobItemCoatRepository.cs b/src/PowderCoating.Core/Interfaces/Repositories/IJobItemCoatRepository.cs new file mode 100644 index 0000000..e4a9387 --- /dev/null +++ b/src/PowderCoating.Core/Interfaces/Repositories/IJobItemCoatRepository.cs @@ -0,0 +1,40 @@ +using PowderCoating.Core.Entities; + +namespace PowderCoating.Core.Interfaces.Repositories; + +/// +/// Typed repository for that adds ThenInclude-based load methods the +/// generic cannot express. Used by DashboardController for powder +/// order marking, powder receipt, and custom powder inventory creation. +/// +public interface IJobItemCoatRepository : IRepository +{ + /// + /// Loads a coat with the full vendor + job chain needed by the MarkPowderOrdered action: + /// JobItem → Job → Customer, InventoryItem → PrimaryVendor, and direct + /// Vendor. Returns if not found. + /// + Task LoadForOrderMarkingAsync(int id); + + /// + /// Loads a coat with only InventoryItem included — used by ReceivePowder for + /// the initial stock update. Returns if not found. + /// + Task LoadWithInventoryAsync(int id); + + /// + /// Loads a coat with JobItem → Job included — used by ReceivePowder to verify + /// company ownership when the initial load did not include the job chain. EF Core identity-map + /// fixup propagates JobItem back to any previously tracked instance of the same coat. + /// Returns if not found. + /// + Task LoadWithJobChainAsync(int id); + + /// + /// Returns all non-deleted coats that have no linked inventory item and belong to + /// , excluding . Used by + /// AddCustomPowderToInventory to link sibling coats to the newly created item. + /// Entities are tracked so that InventoryItemId mutations are saved via UnitOfWork. + /// + Task> GetCandidateCoatsForLinkingAsync(int excludeCoatId, int companyId); +} diff --git a/src/PowderCoating.Core/Interfaces/Repositories/IJobPhotoRepository.cs b/src/PowderCoating.Core/Interfaces/Repositories/IJobPhotoRepository.cs new file mode 100644 index 0000000..e57fcee --- /dev/null +++ b/src/PowderCoating.Core/Interfaces/Repositories/IJobPhotoRepository.cs @@ -0,0 +1,28 @@ +using PowderCoating.Core.Entities; + +namespace PowderCoating.Core.Interfaces.Repositories; + +/// +/// Typed repository for that adds inventory-specific photo lookup +/// queries on top of the generic CRUD interface. These queries require multi-level +/// ThenInclude chains and dynamic filtering that the generic +/// cannot express. +/// +public interface IJobPhotoRepository : IRepository +{ + /// + /// Returns all non-deleted, tagged job photos whose Tags field contains + /// or (SQL LIKE), ordered + /// newest-first, with Job → Customer navigation loaded. The caller performs an + /// exact-token match in memory to reject false positives before paginating. + /// + Task> GetTaggedPhotosAsync(string? colorName, string? itemName); + + /// + /// Returns all non-deleted job photos from jobs that use a specific inventory item + /// in any coat, matched via JobItemCoat.InventoryItemId. Loads + /// Job → Customer and Job → JobItems → Coats navigations. Used by the + /// Photos by Powder panel on the inventory item detail page. + /// + Task> GetPhotosByPowderItemAsync(int inventoryItemId); +} diff --git a/src/PowderCoating.Core/Interfaces/Repositories/IJobRepository.cs b/src/PowderCoating.Core/Interfaces/Repositories/IJobRepository.cs index 18c8c3c..4401b74 100644 --- a/src/PowderCoating.Core/Interfaces/Repositories/IJobRepository.cs +++ b/src/PowderCoating.Core/Interfaces/Repositories/IJobRepository.cs @@ -78,4 +78,11 @@ public interface IJobRepository : IRepository /// Returns null if not found. /// Task LoadForCostingAsync(int jobId, int companyId); + + /// + /// Loads a single job with JobItems → Coats and JobItems → PrepServices for deep-copying + /// into a new via SaveJobAsTemplate. + /// Returns null if not found or soft-deleted. + /// + Task LoadForTemplateSnapshotAsync(int jobId); } diff --git a/src/PowderCoating.Core/Interfaces/Repositories/IJobTemplateRepository.cs b/src/PowderCoating.Core/Interfaces/Repositories/IJobTemplateRepository.cs new file mode 100644 index 0000000..92b3a10 --- /dev/null +++ b/src/PowderCoating.Core/Interfaces/Repositories/IJobTemplateRepository.cs @@ -0,0 +1,25 @@ +using PowderCoating.Core.Entities; + +namespace PowderCoating.Core.Interfaces.Repositories; + +/// +/// Typed repository for that extends the generic CRUD interface with +/// domain-specific queries requiring multi-level include chains the generic +/// cannot express. +/// +public interface IJobTemplateRepository : IRepository +{ + /// + /// Loads a single template with the full include chain for the Details view: + /// Customer, Items (with Coats → InventoryItem and PrepServices → PrepService). + /// Returns null if not found or soft-deleted. + /// + Task LoadForDetailsAsync(int id); + + /// + /// Returns all active, non-deleted templates for the current company with Customer, + /// Items → Coats, and Items → PrepServices → PrepService loaded. Used by the + /// GetTemplatesJson AJAX endpoint to hydrate the job creation wizard. + /// + Task> GetAllActiveWithFullIncludesAsync(); +} diff --git a/src/PowderCoating.Core/Interfaces/Repositories/INotificationLogRepository.cs b/src/PowderCoating.Core/Interfaces/Repositories/INotificationLogRepository.cs index c5759a2..ad4421b 100644 --- a/src/PowderCoating.Core/Interfaces/Repositories/INotificationLogRepository.cs +++ b/src/PowderCoating.Core/Interfaces/Repositories/INotificationLogRepository.cs @@ -1,4 +1,5 @@ using PowderCoating.Core.Entities; +using PowderCoating.Core.Enums; namespace PowderCoating.Core.Interfaces.Repositories; @@ -27,4 +28,19 @@ public interface INotificationLogRepository : IRepository /// Returns all notification log entries for the given job, newest-first. Task> GetAllForJobAsync(int jobId); + + /// + /// Returns a paginated, filtered, and sorted page of notification log entries with Customer, + /// Job, and Quote navigations loaded. All filter parameters are optional — omit to include all. + /// Used by the company-scoped notification log index view. + /// + Task<(List Items, int TotalCount)> GetPagedFilteredAsync( + int pageNumber, int pageSize, + string? searchTerm = null, + NotificationChannel? channel = null, + NotificationStatus? status = null, + NotificationType? type = null, + int? jobId = null, + string sortColumn = "SentAt", + string sortDirection = "desc"); } diff --git a/src/PowderCoating.Core/Interfaces/Repositories/IPowderUsageLogRepository.cs b/src/PowderCoating.Core/Interfaces/Repositories/IPowderUsageLogRepository.cs new file mode 100644 index 0000000..ffffa6b --- /dev/null +++ b/src/PowderCoating.Core/Interfaces/Repositories/IPowderUsageLogRepository.cs @@ -0,0 +1,17 @@ +using PowderCoating.Core.Entities; + +namespace PowderCoating.Core.Interfaces.Repositories; + +/// +/// Typed repository for that adds a dynamic-filter +/// query for the Inventory Ledger usage tab on top of the generic CRUD interface. +/// +public interface IPowderUsageLogRepository : IRepository +{ + /// + /// Returns up to 500 non-deleted powder usage logs matching the supplied filters, + /// ordered newest-first, with Job → Customer, InventoryItem, and JobItemCoat + /// navigations loaded. Null parameter values are treated as "no filter". + /// + Task> GetForLedgerAsync(int? itemId, DateTime? from, DateTime? to); +} diff --git a/src/PowderCoating.Core/Interfaces/Services/IAuditLogService.cs b/src/PowderCoating.Core/Interfaces/Services/IAuditLogService.cs new file mode 100644 index 0000000..cb1b97f --- /dev/null +++ b/src/PowderCoating.Core/Interfaces/Services/IAuditLogService.cs @@ -0,0 +1,24 @@ +using PowderCoating.Core.Entities; + +namespace PowderCoating.Core.Interfaces.Services; + +/// +/// Writes platform-wide audit trail entries. is not a +/// (no soft delete, no tenant filter), so it cannot use the generic +/// ; this service provides the only write path for audit records +/// and keeps ApplicationDbContext out of controller constructors. +/// +public interface IAuditLogService +{ + /// + /// Persists to the audit log immediately. + /// + Task LogAsync(AuditLog entry); + + /// + /// Returns the most recent audit log entries for the given user + /// where EntityType == "ApplicationUser", ordered newest-first. Used by the + /// SuperAdmin user login history panel. + /// + Task> GetUserActivityAsync(string userId, int limit = 50); +} diff --git a/src/PowderCoating.Core/Interfaces/Services/ICompanyDataPurgeService.cs b/src/PowderCoating.Core/Interfaces/Services/ICompanyDataPurgeService.cs new file mode 100644 index 0000000..bfd5f55 --- /dev/null +++ b/src/PowderCoating.Core/Interfaces/Services/ICompanyDataPurgeService.cs @@ -0,0 +1,26 @@ +namespace PowderCoating.Core.Interfaces.Services; + +/// +/// Destructive data-purge operations for the SuperAdmin company management UI. +/// All methods use bulk ExecuteDeleteAsync against ApplicationDbContext directly; +/// they are intentional exceptions to the IUnitOfWork pattern, mirroring +/// DataPurgeController and AccountDataExportController in the documented exceptions list. +/// +public interface ICompanyDataPurgeService +{ + /// + /// Deletes all business-data tables for but does NOT delete the + /// company record or Identity users. The caller is responsible for deleting users via + /// UserManager and the company record via after this call. + /// must be loaded beforehand so announcement-dismissal + /// records that reference users (rather than the company directly) can be cleaned up. + /// + Task DeleteAllBusinessDataAsync(int companyId, IReadOnlyList companyUserIds); + + /// + /// Deletes all business data for while preserving the company + /// record, users, operating costs, preferences, and lookup tables. Also clears the + /// QuickBooks migration state from CompanyPreferences. Used by the ResetData action. + /// + Task ResetBusinessDataAsync(int companyId); +} diff --git a/src/PowderCoating.Core/Interfaces/Services/ICompanyListService.cs b/src/PowderCoating.Core/Interfaces/Services/ICompanyListService.cs new file mode 100644 index 0000000..afe7632 --- /dev/null +++ b/src/PowderCoating.Core/Interfaces/Services/ICompanyListService.cs @@ -0,0 +1,39 @@ +using PowderCoating.Core.Entities; + +namespace PowderCoating.Core.Interfaces.Services; + +/// +/// Wizard completion metadata surfaced in the company list view. +/// +public record CompanyWizardInfo(bool Completed, DateTime? CompletedAt, string? CompletedByName); + +/// +/// Per-company entity count summary used to populate the Index list without N+1 round-trips. +/// +public record CompanyCountSummary( + IReadOnlyDictionary JobCounts, + IReadOnlyDictionary QuoteCounts, + IReadOnlyDictionary CustomerCounts, + IReadOnlyDictionary WizardInfo +); + +/// +/// Read service for the SuperAdmin company list. Wraps queries that require +/// IgnoreQueryFilters(), dynamic search/sort, and cross-entity GROUP BY aggregations — +/// patterns the generic cannot express. +/// +public interface ICompanyListService +{ + /// + /// Returns a paged, searched, and sorted slice of non-deleted companies together with the + /// total unfiltered count for pagination. + /// + Task<(List Companies, int TotalCount)> GetPagedAsync( + string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize); + + /// + /// Returns job, quote, customer, and wizard completion counts for each of the supplied + /// company IDs in three GROUP BY queries instead of N+1 individual lookups. + /// + Task GetCountSummaryAsync(IReadOnlyList companyIds); +} diff --git a/src/PowderCoating.Core/Interfaces/Services/IDashboardReadService.cs b/src/PowderCoating.Core/Interfaces/Services/IDashboardReadService.cs new file mode 100644 index 0000000..c9e0435 --- /dev/null +++ b/src/PowderCoating.Core/Interfaces/Services/IDashboardReadService.cs @@ -0,0 +1,42 @@ +using PowderCoating.Core.Entities; + +namespace PowderCoating.Core.Interfaces.Services; + +/// +/// Result record carrying all pre-fetched entity lists and aggregates needed to render the operator +/// dashboard index view. Raw entities are returned so the controller can apply in-memory +/// filtering, grouping, and DTO projection without additional round-trips. +/// +public record DashboardIndexData( + List ActiveJobs, + decimal MonthlyRevenue, + List TodaysAppointments, + List UpcomingMaintenance, + List PendingQuotes, + List OpenInvoices, + decimal InvoicedThisMonth, + decimal CollectedThisMonth, + List RecentPayments, + List RecentQuotes, + List RecentJobs, + List JobsNeedingPowder, + List JobsWithOrderedPowder, + List BillsDue, + string? TipOfTheDay +); + +/// +/// Read-only service for the dashboard. All methods execute complex queries that require +/// ThenInclude chains or navigation-property predicates beyond what the generic +/// can express. Lives in Infrastructure so ApplicationDbContext +/// is available; injected into the controller via DI. +/// +public interface IDashboardReadService +{ + /// Fetches all data needed to render the tenant operator dashboard. + /// The local date used for date-range predicates (today, start-of-month, etc.). + Task GetIndexDataAsync(DateTime today); + + /// Returns the total count of tenant users (CompanyId > 0) for the SuperAdmin dashboard. + Task GetTotalUserCountAsync(); +} diff --git a/src/PowderCoating.Infrastructure/Repositories/BillRepository.cs b/src/PowderCoating.Infrastructure/Repositories/BillRepository.cs index 1c41dec..ef7eed9 100644 --- a/src/PowderCoating.Infrastructure/Repositories/BillRepository.cs +++ b/src/PowderCoating.Infrastructure/Repositories/BillRepository.cs @@ -89,4 +89,17 @@ public class BillRepository : Repository, IBillRepository .Select(p => p.PaymentNumber) .FirstOrDefaultAsync(); } + + /// + public async Task> GetForDateRangeAsync(DateTime start, DateTime end) + { + return await _context.Bills + .Where(b => !b.IsDeleted && b.BillDate >= start && b.BillDate <= end) + .Include(b => b.Vendor) + .Include(b => b.LineItems.Where(li => !li.IsDeleted)) + .ThenInclude(li => li.Account) + .Include(b => b.Payments.Where(p => !p.IsDeleted)) + .OrderBy(b => b.BillDate) + .ToListAsync(); + } } diff --git a/src/PowderCoating.Infrastructure/Repositories/InventoryTransactionRepository.cs b/src/PowderCoating.Infrastructure/Repositories/InventoryTransactionRepository.cs new file mode 100644 index 0000000..84d965a --- /dev/null +++ b/src/PowderCoating.Infrastructure/Repositories/InventoryTransactionRepository.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore; +using PowderCoating.Core.Entities; +using PowderCoating.Core.Enums; +using PowderCoating.Core.Interfaces.Repositories; +using PowderCoating.Infrastructure.Data; + +namespace PowderCoating.Infrastructure.Repositories; + +/// +/// Typed repository for that adds a dynamic-filter +/// ledger query on top of the generic . +/// +public class InventoryTransactionRepository : Repository, IInventoryTransactionRepository +{ + public InventoryTransactionRepository(ApplicationDbContext context) : base(context) { } + + /// + public async Task> GetForLedgerAsync( + int? itemId, + DateTime? from, + DateTime? to, + InventoryTransactionType? type) + { + var query = _context.InventoryTransactions + .AsNoTracking() + .Include(t => t.InventoryItem) + .Include(t => t.PurchaseOrder) + .Include(t => t.Job) + .Where(t => !t.IsDeleted); + + if (itemId.HasValue) + query = query.Where(t => t.InventoryItemId == itemId.Value); + if (from.HasValue) + query = query.Where(t => t.TransactionDate >= from.Value); + if (to.HasValue) + query = query.Where(t => t.TransactionDate < to.Value.AddDays(1)); + if (type.HasValue) + query = query.Where(t => t.TransactionType == type.Value); + + return await query + .OrderByDescending(t => t.TransactionDate) + .Take(500) + .ToListAsync(); + } +} diff --git a/src/PowderCoating.Infrastructure/Repositories/JobItemCoatRepository.cs b/src/PowderCoating.Infrastructure/Repositories/JobItemCoatRepository.cs new file mode 100644 index 0000000..2dc2e1b --- /dev/null +++ b/src/PowderCoating.Infrastructure/Repositories/JobItemCoatRepository.cs @@ -0,0 +1,52 @@ +using Microsoft.EntityFrameworkCore; +using PowderCoating.Core.Entities; +using PowderCoating.Core.Interfaces.Repositories; +using PowderCoating.Infrastructure.Data; + +namespace PowderCoating.Infrastructure.Repositories; + +/// +/// Typed repository for that adds ThenInclude-based load methods the +/// generic cannot express. +/// +public class JobItemCoatRepository : Repository, IJobItemCoatRepository +{ + public JobItemCoatRepository(ApplicationDbContext context) : base(context) { } + + /// + public async Task LoadForOrderMarkingAsync(int id) + { + return await _context.JobItemCoats + .Include(c => c.JobItem).ThenInclude(i => i.Job).ThenInclude(j => j.Customer) + .Include(c => c.Vendor) + .Include(c => c.InventoryItem).ThenInclude(i => i!.PrimaryVendor) + .FirstOrDefaultAsync(c => c.Id == id); + } + + /// + public async Task LoadWithInventoryAsync(int id) + { + return await _context.JobItemCoats + .Include(c => c.InventoryItem) + .FirstOrDefaultAsync(c => c.Id == id); + } + + /// + public async Task LoadWithJobChainAsync(int id) + { + return await _context.JobItemCoats + .Include(c => c.JobItem).ThenInclude(i => i.Job) + .FirstOrDefaultAsync(c => c.Id == id); + } + + /// + public async Task> GetCandidateCoatsForLinkingAsync(int excludeCoatId, int companyId) + { + return await _context.JobItemCoats + .Include(c => c.JobItem) + .Where(c => c.Id != excludeCoatId + && c.InventoryItemId == null + && c.JobItem.CompanyId == companyId) + .ToListAsync(); + } +} diff --git a/src/PowderCoating.Infrastructure/Repositories/JobPhotoRepository.cs b/src/PowderCoating.Infrastructure/Repositories/JobPhotoRepository.cs new file mode 100644 index 0000000..1693a0a --- /dev/null +++ b/src/PowderCoating.Infrastructure/Repositories/JobPhotoRepository.cs @@ -0,0 +1,52 @@ +using Microsoft.EntityFrameworkCore; +using PowderCoating.Core.Entities; +using PowderCoating.Core.Interfaces.Repositories; +using PowderCoating.Infrastructure.Data; + +namespace PowderCoating.Infrastructure.Repositories; + +/// +/// Typed repository for that provides inventory-specific photo +/// lookup queries requiring multi-level ThenInclude chains that the generic +/// cannot express. +/// +public class JobPhotoRepository : Repository, IJobPhotoRepository +{ + public JobPhotoRepository(ApplicationDbContext context) : base(context) { } + + /// + public async Task> GetTaggedPhotosAsync(string? colorName, string? itemName) + { + var query = _context.JobPhotos + .AsNoTracking() + .Include(p => p.Job) + .ThenInclude(j => j!.Customer) + .Where(p => !p.IsDeleted && p.Tags != null && p.Tags != ""); + + if (!string.IsNullOrEmpty(colorName) && !string.IsNullOrEmpty(itemName) && colorName != itemName) + query = query.Where(p => p.Tags!.ToLower().Contains(colorName) || p.Tags!.ToLower().Contains(itemName)); + else if (!string.IsNullOrEmpty(colorName)) + query = query.Where(p => p.Tags!.ToLower().Contains(colorName)); + else if (!string.IsNullOrEmpty(itemName)) + query = query.Where(p => p.Tags!.ToLower().Contains(itemName)); + + return await query.OrderByDescending(p => p.UploadedDate).ToListAsync(); + } + + /// + public async Task> GetPhotosByPowderItemAsync(int inventoryItemId) + { + return await _context.JobPhotos + .AsNoTracking() + .Include(p => p.Job) + .ThenInclude(j => j!.Customer) + .Include(p => p.Job) + .ThenInclude(j => j!.JobItems) + .ThenInclude(ji => ji.Coats) + .Where(p => !p.IsDeleted && + p.Job != null && + p.Job.JobItems.Any(ji => ji.Coats.Any(c => c.InventoryItemId == inventoryItemId))) + .OrderByDescending(p => p.UploadedDate) + .ToListAsync(); + } +} diff --git a/src/PowderCoating.Infrastructure/Repositories/JobRepository.cs b/src/PowderCoating.Infrastructure/Repositories/JobRepository.cs index 2504c0a..08ffe70 100644 --- a/src/PowderCoating.Infrastructure/Repositories/JobRepository.cs +++ b/src/PowderCoating.Infrastructure/Repositories/JobRepository.cs @@ -175,4 +175,16 @@ public class JobRepository : Repository, IJobRepository .AsNoTracking() .FirstOrDefaultAsync(); } + + /// + public async Task LoadForTemplateSnapshotAsync(int jobId) + { + return await _context.Jobs + .Where(j => j.Id == jobId && !j.IsDeleted) + .Include(j => j.JobItems.Where(i => !i.IsDeleted)) + .ThenInclude(i => i.Coats.Where(c => !c.IsDeleted)) + .Include(j => j.JobItems.Where(i => !i.IsDeleted)) + .ThenInclude(i => i.PrepServices.Where(p => !p.IsDeleted)) + .FirstOrDefaultAsync(); + } } diff --git a/src/PowderCoating.Infrastructure/Repositories/JobTemplateRepository.cs b/src/PowderCoating.Infrastructure/Repositories/JobTemplateRepository.cs new file mode 100644 index 0000000..0f18fa8 --- /dev/null +++ b/src/PowderCoating.Infrastructure/Repositories/JobTemplateRepository.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore; +using PowderCoating.Core.Entities; +using PowderCoating.Core.Interfaces.Repositories; +using PowderCoating.Infrastructure.Data; + +namespace PowderCoating.Infrastructure.Repositories; + +/// +/// Typed repository for that provides domain-specific multi-level +/// include queries that the generic cannot express. +/// The base class handles all standard CRUD operations; this class adds the read queries +/// that require ThenInclude chains for items, coats, and prep services. +/// +public class JobTemplateRepository : Repository, IJobTemplateRepository +{ + public JobTemplateRepository(ApplicationDbContext context) : base(context) { } + + /// + public async Task LoadForDetailsAsync(int id) + { + return await _context.JobTemplates + .Where(t => t.Id == id && !t.IsDeleted) + .Include(t => t.Customer) + .Include(t => t.Items.Where(i => !i.IsDeleted)) + .ThenInclude(i => i.Coats.Where(c => !c.IsDeleted)) + .ThenInclude(c => c.InventoryItem) + .Include(t => t.Items.Where(i => !i.IsDeleted)) + .ThenInclude(i => i.PrepServices.Where(p => !p.IsDeleted)) + .ThenInclude(p => p.PrepService) + .FirstOrDefaultAsync(); + } + + /// + public async Task> GetAllActiveWithFullIncludesAsync() + { + return await _context.JobTemplates + .Where(t => !t.IsDeleted && t.IsActive) + .Include(t => t.Customer) + .Include(t => t.Items.Where(i => !i.IsDeleted)) + .ThenInclude(i => i.Coats.Where(c => !c.IsDeleted)) + .Include(t => t.Items.Where(i => !i.IsDeleted)) + .ThenInclude(i => i.PrepServices.Where(p => !p.IsDeleted)) + .ThenInclude(p => p.PrepService) + .OrderBy(t => t.Name) + .ToListAsync(); + } +} diff --git a/src/PowderCoating.Infrastructure/Repositories/NotificationLogRepository.cs b/src/PowderCoating.Infrastructure/Repositories/NotificationLogRepository.cs index 86b356c..d6fd49e 100644 --- a/src/PowderCoating.Infrastructure/Repositories/NotificationLogRepository.cs +++ b/src/PowderCoating.Infrastructure/Repositories/NotificationLogRepository.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using PowderCoating.Core.Entities; +using PowderCoating.Core.Enums; using PowderCoating.Core.Interfaces.Repositories; using PowderCoating.Infrastructure.Data; @@ -60,4 +61,69 @@ public class NotificationLogRepository : Repository, INotificat .Where(n => n.JobId == jobId) .OrderByDescending(n => n.SentAt) .ToListAsync(); + + /// + public async Task<(List Items, int TotalCount)> GetPagedFilteredAsync( + int pageNumber, int pageSize, + string? searchTerm = null, + NotificationChannel? channel = null, + NotificationStatus? status = null, + NotificationType? type = null, + int? jobId = null, + string sortColumn = "SentAt", + string sortDirection = "desc") + { + var query = _dbSet + .AsNoTracking() + .Include(n => n.Customer) + .Include(n => n.Job) + .Include(n => n.Quote) + .AsQueryable(); + + if (jobId.HasValue) + query = query.Where(n => n.JobId == jobId.Value); + + if (!string.IsNullOrWhiteSpace(searchTerm)) + { + var s = searchTerm.ToLower(); + query = query.Where(n => + n.RecipientName.ToLower().Contains(s) || + n.Recipient.ToLower().Contains(s) || + (n.Subject != null && n.Subject.ToLower().Contains(s)) || + (n.Job != null && n.Job.JobNumber.ToLower().Contains(s)) || + (n.Quote != null && n.Quote.QuoteNumber.ToLower().Contains(s))); + } + + if (channel.HasValue) + query = query.Where(n => n.Channel == channel.Value); + + if (status.HasValue) + query = query.Where(n => n.Status == status.Value); + + if (type.HasValue) + query = query.Where(n => n.NotificationType == type.Value); + + var totalCount = await query.CountAsync(); + + query = (sortColumn, sortDirection) switch + { + ("RecipientName", "asc") => query.OrderBy(n => n.RecipientName), + ("RecipientName", _) => query.OrderByDescending(n => n.RecipientName), + ("Channel", "asc") => query.OrderBy(n => n.Channel), + ("Channel", _) => query.OrderByDescending(n => n.Channel), + ("Status", "asc") => query.OrderBy(n => n.Status), + ("Status", _) => query.OrderByDescending(n => n.Status), + ("Type", "asc") => query.OrderBy(n => n.NotificationType), + ("Type", _) => query.OrderByDescending(n => n.NotificationType), + (_, "asc") => query.OrderBy(n => n.SentAt), + _ => query.OrderByDescending(n => n.SentAt) + }; + + var items = await query + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + return (items, totalCount); + } } diff --git a/src/PowderCoating.Infrastructure/Repositories/PlainRepository.cs b/src/PowderCoating.Infrastructure/Repositories/PlainRepository.cs new file mode 100644 index 0000000..fe4b605 --- /dev/null +++ b/src/PowderCoating.Infrastructure/Repositories/PlainRepository.cs @@ -0,0 +1,73 @@ +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore; +using PowderCoating.Core.Interfaces; +using PowderCoating.Infrastructure.Data; + +namespace PowderCoating.Infrastructure.Repositories; + +/// +/// Generic repository for platform-level entities that do not inherit BaseEntity +/// (Announcement, BannedIp, DashboardTip, ReleaseNote). No global query filters apply +/// to these entities, so no IgnoreQueryFilters support is needed. All writes are staged +/// in the EF change tracker — call IUnitOfWork.CompleteAsync() to flush. +/// +public class PlainRepository : IPlainRepository where T : class +{ + protected readonly ApplicationDbContext _context; + protected readonly DbSet _dbSet; + + public PlainRepository(ApplicationDbContext context) + { + _context = context; + _dbSet = context.Set(); + } + + public virtual async Task GetByIdAsync(int id) + => await _dbSet.FindAsync(id); + + public virtual async Task> GetAllAsync() + => await _dbSet.ToListAsync(); + + public virtual async Task> FindAsync(Expression> predicate) + => await _dbSet.Where(predicate).ToListAsync(); + + public virtual async Task FirstOrDefaultAsync(Expression> predicate) + => await _dbSet.FirstOrDefaultAsync(predicate); + + public virtual async Task AnyAsync(Expression> predicate) + => await _dbSet.AnyAsync(predicate); + + public virtual async Task CountAsync(Expression>? predicate = null) + => predicate == null ? await _dbSet.CountAsync() : await _dbSet.CountAsync(predicate); + + public virtual async Task AddAsync(T entity) + { + await _dbSet.AddAsync(entity); + return entity; + } + + public virtual async Task> AddRangeAsync(IEnumerable entities) + { + await _dbSet.AddRangeAsync(entities); + return entities; + } + + public virtual Task UpdateAsync(T entity) + { + _dbSet.Update(entity); + return Task.CompletedTask; + } + + public virtual async Task DeleteAsync(T entity) + { + _dbSet.Remove(entity); + await Task.CompletedTask; + } + + public virtual async Task DeleteAsync(int id) + { + var entity = await GetByIdAsync(id); + if (entity != null) + await DeleteAsync(entity); + } +} diff --git a/src/PowderCoating.Infrastructure/Repositories/PowderUsageLogRepository.cs b/src/PowderCoating.Infrastructure/Repositories/PowderUsageLogRepository.cs new file mode 100644 index 0000000..fdfa50a --- /dev/null +++ b/src/PowderCoating.Infrastructure/Repositories/PowderUsageLogRepository.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore; +using PowderCoating.Core.Entities; +using PowderCoating.Core.Interfaces.Repositories; +using PowderCoating.Infrastructure.Data; + +namespace PowderCoating.Infrastructure.Repositories; + +/// +/// Typed repository for that adds a dynamic-filter +/// ledger query on top of the generic . +/// +public class PowderUsageLogRepository : Repository, IPowderUsageLogRepository +{ + public PowderUsageLogRepository(ApplicationDbContext context) : base(context) { } + + /// + public async Task> GetForLedgerAsync(int? itemId, DateTime? from, DateTime? to) + { + var query = _context.PowderUsageLogs + .AsNoTracking() + .Include(u => u.Job) + .ThenInclude(j => j!.Customer) + .Include(u => u.InventoryItem) + .Include(u => u.JobItemCoat) + .Where(u => !u.IsDeleted); + + if (itemId.HasValue) + query = query.Where(u => u.InventoryItemId == itemId.Value); + if (from.HasValue) + query = query.Where(u => u.RecordedAt >= from.Value); + if (to.HasValue) + query = query.Where(u => u.RecordedAt < to.Value.AddDays(1)); + + return await query + .OrderByDescending(u => u.RecordedAt) + .Take(500) + .ToListAsync(); + } +} diff --git a/src/PowderCoating.Infrastructure/Repositories/UnitOfWork.cs b/src/PowderCoating.Infrastructure/Repositories/UnitOfWork.cs index a2d2c92..d20d2e1 100644 --- a/src/PowderCoating.Infrastructure/Repositories/UnitOfWork.cs +++ b/src/PowderCoating.Infrastructure/Repositories/UnitOfWork.cs @@ -41,14 +41,14 @@ public class UnitOfWork : IUnitOfWork private IRepository? _aiItemPredictions; // Powder Insights - private IRepository? _powderUsageLogs; + private IPowderUsageLogRepository? _powderUsageLogs; // Core repositories private ICustomerRepository? _customers; private IJobRepository? _jobs; private IRepository? _jobDailyPriorities; private IRepository? _jobItems; - private IRepository? _jobItemCoats; + private IJobItemCoatRepository? _jobItemCoats; private IRepository? _jobItemPrepServices; private IRepository? _jobChangeHistories; private IRepository? _jobPrepServices; @@ -59,13 +59,13 @@ public class UnitOfWork : IUnitOfWork private IRepository? _quoteItemPrepServices; private IRepository? _quoteChangeHistories; private IRepository? _inventoryItems; - private IRepository? _inventoryTransactions; + private IInventoryTransactionRepository? _inventoryTransactions; private IRepository? _equipment; private IRepository? _ovenCosts; private IRepository? _blastSetups; private IRepository? _maintenanceRecords; private IRepository? _vendors; - private IRepository? _jobPhotos; + private IJobPhotoRepository? _jobPhotos; private IRepository? _jobNotes; private IRepository? _customerNotes; private IRepository? _jobStatusHistory; @@ -97,13 +97,21 @@ public class UnitOfWork : IUnitOfWork private IRepository? _subscriptionPlanConfigs; // Job Templates - private IRepository? _jobTemplates; + private IJobTemplateRepository? _jobTemplates; private IRepository? _jobTemplateItems; private IRepository? _jobTemplateItemCoats; private IRepository? _jobTemplateItemPrepServices; + // Platform content + private IPlainRepository? _announcements; + private IPlainRepository? _bannedIps; + private IPlainRepository? _dashboardTips; + private IRepository? _inAppNotifications; + private IPlainRepository? _releaseNotes; + // Bug Reports private IRepository? _bugReports; + private IRepository? _bugReportAttachments; private IRepository? _contactSubmissions; private IRepository? _manufacturerLookupPatterns; @@ -169,8 +177,8 @@ public class UnitOfWork : IUnitOfWork // Powder Insights /// Repository for records capturing per-coat powder consumption; used by powder-usage analytics. - public IRepository PowderUsageLogs => - _powderUsageLogs ??= new Repository(_context); + public IPowderUsageLogRepository PowderUsageLogs => + _powderUsageLogs ??= new PowderUsageLogRepository(_context); // Core repositories /// Repository for records (commercial and non-commercial); tenant-filtered with soft delete. @@ -190,8 +198,8 @@ public class UnitOfWork : IUnitOfWork _jobItems ??= new Repository(_context); /// Repository for powder coat passes; tenant-filtered with soft delete. - public IRepository JobItemCoats => - _jobItemCoats ??= new Repository(_context); + public IJobItemCoatRepository JobItemCoats => + _jobItemCoats ??= new JobItemCoatRepository(_context); public IRepository JobItemPrepServices => _jobItemPrepServices ??= new Repository(_context); @@ -232,8 +240,8 @@ public class UnitOfWork : IUnitOfWork _inventoryItems ??= new Repository(_context); /// Repository for stock movements; tenant-filtered with soft delete. - public IRepository InventoryTransactions => - _inventoryTransactions ??= new Repository(_context); + public IInventoryTransactionRepository InventoryTransactions => + _inventoryTransactions ??= new InventoryTransactionRepository(_context); /// Repository for records (ovens, sandblasters, booths); tenant-filtered with soft delete. public IRepository Equipment => @@ -256,8 +264,8 @@ public class UnitOfWork : IUnitOfWork _vendors ??= new Repository(_context); /// Repository for attachments; tenant-filtered with soft delete. - public IRepository JobPhotos => - _jobPhotos ??= new Repository(_context); + public IJobPhotoRepository JobPhotos => + _jobPhotos ??= new JobPhotoRepository(_context); /// Repository for free-text staff notes on jobs; tenant-filtered with soft delete. public IRepository JobNotes => @@ -372,11 +380,35 @@ public class UnitOfWork : IUnitOfWork public IRepository SubscriptionPlanConfigs => _subscriptionPlanConfigs ??= new Repository(_context); + // Platform content + /// Repository for platform-wide announcements; no tenant filter, no soft delete. + public IPlainRepository Announcements => + _announcements ??= new PlainRepository(_context); + + /// Repository for IP ban records; no tenant filter, no soft delete. + public IPlainRepository BannedIps => + _bannedIps ??= new PlainRepository(_context); + + /// Repository for rotating tip-of-the-day entries; no tenant filter, no soft delete. + public IPlainRepository DashboardTips => + _dashboardTips ??= new PlainRepository(_context); + + /// Repository for bell-notification records; tenant-filtered with soft delete. + public IRepository InAppNotifications => + _inAppNotifications ??= new Repository(_context); + + /// Repository for platform changelog entries; no tenant filter, no soft delete. + public IPlainRepository ReleaseNotes => + _releaseNotes ??= new PlainRepository(_context); + // Bug Reports /// Repository for user-submitted bug reports; tenant-filtered with soft delete. public IRepository BugReports => _bugReports ??= new Repository(_context); + public IRepository BugReportAttachments => + _bugReportAttachments ??= new Repository(_context); + // Contact Us /// Repository for contact form submissions; platform admins see all, company users see their own. public IRepository ContactSubmissions => @@ -397,8 +429,8 @@ public class UnitOfWork : IUnitOfWork // Job Templates /// Repository for reusable job blueprints; tenant-filtered with soft delete. - public IRepository JobTemplates => - _jobTemplates ??= new Repository(_context); + public IJobTemplateRepository JobTemplates => + _jobTemplates ??= new JobTemplateRepository(_context); /// Repository for item definitions within a job template. public IRepository JobTemplateItems => diff --git a/src/PowderCoating.Infrastructure/Services/AiUsageReportService.cs b/src/PowderCoating.Infrastructure/Services/AiUsageReportService.cs new file mode 100644 index 0000000..0820cf2 --- /dev/null +++ b/src/PowderCoating.Infrastructure/Services/AiUsageReportService.cs @@ -0,0 +1,55 @@ +using Microsoft.EntityFrameworkCore; +using PowderCoating.Application.Interfaces; +using PowderCoating.Infrastructure.Data; + +namespace PowderCoating.Infrastructure.Services; + +/// +/// Implements by querying AiUsageLogs and +/// QuotePhotos directly via ApplicationDbContext. Both tables either are +/// not BaseEntity (AiUsageLog) or require cross-tenant GROUP BY aggregations that must +/// execute in SQL; this service encapsulates those queries so the controller stays clean. +/// +public class AiUsageReportService : IAiUsageReportService +{ + private readonly ApplicationDbContext _context; + + public AiUsageReportService(ApplicationDbContext context) + { + _context = context; + } + + /// + public async Task GetReportDataAsync() + { + var now = DateTime.UtcNow; + var todayStart = now.Date; + var last7Start = todayStart.AddDays(-7); + var last30Start = todayStart.AddDays(-30); + + var usageByCompany = await _context.AiUsageLogs + .GroupBy(l => l.CompanyId) + .Select(g => new AiCompanyUsage( + g.Key, + g.Count(l => l.CalledAt >= todayStart), + g.Count(l => l.CalledAt >= last7Start), + g.Count(l => l.CalledAt >= last30Start), + g.Count())) + .ToListAsync(); + + var featureStats = await _context.AiUsageLogs + .Where(l => l.CalledAt >= last30Start) + .GroupBy(l => new { l.CompanyId, l.Feature }) + .Select(g => new AiFeatureStat(g.Key.CompanyId, g.Key.Feature, g.Count())) + .ToListAsync(); + + var photoCounts = await _context.QuotePhotos + .IgnoreQueryFilters() + .Where(p => p.IsAiAnalysisPhoto && !p.IsDeleted) + .GroupBy(p => p.CompanyId) + .Select(g => new { CompanyId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(x => x.CompanyId, x => x.Count); + + return new AiUsageReportData(usageByCompany, featureStats, photoCounts); + } +} diff --git a/src/PowderCoating.Infrastructure/Services/AuditLogService.cs b/src/PowderCoating.Infrastructure/Services/AuditLogService.cs new file mode 100644 index 0000000..3dcd3f4 --- /dev/null +++ b/src/PowderCoating.Infrastructure/Services/AuditLogService.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore; +using PowderCoating.Core.Entities; +using PowderCoating.Core.Interfaces.Services; +using PowderCoating.Infrastructure.Data; + +namespace PowderCoating.Infrastructure.Services; + +/// +/// Concrete implementation of that writes +/// entries via directly. AuditLog does not inherit +/// from BaseEntity so it cannot be managed through the generic repository; this service +/// owns that write path and keeps ApplicationDbContext out of controller constructors. +/// +public class AuditLogService : IAuditLogService +{ + private readonly ApplicationDbContext _context; + + public AuditLogService(ApplicationDbContext context) + { + _context = context; + } + + /// + public async Task LogAsync(AuditLog entry) + { + _context.AuditLogs.Add(entry); + await _context.SaveChangesAsync(); + } + + /// + public async Task> GetUserActivityAsync(string userId, int limit = 50) + { + return await _context.AuditLogs + .AsNoTracking() + .Where(l => l.UserId == userId && l.EntityType == "ApplicationUser") + .OrderByDescending(l => l.Timestamp) + .Take(limit) + .ToListAsync(); + } +} diff --git a/src/PowderCoating.Infrastructure/Services/CompanyDataPurgeService.cs b/src/PowderCoating.Infrastructure/Services/CompanyDataPurgeService.cs new file mode 100644 index 0000000..dfc3391 --- /dev/null +++ b/src/PowderCoating.Infrastructure/Services/CompanyDataPurgeService.cs @@ -0,0 +1,180 @@ +using Microsoft.EntityFrameworkCore; +using PowderCoating.Core.Interfaces.Services; +using PowderCoating.Infrastructure.Data; + +namespace PowderCoating.Infrastructure.Services; + +/// +/// Implements via bulk ExecuteDeleteAsync against +/// directly. This is an intentional exception to the +/// IUnitOfWork pattern — identical to the rationale for DataPurgeController in the +/// documented permanent exceptions list. Each ExecuteDeleteAsync call commits immediately +/// at the database level so no SaveChangesAsync is needed for the bulk tiers. +/// +public class CompanyDataPurgeService : ICompanyDataPurgeService +{ + private readonly ApplicationDbContext _context; + + public CompanyDataPurgeService(ApplicationDbContext context) + { + _context = context; + } + + /// + public async Task DeleteAllBusinessDataAsync(int companyId, IReadOnlyList companyUserIds) + { + // ── Tier 1: Leaf children ───────────────────────────────────────────── + await _context.JobItemPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.JobItemCoats.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.QuoteItemPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.QuoteItemCoats.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + + // Announcement dismissals referencing the company's users or company-targeted announcements + var announcementIds = await _context.Announcements + .Where(a => a.TargetCompanyId == companyId).Select(a => a.Id).ToListAsync(); + await _context.AnnouncementDismissals.IgnoreQueryFilters() + .Where(x => companyUserIds.Contains(x.UserId) || announcementIds.Contains(x.AnnouncementId)) + .ExecuteDeleteAsync(); + + // ── Tier 2: Mid-level children ──────────────────────────────────────── + await _context.JobItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.QuoteItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.JobStatusHistory.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.JobChangeHistories.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.JobPhotos.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.JobNotes.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.JobDailyPriorities.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.QuoteChangeHistories.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.CustomerNotes.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.InventoryTransactions.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.MaintenanceRecords.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.BillLineItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.BillPayments.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.Payments.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.InvoiceItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.JobPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.QuotePrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + + // ── Tier 3: Top-level company entities ─────────────────────────────── + await _context.Invoices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.Appointments.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.Jobs.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.Quotes.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.Customers.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.Bills.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.Expenses.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.Vendors.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.InventoryItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.Equipment.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.OvenCosts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.CatalogItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.Accounts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.NotificationLogs.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.NotificationTemplates.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.Announcements.Where(x => x.TargetCompanyId == companyId).ExecuteDeleteAsync(); + await _context.BugReports.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.ShopWorkers.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.PrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + + // ── Tier 4: Company configs and lookup tables ───────────────────────── + await _context.CatalogCategories.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.InventoryCategoryLookups.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.JobStatusLookups.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.JobPriorityLookups.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.QuoteStatusLookups.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.AppointmentStatusLookups.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.AppointmentTypeLookups.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.PricingTiers.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.CompanyOperatingCosts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.CompanyPreferences.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + // Note: company record and users are left for the caller to handle via UserManager and UnitOfWork + } + + /// + public async Task ResetBusinessDataAsync(int companyId) + { + // ── Tier 0: Grandchildren ───────────────────────────────────────────── + await _context.JobTemplateItemPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.JobTemplateItemCoats.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.JobItemPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.JobItemCoats.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.QuoteItemPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.QuoteItemCoats.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.GiftCertificateRedemptions.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.CreditMemoApplications.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.OvenBatchItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + + var announcementIds = await _context.Announcements + .Where(a => a.TargetCompanyId == companyId).Select(a => a.Id).ToListAsync(); + if (announcementIds.Count > 0) + await _context.AnnouncementDismissals.IgnoreQueryFilters() + .Where(x => announcementIds.Contains(x.AnnouncementId)) + .ExecuteDeleteAsync(); + + // ── Tier 1: Children ────────────────────────────────────────────────── + await _context.JobTemplateItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.JobItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.QuoteItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.JobStatusHistory.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.JobChangeHistories.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.JobPhotos.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.JobNotes.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.JobDailyPriorities.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.JobTimeEntries.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.JobPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.ReworkRecords.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.QuoteChangeHistories.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.QuotePrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.QuotePhotos.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.CustomerNotes.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.InventoryTransactions.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.MaintenanceRecords.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.BillLineItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.BillPayments.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.Payments.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.Deposits.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.InvoiceItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.PurchaseOrderItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.AiItemPredictions.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.PowderUsageLogs.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.ShopWorkerRoleCosts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.OvenBatches.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.Refunds.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.CreditMemos.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.GiftCertificates.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + + // ── Tier 2: Top-level business entities ────────────────────────────── + await _context.Invoices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.Appointments.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.Jobs.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.JobTemplates.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.Quotes.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.Customers.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.Bills.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.PurchaseOrders.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.Expenses.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.Vendors.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.CatalogItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.InventoryItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.Equipment.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.OvenCosts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.Accounts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.NotificationLogs.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.ShopWorkers.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.PrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.CatalogCategories.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.InventoryCategoryLookups.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); + await _context.Announcements.Where(x => x.TargetCompanyId == companyId).ExecuteDeleteAsync(); + + // Clear QuickBooks migration wizard progress (tracked update, not bulk delete) + var prefs = await _context.CompanyPreferences + .IgnoreQueryFilters() + .FirstOrDefaultAsync(p => p.CompanyId == companyId); + if (prefs?.QbMigrationStateJson != null) + { + prefs.QbMigrationStateJson = null; + prefs.UpdatedAt = DateTime.UtcNow; + await _context.SaveChangesAsync(); + } + } +} diff --git a/src/PowderCoating.Infrastructure/Services/CompanyListService.cs b/src/PowderCoating.Infrastructure/Services/CompanyListService.cs new file mode 100644 index 0000000..0da7e5c --- /dev/null +++ b/src/PowderCoating.Infrastructure/Services/CompanyListService.cs @@ -0,0 +1,103 @@ +using Microsoft.EntityFrameworkCore; +using PowderCoating.Core.Entities; +using PowderCoating.Core.Interfaces.Services; +using PowderCoating.Infrastructure.Data; + +namespace PowderCoating.Infrastructure.Services; + +/// +/// Implements using directly. +/// Queries require IgnoreQueryFilters() (to bypass the tenant filter and see all companies), +/// dynamic sort expressions, and cross-entity GROUP BY aggregations — all of which are beyond the +/// generic . +/// +public class CompanyListService : ICompanyListService +{ + private readonly ApplicationDbContext _context; + + public CompanyListService(ApplicationDbContext context) + { + _context = context; + } + + /// + public async Task<(List Companies, int TotalCount)> GetPagedAsync( + string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize) + { + var query = _context.Companies + .AsNoTracking() + .IgnoreQueryFilters() + .Where(c => !c.IsDeleted) + .AsQueryable(); + + if (!string.IsNullOrWhiteSpace(searchTerm)) + { + var s = searchTerm.ToLower(); + query = query.Where(c => + c.CompanyName.ToLower().Contains(s) || + (c.CompanyCode != null && c.CompanyCode.ToLower().Contains(s)) || + (c.PrimaryContactEmail != null && c.PrimaryContactEmail.ToLower().Contains(s)) || + (c.Phone != null && c.Phone.ToLower().Contains(s))); + } + + query = (sortColumn, sortDirection == "asc") switch + { + ("CompanyName", true) => query.OrderBy(c => c.CompanyName), + ("CompanyName", false) => query.OrderByDescending(c => c.CompanyName), + ("Plan", true) => query.OrderBy(c => c.SubscriptionPlan), + ("Plan", false) => query.OrderByDescending(c => c.SubscriptionPlan), + ("Status", true) => query.OrderBy(c => c.IsActive), + ("Status", false) => query.OrderByDescending(c => c.IsActive), + ("Created", true) => query.OrderBy(c => c.CreatedAt), + ("Created", false) => query.OrderByDescending(c => c.CreatedAt), + _ => query.OrderBy(c => c.CompanyName) + }; + + var totalCount = await query.CountAsync(); + + var companies = await query + .Include(c => c.Users) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + return (companies, totalCount); + } + + /// + public async Task GetCountSummaryAsync(IReadOnlyList companyIds) + { + var jobCounts = await _context.Jobs + .IgnoreQueryFilters() + .Where(j => companyIds.Contains(j.CompanyId) && !j.IsDeleted) + .GroupBy(j => j.CompanyId) + .Select(g => new { g.Key, Count = g.Count() }) + .ToDictionaryAsync(x => x.Key, x => x.Count); + + var quoteCounts = await _context.Quotes + .IgnoreQueryFilters() + .Where(q => companyIds.Contains(q.CompanyId) && !q.IsDeleted) + .GroupBy(q => q.CompanyId) + .Select(g => new { g.Key, Count = g.Count() }) + .ToDictionaryAsync(x => x.Key, x => x.Count); + + var customerCounts = await _context.Customers + .IgnoreQueryFilters() + .Where(c => companyIds.Contains(c.CompanyId) && !c.IsDeleted) + .GroupBy(c => c.CompanyId) + .Select(g => new { g.Key, Count = g.Count() }) + .ToDictionaryAsync(x => x.Key, x => x.Count); + + var wizardRaw = await _context.CompanyPreferences + .IgnoreQueryFilters() + .Where(p => companyIds.Contains(p.CompanyId) && p.SetupWizardCompleted) + .Select(p => new { p.CompanyId, p.SetupWizardCompletedAt, p.SetupWizardCompletedByName }) + .ToListAsync(); + + var wizardInfo = wizardRaw.ToDictionary( + x => x.CompanyId, + x => new CompanyWizardInfo(true, x.SetupWizardCompletedAt, x.SetupWizardCompletedByName)); + + return new CompanyCountSummary(jobCounts, quoteCounts, customerCounts, wizardInfo); + } +} diff --git a/src/PowderCoating.Infrastructure/Services/DashboardReadService.cs b/src/PowderCoating.Infrastructure/Services/DashboardReadService.cs new file mode 100644 index 0000000..7cad882 --- /dev/null +++ b/src/PowderCoating.Infrastructure/Services/DashboardReadService.cs @@ -0,0 +1,225 @@ +using Microsoft.EntityFrameworkCore; +using PowderCoating.Core.Entities; +using PowderCoating.Core.Enums; +using PowderCoating.Core.Interfaces.Services; +using PowderCoating.Infrastructure.Data; + +namespace PowderCoating.Infrastructure.Services; + +/// +/// Implements using directly. +/// All queries require ThenInclude chains or navigation-property predicates (e.g. JobStatus.StatusCode) +/// that cannot be expressed through the generic . +/// +public class DashboardReadService : IDashboardReadService +{ + private static readonly string[] CompletedStatusCodes = + [ + "COMPLETED", + "READY_FOR_PICKUP", + "DELIVERED", + "CANCELLED" + ]; + + private readonly ApplicationDbContext _context; + + public DashboardReadService(ApplicationDbContext context) + { + _context = context; + } + + /// + public async Task GetIndexDataAsync(DateTime today) + { + var startOfMonth = new DateTime(today.Year, today.Month, 1); + var endOfMonth = startOfMonth.AddMonths(1).AddDays(-1); + var tomorrow = today.AddDays(1); + var lookAheadDate = today.AddDays(7); + var last30Days = today.AddDays(-30); + + var openInvoiceStatuses = new[] { InvoiceStatus.Sent, InvoiceStatus.PartiallyPaid, InvoiceStatus.Overdue }; + + // All active jobs (for today/overdue/in-progress panels) + var activeJobs = await _context.Jobs + .AsNoTracking() + .Include(j => j.Customer) + .Include(j => j.AssignedUser) + .Include(j => j.JobStatus) + .Include(j => j.JobPriority) + .Where(j => !CompletedStatusCodes.Contains(j.JobStatus.StatusCode)) + .ToListAsync(); + + // Monthly revenue — sum completed jobs updated in current month + var monthlyRevenue = await _context.Jobs + .Include(j => j.JobStatus) + .Where(j => CompletedStatusCodes.Contains(j.JobStatus.StatusCode) + && j.UpdatedAt >= startOfMonth + && j.UpdatedAt <= endOfMonth) + .SumAsync(j => j.FinalPrice); + + // Today's appointments (non-cancelled) + var todaysAppointments = await _context.Appointments + .AsNoTracking() + .Include(a => a.Customer) + .Include(a => a.AppointmentType) + .Include(a => a.AppointmentStatus) + .Include(a => a.AssignedUser) + .Where(a => a.ScheduledStartTime >= today && a.ScheduledStartTime < tomorrow + && a.AppointmentStatus.StatusCode != "CANCELLED") + .OrderBy(a => a.ScheduledStartTime) + .ToListAsync(); + + // Upcoming/overdue maintenance + var upcomingMaintenance = await _context.MaintenanceRecords + .AsNoTracking() + .Include(m => m.Equipment) + .Include(m => m.AssignedUser) + .Where(m => (m.Status == MaintenanceStatus.Scheduled + || m.Status == MaintenanceStatus.InProgress + || m.Status == MaintenanceStatus.Overdue) + && (m.Status == MaintenanceStatus.Overdue || m.ScheduledDate <= lookAheadDate)) + .OrderBy(m => m.Status == MaintenanceStatus.Overdue ? 0 : 1) + .ThenByDescending(m => m.Priority) + .ThenBy(m => m.ScheduledDate) + .Take(10) + .ToListAsync(); + + // Pending quotes (SENT status) + var pendingQuotes = await _context.Quotes + .AsNoTracking() + .Include(q => q.Customer) + .Include(q => q.QuoteStatus) + .Where(q => q.QuoteStatus.StatusCode == "SENT") + .ToListAsync(); + + // Open invoices (for AR aging + overdue list) + var openInvoices = await _context.Invoices + .AsNoTracking() + .Include(i => i.Customer) + .Where(i => openInvoiceStatuses.Contains(i.Status)) + .ToListAsync(); + + // Invoiced this month + var invoicedThisMonth = await _context.Invoices + .Where(i => i.Status != InvoiceStatus.Draft + && i.Status != InvoiceStatus.Voided + && i.Status != InvoiceStatus.WrittenOff + && i.InvoiceDate >= startOfMonth + && i.InvoiceDate <= endOfMonth) + .SumAsync(i => i.Total); + + // Collected this month + var collectedThisMonth = await _context.Payments + .Where(p => p.PaymentDate >= startOfMonth && p.PaymentDate <= endOfMonth) + .SumAsync(p => p.Amount); + + // Recent payments with Invoice → Customer + var recentPayments = await _context.Payments + .AsNoTracking() + .Include(p => p.Invoice).ThenInclude(i => i!.Customer) + .OrderByDescending(p => p.PaymentDate) + .Take(6) + .ToListAsync(); + + // Recent quotes (last 30 days) + var recentQuotes = await _context.Quotes + .AsNoTracking() + .Include(q => q.Customer) + .Include(q => q.QuoteStatus) + .Where(q => q.CreatedAt >= last30Days) + .OrderByDescending(q => q.CreatedAt) + .Take(5) + .ToListAsync(); + + // Recent jobs (last 30 days) + var recentJobs = await _context.Jobs + .AsNoTracking() + .Include(j => j.Customer) + .Include(j => j.JobStatus) + .Where(j => j.CreatedAt >= last30Days) + .OrderByDescending(j => j.CreatedAt) + .Take(5) + .ToListAsync(); + + // Jobs needing powder (not yet ordered, insufficient stock) + var jobsNeedingPowder = await _context.Jobs + .AsNoTracking() + .Include(j => j.Customer) + .Include(j => j.JobStatus) + .Include(j => j.JobItems) + .ThenInclude(i => i.Coats) + .ThenInclude(c => c.InventoryItem) + .ThenInclude(inv => inv!.PrimaryVendor) + .Include(j => j.JobItems) + .ThenInclude(i => i.Coats) + .ThenInclude(c => c.Vendor) + .Where(j => !j.IsDeleted + && !CompletedStatusCodes.Contains(j.JobStatus.StatusCode) + && j.JobItems.Any(i => i.Coats.Any(c => + !c.IsDeleted && + !c.PowderOrdered && + c.PowderToOrder > 0 && + (c.InventoryItemId == null || c.InventoryItem!.QuantityOnHand < c.PowderToOrder)))) + .ToListAsync(); + + // Jobs with powder already ordered but not yet received + var jobsWithOrderedPowder = await _context.Jobs + .AsNoTracking() + .Include(j => j.Customer) + .Include(j => j.JobStatus) + .Include(j => j.JobItems) + .ThenInclude(i => i.Coats) + .ThenInclude(c => c.InventoryItem) + .ThenInclude(inv => inv!.PrimaryVendor) + .Include(j => j.JobItems) + .ThenInclude(i => i.Coats) + .ThenInclude(c => c.Vendor) + .Where(j => !j.IsDeleted + && !CompletedStatusCodes.Contains(j.JobStatus.StatusCode) + && j.JobItems.Any(i => i.Coats.Any(c => + !c.IsDeleted && + c.PowderOrdered && + !c.PowderReceived))) + .ToListAsync(); + + // Bills due (open/partial, balance remaining) + var billsDue = await _context.Bills + .AsNoTracking() + .Include(b => b.Vendor) + .Where(b => (b.Status == BillStatus.Open || b.Status == BillStatus.PartiallyPaid) + && b.Total > b.AmountPaid) + .OrderBy(b => b.DueDate) + .Take(15) + .ToListAsync(); + + // Random tip of the day + var tips = await _context.DashboardTips.Where(t => t.IsActive).ToListAsync(); + var tipOfTheDay = tips.Count > 0 ? tips[Random.Shared.Next(tips.Count)].TipText : null; + + return new DashboardIndexData( + ActiveJobs: activeJobs, + MonthlyRevenue: monthlyRevenue, + TodaysAppointments: todaysAppointments, + UpcomingMaintenance: upcomingMaintenance, + PendingQuotes: pendingQuotes, + OpenInvoices: openInvoices, + InvoicedThisMonth: invoicedThisMonth, + CollectedThisMonth: collectedThisMonth, + RecentPayments: recentPayments, + RecentQuotes: recentQuotes, + RecentJobs: recentJobs, + JobsNeedingPowder: jobsNeedingPowder, + JobsWithOrderedPowder: jobsWithOrderedPowder, + BillsDue: billsDue, + TipOfTheDay: tipOfTheDay + ); + } + + /// + public async Task GetTotalUserCountAsync() + { + return await _context.Users + .Where(u => u.CompanyId > 0) + .CountAsync(); + } +} diff --git a/src/PowderCoating.Infrastructure/Services/FinancialReportService.cs b/src/PowderCoating.Infrastructure/Services/FinancialReportService.cs index 1463116..6563c80 100644 --- a/src/PowderCoating.Infrastructure/Services/FinancialReportService.cs +++ b/src/PowderCoating.Infrastructure/Services/FinancialReportService.cs @@ -1,14 +1,18 @@ using Microsoft.EntityFrameworkCore; using PowderCoating.Application.DTOs.Accounting; using PowderCoating.Application.Interfaces; +using PowderCoating.Core.Entities; +using PowderCoating.Core.Enums; using PowderCoating.Infrastructure.Data; namespace PowderCoating.Infrastructure.Services; /// -/// Implements financial aggregate reports using direct DbContext access with AsNoTracking. -/// Query logic is migrated here from ReportsController as each report action is -/// converted during Phase 2/3 of the data-access architecture migration. +/// Implements financial aggregate reports (P&L, Balance Sheet, AR Aging, Sales & Income) +/// using direct DbContext access with AsNoTracking. Migrated from inline queries in +/// ReportsController as part of Phase 2 of the data-access architecture migration. +/// The four report types each have matching PDF export paths in the controller that +/// share the same data by calling these methods, eliminating the previous duplication. /// See docs/DATA_ACCESS_ARCHITECTURE.md for the full migration plan. /// public class FinancialReportService : IFinancialReportService @@ -21,19 +25,415 @@ public class FinancialReportService : IFinancialReportService } /// - /// Implemented — migrated from ReportsController.ProfitAndLoss in Phase 2. - public Task GetProfitAndLossAsync(int companyId, DateTime from, DateTime to) - => throw new NotImplementedException("Migrate from ReportsController.ProfitAndLoss — Phase 2."); + public async Task GetProfitAndLossAsync(int companyId, DateTime from, DateTime to) + { + var toEnd = to.AddDays(1).AddTicks(-1); + var companyName = await GetCompanyNameAsync(companyId); + + // Revenue: InvoiceItems posted to revenue accounts + var revenueByAccount = await _context.InvoiceItems + .Where(ii => ii.RevenueAccountId != null + && ii.Invoice.Status != InvoiceStatus.Draft + && ii.Invoice.Status != InvoiceStatus.Voided + && ii.Invoice.InvoiceDate >= from && ii.Invoice.InvoiceDate <= toEnd) + .GroupBy(ii => ii.RevenueAccountId!.Value) + .Select(g => new { AccountId = g.Key, Amount = g.Sum(ii => ii.TotalPrice) }) + .ToListAsync(); + + var unlinkedRevenue = await _context.InvoiceItems + .Where(ii => ii.RevenueAccountId == null + && ii.Invoice.Status != InvoiceStatus.Draft + && ii.Invoice.Status != InvoiceStatus.Voided + && ii.Invoice.InvoiceDate >= from && ii.Invoice.InvoiceDate <= toEnd) + .SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0; + + var revenueAccounts = await _context.Accounts + .Where(a => a.AccountType == AccountType.Revenue && a.IsActive) + .ToDictionaryAsync(a => a.Id); + + var revenueLines = revenueByAccount + .Where(r => revenueAccounts.ContainsKey(r.AccountId)) + .Select(r => new FinancialReportLine + { + AccountId = r.AccountId, + AccountNumber = revenueAccounts[r.AccountId].AccountNumber, + AccountName = revenueAccounts[r.AccountId].Name, + Amount = r.Amount + }) + .OrderBy(l => l.AccountNumber) + .ToList(); + + if (unlinkedRevenue > 0) + revenueLines.Add(new FinancialReportLine { AccountNumber = "—", AccountName = "Other Sales (unclassified)", Amount = unlinkedRevenue }); + + // COGS & Expenses: direct Expenses + BillLineItems merged per account + var directByAccount = await _context.Expenses + .Where(e => e.Date >= from && e.Date <= toEnd) + .GroupBy(e => e.ExpenseAccountId) + .Select(g => new { AccountId = g.Key, Amount = g.Sum(e => e.Amount) }) + .ToListAsync(); + + var billLinesByAccount = await _context.BillLineItems + .Where(bli => bli.AccountId != null + && bli.Bill.Status != BillStatus.Draft + && bli.Bill.Status != BillStatus.Voided + && bli.Bill.BillDate >= from && bli.Bill.BillDate <= toEnd) + .GroupBy(bli => bli.AccountId!.Value) + .Select(g => new { AccountId = g.Key, Amount = g.Sum(bli => bli.Amount) }) + .ToListAsync(); + + var expenseAmounts = new Dictionary(); + foreach (var e in directByAccount) + expenseAmounts[e.AccountId] = expenseAmounts.GetValueOrDefault(e.AccountId) + e.Amount; + foreach (var b in billLinesByAccount) + expenseAmounts[b.AccountId] = expenseAmounts.GetValueOrDefault(b.AccountId) + b.Amount; + + var expAccounts = await _context.Accounts + .Where(a => (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods) && a.IsActive) + .ToDictionaryAsync(a => a.Id); + + var cogsLines = new List(); + var expenseLines = new List(); + + foreach (var (accountId, amount) in expenseAmounts.OrderBy(kv => expAccounts.ContainsKey(kv.Key) ? expAccounts[kv.Key].AccountNumber : "999")) + { + if (!expAccounts.TryGetValue(accountId, out var acct)) continue; + var line = new FinancialReportLine { AccountId = accountId, AccountNumber = acct.AccountNumber, AccountName = acct.Name, Amount = amount }; + if (acct.AccountType == AccountType.CostOfGoods) cogsLines.Add(line); + else expenseLines.Add(line); + } + + return new ProfitAndLossDto + { + From = from, + To = to, + CompanyName = companyName, + RevenueLines = revenueLines, + TotalRevenue = revenueLines.Sum(l => l.Amount), + CogsLines = cogsLines, + TotalCogs = cogsLines.Sum(l => l.Amount), + ExpenseLines = expenseLines, + TotalExpenses = expenseLines.Sum(l => l.Amount), + }; + } /// - public Task GetBalanceSheetAsync(int companyId, DateTime asOf) - => throw new NotImplementedException("Migrate from ReportsController.BalanceSheet — Phase 2."); + public async Task GetBalanceSheetAsync(int companyId, DateTime asOf) + { + var asOfEnd = asOf.AddDays(1).AddTicks(-1); + var companyName = await GetCompanyNameAsync(companyId); + + // Pre-compute balance contributions per account (batch GROUP BY queries avoid N+1) + + var depositsByAcct = await _context.Payments + .Where(p => p.PaymentDate <= asOfEnd && p.DepositAccountId != null + && p.Invoice.Status != InvoiceStatus.Voided + && p.Invoice.Status != InvoiceStatus.WrittenOff) + .GroupBy(p => p.DepositAccountId!.Value) + .Select(g => new { Id = g.Key, Amount = g.Sum(p => p.Amount) }) + .ToDictionaryAsync(g => g.Id, g => g.Amount); + + var expFromByAcct = await _context.Expenses + .Where(e => e.Date <= asOfEnd) + .GroupBy(e => e.PaymentAccountId) + .Select(g => new { Id = g.Key, Amount = g.Sum(e => e.Amount) }) + .ToDictionaryAsync(g => g.Id, g => g.Amount); + + var bpFromByAcct = await _context.BillPayments + .Where(bp => bp.PaymentDate <= asOfEnd) + .GroupBy(bp => bp.BankAccountId) + .Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) }) + .ToDictionaryAsync(g => g.Id, g => g.Amount); + + var billsByApAcct = await _context.Bills + .Where(b => b.Status != BillStatus.Draft && b.Status != BillStatus.Voided && b.BillDate <= asOfEnd) + .GroupBy(b => b.APAccountId) + .Select(g => new { Id = g.Key, Amount = g.Sum(b => b.Total) }) + .ToDictionaryAsync(g => g.Id, g => g.Amount); + + var bpByApAcct = await _context.BillPayments + .Where(bp => bp.PaymentDate <= asOfEnd) + .GroupBy(bp => bp.Bill.APAccountId) + .Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) }) + .ToDictionaryAsync(g => g.Id, g => g.Amount); + + var taxByAcct = await _context.Invoices + .Where(i => i.SalesTaxAccountId != null && i.TaxAmount > 0 + && i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided + && i.InvoiceDate <= asOfEnd) + .GroupBy(i => i.SalesTaxAccountId!.Value) + .Select(g => new { Id = g.Key, Amount = g.Sum(i => i.TaxAmount) }) + .ToDictionaryAsync(g => g.Id, g => g.Amount); + + var arDebits = await _context.Invoices + .Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.InvoiceDate <= asOfEnd) + .SumAsync(i => (decimal?)i.Total) ?? 0; + var arCredits = await _context.Payments + .Where(p => p.PaymentDate <= asOfEnd + && p.Invoice.Status != InvoiceStatus.Voided + && p.Invoice.Status != InvoiceStatus.WrittenOff) + .SumAsync(p => (decimal?)p.Amount) ?? 0; + + // Retained earnings = net P&L from inception through asOf + var lifetimeRevenue = await _context.InvoiceItems + .Where(ii => ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.InvoiceDate <= asOfEnd) + .SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0; + var lifetimeCogs = await _context.Expenses + .Where(e => e.Date <= asOfEnd) + .SumAsync(e => (decimal?)e.Amount) ?? 0; + var lifetimeBillCosts = await _context.BillLineItems + .Where(bli => bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Voided && bli.Bill.BillDate <= asOfEnd) + .SumAsync(bli => (decimal?)bli.Amount) ?? 0; + var retainedEarnings = lifetimeRevenue - lifetimeCogs - lifetimeBillCosts; + + var accounts = await _context.Accounts + .Where(a => a.IsActive) + .OrderBy(a => a.AccountNumber) + .ToListAsync(); + + // Standard double-entry: assets have normal debit balance; liabilities+equity have normal credit balance. + decimal ComputeBalance(Account a) + { + bool normalDebit = a.AccountType == AccountType.Asset; + decimal debits = 0, credits = 0; + + if (a.AccountSubType == AccountSubType.AccountsReceivable) + { + debits = arDebits; credits = arCredits; + } + else if (a.AccountSubType == AccountSubType.AccountsPayable) + { + credits = billsByApAcct.GetValueOrDefault(a.Id); + debits = bpByApAcct.GetValueOrDefault(a.Id); + } + else + { + debits += depositsByAcct.GetValueOrDefault(a.Id); + credits += expFromByAcct.GetValueOrDefault(a.Id); + credits += bpFromByAcct.GetValueOrDefault(a.Id); + credits += taxByAcct.GetValueOrDefault(a.Id); + } + + decimal opening = (a.OpeningBalanceDate == null || a.OpeningBalanceDate.Value.Date <= asOf) + ? a.OpeningBalance : 0; + decimal net = normalDebit ? debits - credits : credits - debits; + return opening + net; + } + + FinancialReportLine ToLine(Account a) => new() + { + AccountId = a.Id, + AccountNumber = a.AccountNumber, + AccountName = a.Name, + Amount = ComputeBalance(a) + }; + + var assetAccts = accounts.Where(a => a.AccountType == AccountType.Asset).ToList(); + var liabilityAccts = accounts.Where(a => a.AccountType == AccountType.Liability).ToList(); + var equityAccts = accounts.Where(a => a.AccountType == AccountType.Equity).ToList(); + + var currentAssets = assetAccts.Where(a => a.AccountSubType is AccountSubType.Checking or AccountSubType.Savings or AccountSubType.AccountsReceivable or AccountSubType.Inventory or AccountSubType.OtherCurrentAsset).Select(ToLine).ToList(); + var fixedAssets = assetAccts.Where(a => a.AccountSubType == AccountSubType.FixedAsset).Select(ToLine).ToList(); + var otherAssets = assetAccts.Where(a => a.AccountSubType == AccountSubType.OtherAsset).Select(ToLine).ToList(); + var currentLiabilities = liabilityAccts.Where(a => a.AccountSubType is AccountSubType.AccountsPayable or AccountSubType.CreditCard or AccountSubType.OtherCurrentLiability).Select(ToLine).ToList(); + var longTermLiabilities = liabilityAccts.Where(a => a.AccountSubType == AccountSubType.LongTermLiability).Select(ToLine).ToList(); + var equityLines = equityAccts.Select(ToLine).ToList(); + + var totalAssets = currentAssets.Sum(l => l.Amount) + fixedAssets.Sum(l => l.Amount) + otherAssets.Sum(l => l.Amount); + var totalLiabilities = currentLiabilities.Sum(l => l.Amount) + longTermLiabilities.Sum(l => l.Amount); + var totalEquity = equityLines.Sum(l => l.Amount) + retainedEarnings; + + return new BalanceSheetDto + { + AsOf = asOf, + CompanyName = companyName, + CurrentAssets = currentAssets, + FixedAssets = fixedAssets, + OtherAssets = otherAssets, + TotalAssets = totalAssets, + CurrentLiabilities = currentLiabilities, + LongTermLiabilities = longTermLiabilities, + TotalLiabilities = totalLiabilities, + EquityLines = equityLines, + RetainedEarnings = retainedEarnings, + TotalEquity = totalEquity, + }; + } /// - public Task GetArAgingAsync(int companyId, DateTime asOf) - => throw new NotImplementedException("Migrate from ReportsController.ArAging — Phase 2."); + public async Task GetArAgingAsync(int companyId, DateTime asOf) + { + var asOfEnd = asOf.AddDays(1).AddTicks(-1); + var companyName = await GetCompanyNameAsync(companyId); + + var openInvoices = await _context.Invoices + .Include(i => i.Customer) + .Where(i => i.Status != InvoiceStatus.Draft + && i.Status != InvoiceStatus.Voided + && i.Status != InvoiceStatus.Paid + && i.InvoiceDate <= asOfEnd + && (i.Total - i.AmountPaid - i.CreditApplied - i.GiftCertificateRedeemed) > 0) + .OrderBy(i => i.Customer!.CompanyName) + .ThenBy(i => i.DueDate) + .ToListAsync(); + + static string AgingBucket(int d) => d switch + { + <= 0 => "current", + <= 30 => "1-30", + <= 60 => "31-60", + <= 90 => "61-90", + _ => "90+" + }; + + var customerDtos = new List(); + + foreach (var grp in openInvoices.GroupBy(i => new { i.CustomerId, i.Customer!.CompanyName, i.Customer.ContactFirstName, i.Customer.ContactLastName, i.Customer.IsCommercial })) + { + var customerName = grp.Key.IsCommercial + ? grp.Key.CompanyName + : $"{grp.Key.ContactFirstName} {grp.Key.ContactLastName}".Trim(); + + var custDto = new ArAgingCustomerDto { CustomerId = grp.Key.CustomerId, CustomerName = customerName }; + + foreach (var inv in grp) + { + var balance = inv.BalanceDue; + var daysOverdue = inv.DueDate.HasValue ? (int)(asOf - inv.DueDate.Value.Date).TotalDays : 0; + + custDto.Invoices.Add(new ArAgingInvoiceDto + { + InvoiceId = inv.Id, + InvoiceNumber = inv.InvoiceNumber, + InvoiceDate = inv.InvoiceDate, + DueDate = inv.DueDate, + BalanceDue = balance, + DaysOverdue = daysOverdue + }); + + switch (AgingBucket(daysOverdue)) + { + case "current": custDto.TotalCurrent += balance; break; + case "1-30": custDto.Total1to30 += balance; break; + case "31-60": custDto.Total31to60 += balance; break; + case "61-90": custDto.Total61to90 += balance; break; + default: custDto.TotalOver90 += balance; break; + } + } + + customerDtos.Add(custDto); + } + + var sorted = customerDtos.OrderByDescending(c => c.TotalBalance).ToList(); + + return new ArAgingReportDto + { + AsOf = asOf, + CompanyName = companyName, + Customers = sorted, + TotalCurrent = sorted.Sum(c => c.TotalCurrent), + Total1to30 = sorted.Sum(c => c.Total1to30), + Total31to60 = sorted.Sum(c => c.Total31to60), + Total61to90 = sorted.Sum(c => c.Total61to90), + TotalOver90 = sorted.Sum(c => c.TotalOver90), + }; + } /// - public Task GetSalesAndIncomeAsync(int companyId, DateTime from, DateTime to) - => throw new NotImplementedException("Migrate from ReportsController.SalesAndIncome — Phase 2."); + public async Task GetSalesAndIncomeAsync(int companyId, DateTime from, DateTime to) + { + var toEnd = to.AddDays(1).AddTicks(-1); + var companyName = await GetCompanyNameAsync(companyId); + + var invoices = await _context.Invoices + .Include(i => i.Customer) + .Include(i => i.Payments) + .Where(i => i.Status != InvoiceStatus.Draft + && i.Status != InvoiceStatus.Voided + && i.InvoiceDate >= from && i.InvoiceDate <= toEnd) + .OrderBy(i => i.InvoiceDate) + .ToListAsync(); + + var collectedInPeriod = await _context.Payments + .Where(p => p.PaymentDate >= from && p.PaymentDate <= toEnd) + .SumAsync(p => (decimal?)p.Amount) ?? 0; + + var byCustomer = invoices + .GroupBy(i => new + { + i.CustomerId, + Name = i.Customer!.IsCommercial + ? i.Customer.CompanyName + : $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim() + }) + .Select(g => new SalesByCustomerDto + { + CustomerId = g.Key.CustomerId, + CustomerName = g.Key.Name, + InvoiceCount = g.Count(), + TotalInvoiced = g.Sum(i => i.Total), + TotalPaid = g.Sum(i => i.AmountPaid), + BalanceDue = g.Sum(i => i.BalanceDue), + }) + .OrderByDescending(c => c.TotalInvoiced) + .ToList(); + + var byMonth = invoices + .GroupBy(i => new { i.InvoiceDate.Year, i.InvoiceDate.Month }) + .Select(g => new SalesByMonthDto + { + Year = g.Key.Year, + Month = g.Key.Month, + Label = new DateTime(g.Key.Year, g.Key.Month, 1).ToString("MMM yyyy"), + TotalInvoiced = g.Sum(i => i.Total), + TotalCollected = g.Sum(i => i.AmountPaid), + InvoiceCount = g.Count(), + }) + .OrderBy(m => m.Year).ThenBy(m => m.Month) + .ToList(); + + var invoiceLines = invoices.Select(i => new SalesInvoiceLineDto + { + InvoiceId = i.Id, + InvoiceNumber = i.InvoiceNumber, + CustomerName = i.Customer!.IsCommercial + ? i.Customer.CompanyName + : $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim(), + InvoiceDate = i.InvoiceDate, + DueDate = i.DueDate, + Status = i.Status.ToString(), + SubTotal = i.SubTotal, + TaxAmount = i.TaxAmount, + Total = i.Total, + AmountPaid = i.AmountPaid, + BalanceDue = i.BalanceDue, + }).ToList(); + + return new SalesIncomeReportDto + { + From = from, + To = to, + CompanyName = companyName, + TotalInvoiced = invoices.Sum(i => i.Total), + TotalCollected = collectedInPeriod, + TotalTax = invoices.Sum(i => i.TaxAmount), + TotalDiscount = invoices.Sum(i => i.DiscountAmount), + InvoiceCount = invoices.Count, + CustomerCount = invoices.Select(i => i.CustomerId).Distinct().Count(), + ByCustomer = byCustomer, + ByMonth = byMonth, + Invoices = invoiceLines, + }; + } + + /// + /// Looks up the company name by ID for report headers and AI prompt injection. + /// Falls back to "Your Company" if the record is not found. + /// + private async Task GetCompanyNameAsync(int companyId) + { + if (companyId <= 0) return "Your Company"; + var company = await _context.Companies.FirstOrDefaultAsync(c => c.Id == companyId); + return company?.CompanyName ?? "Your Company"; + } } diff --git a/src/PowderCoating.Infrastructure/Services/OperationalReportService.cs b/src/PowderCoating.Infrastructure/Services/OperationalReportService.cs index b080f2c..4d8bea5 100644 --- a/src/PowderCoating.Infrastructure/Services/OperationalReportService.cs +++ b/src/PowderCoating.Infrastructure/Services/OperationalReportService.cs @@ -1,4 +1,7 @@ +using Microsoft.EntityFrameworkCore; using PowderCoating.Application.Interfaces; +using PowderCoating.Core.Entities; +using PowderCoating.Core.Enums; using PowderCoating.Infrastructure.Data; namespace PowderCoating.Infrastructure.Services; @@ -19,10 +22,124 @@ public class OperationalReportService : IOperationalReportService } /// - public Task GetJobCycleTimeAsync(int companyId, int months) - => throw new NotImplementedException("Migrate from ReportsController.JobCycleTime — Phase 2."); + public async Task GetJobCycleTimeAsync(int companyId, int months) + { + var completedCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" }; + + var completedJobs = await _context.Jobs + .Include(j => j.JobStatus) + .Where(j => !j.IsDeleted && completedCodes.Contains(j.JobStatus.StatusCode) && j.CompletedDate.HasValue) + .AsNoTracking() + .ToListAsync(); + + var history = await _context.JobStatusHistory + .Include(h => h.FromStatus) + .Include(h => h.ToStatus) + .AsNoTracking() + .ToListAsync(); + + var historyByJob = history + .GroupBy(h => h.JobId) + .ToDictionary(g => g.Key, g => g.OrderBy(h => h.ChangedDate).ToList()); + + var statusTimings = new Dictionary Days)>(); + + foreach (var job in completedJobs) + { + if (!historyByJob.TryGetValue(job.Id, out var jobHistory) || !jobHistory.Any()) continue; + + var prevDate = job.CreatedAt; + foreach (var entry in jobHistory) + { + var fc = entry.FromStatus?.StatusCode; + var fn = entry.FromStatus?.DisplayName; + if (fc == null) { prevDate = entry.ChangedDate; continue; } + var d = (entry.ChangedDate - prevDate).TotalDays; + if (d >= 0 && d <= 365) + { + if (!statusTimings.ContainsKey(fc)) statusTimings[fc] = (fn ?? fc, new List()); + statusTimings[fc].Days.Add(d); + } + prevDate = entry.ChangedDate; + } + + var last = jobHistory.Last(); + var tc = last.ToStatus?.StatusCode; + var tn = last.ToStatus?.DisplayName; + if (tc != null) + { + var d = (job.CompletedDate!.Value - last.ChangedDate).TotalDays; + if (d >= 0 && d <= 365) + { + if (!statusTimings.ContainsKey(tc)) statusTimings[tc] = (tn ?? tc, new List()); + statusTimings[tc].Days.Add(d); + } + } + } + + var rows = statusTimings + .Where(kv => kv.Value.Days.Any()) + .Select(kv => new JobCycleTimeRow(kv.Value.DisplayName, Math.Round(kv.Value.Days.Average(), 1), kv.Value.Days.Count)) + .ToList(); + + return new JobCycleTimeReport(rows, months); + } /// - public Task GetPowderUsageAsync(int companyId, int months) - => throw new NotImplementedException("Migrate from ReportsController.PowderUsage — Phase 2."); + public async Task GetPowderUsageAsync(int companyId, int months) + { + var startDate = DateTime.UtcNow.AddMonths(-months); + + var transactions = await _context.InventoryTransactions + .Include(t => t.InventoryItem) + .Where(t => !t.IsDeleted + && t.TransactionType == InventoryTransactionType.JobUsage + && t.TransactionDate >= startDate) + .AsNoTracking() + .ToListAsync(); + + var rows = transactions + .Where(t => t.InventoryItem != null) + .GroupBy(t => t.InventoryItemId) + .Select(g => new PowderUsageRow( + ColorName: g.First().InventoryItem!.ColorName ?? g.First().InventoryItem!.Name, + VendorName: g.First().InventoryItem!.Manufacturer ?? string.Empty, + TotalLbs: g.Sum(t => Math.Abs(t.Quantity)), + TotalCost: g.Sum(t => Math.Abs(t.TotalCost)))) + .OrderByDescending(r => r.TotalLbs) + .ToList(); + + return new PowderUsageReport(rows, months); + } + + /// + public async Task> GetActiveBillsAsync() + { + return await _context.Bills + .Include(b => b.Vendor) + .Include(b => b.Payments.Where(p => !p.IsDeleted)) + .Where(b => !b.IsDeleted && b.Status != BillStatus.Voided) + .AsNoTracking() + .ToListAsync(); + } + + /// + public async Task> GetAllExpensesAsync() + { + return await _context.Expenses + .Include(e => e.ExpenseAccount) + .Where(e => !e.IsDeleted) + .AsNoTracking() + .ToListAsync(); + } + + /// + public async Task> GetAllJobStatusHistoryAsync() + { + return await _context.JobStatusHistory + .Include(h => h.FromStatus) + .Include(h => h.ToStatus) + .AsNoTracking() + .ToListAsync(); + } } diff --git a/src/PowderCoating.Web/Controllers/AccountingExportController.cs b/src/PowderCoating.Web/Controllers/AccountingExportController.cs index 433734b..3d5039f 100644 --- a/src/PowderCoating.Web/Controllers/AccountingExportController.cs +++ b/src/PowderCoating.Web/Controllers/AccountingExportController.cs @@ -1,9 +1,7 @@ using Microsoft.AspNetCore.Authorization; using PowderCoating.Shared.Constants; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; using PowderCoating.Core.Interfaces; -using PowderCoating.Infrastructure.Data; using System.IO.Compression; using System.Text; @@ -12,14 +10,14 @@ namespace PowderCoating.Web.Controllers; [Authorize(Policy = AppConstants.Policies.CanManageJobs)] public class AccountingExportController : Controller { - private readonly ApplicationDbContext _context; + private readonly IUnitOfWork _unitOfWork; private readonly ITenantContext _tenantContext; private readonly PowderCoating.Application.Interfaces.IAuditService _auditService; - public AccountingExportController(ApplicationDbContext context, ITenantContext tenantContext, + public AccountingExportController(IUnitOfWork unitOfWork, ITenantContext tenantContext, PowderCoating.Application.Interfaces.IAuditService auditService) { - _context = context; + _unitOfWork = unitOfWork; _tenantContext = tenantContext; _auditService = auditService; } @@ -60,42 +58,33 @@ public class AccountingExportController : Controller [ValidateAntiForgeryToken] public async Task Export(DateTime startDate, DateTime endDate, string format) { - var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var start = startDate.Date; var end = endDate.Date.AddDays(1).AddTicks(-1); // ── Load data ───────────────────────────────────────────────────────── - var invoices = await _context.Invoices - .Include(i => i.InvoiceItems) - .Include(i => i.Payments) - .Include(i => i.Customer) - .Where(i => !i.IsDeleted && i.CompanyId == companyId - && i.InvoiceDate >= start && i.InvoiceDate <= end) + var invoices = (await _unitOfWork.Invoices.FindAsync( + i => i.InvoiceDate >= start && i.InvoiceDate <= end, + false, + i => i.InvoiceItems, + i => i.Payments, + i => i.Customer)) .OrderBy(i => i.InvoiceDate) - .ToListAsync(); + .ToList(); - var expenses = await _context.Set() - .Include(e => e.Vendor) - .Include(e => e.ExpenseAccount) - .Include(e => e.PaymentAccount) - .Where(e => !e.IsDeleted && e.CompanyId == companyId - && e.Date >= start && e.Date <= end) + var expenses = (await _unitOfWork.Expenses.FindAsync( + e => e.Date >= start && e.Date <= end, + false, + e => e.Vendor, + e => e.ExpenseAccount, + e => e.PaymentAccount)) .OrderBy(e => e.Date) - .ToListAsync(); + .ToList(); - var bills = await _context.Set() - .Include(b => b.Vendor) - .Include(b => b.LineItems).ThenInclude(l => l.Account) - .Include(b => b.Payments) - .Where(b => !b.IsDeleted && b.CompanyId == companyId - && b.BillDate >= start && b.BillDate <= end) - .OrderBy(b => b.BillDate) - .ToListAsync(); + var bills = await _unitOfWork.Bills.GetForDateRangeAsync(start, end); - var customers = await _context.Customers - .Where(c => !c.IsDeleted && c.CompanyId == companyId) + var customers = (await _unitOfWork.Customers.GetAllAsync()) .OrderBy(c => c.CompanyName ?? c.ContactFirstName) - .ToListAsync(); + .ToList(); // ── Build ZIP ───────────────────────────────────────────────────────── using var ms = new MemoryStream(); diff --git a/src/PowderCoating.Web/Controllers/AiQuickQuoteController.cs b/src/PowderCoating.Web/Controllers/AiQuickQuoteController.cs index f95a314..e111049 100644 --- a/src/PowderCoating.Web/Controllers/AiQuickQuoteController.cs +++ b/src/PowderCoating.Web/Controllers/AiQuickQuoteController.cs @@ -8,7 +8,6 @@ using PowderCoating.Application.Interfaces; using PowderCoating.Application.Services; using PowderCoating.Core.Entities; using PowderCoating.Core.Interfaces; -using PowderCoating.Infrastructure.Data; using PowderCoating.Shared.Constants; namespace PowderCoating.Web.Controllers; @@ -19,7 +18,6 @@ public class AiQuickQuoteController : Controller private readonly IUnitOfWork _unitOfWork; private readonly IAiQuickQuoteService _aiService; private readonly IPricingCalculationService _pricingService; - private readonly ApplicationDbContext _context; private readonly UserManager _userManager; private readonly ILogger _logger; @@ -27,14 +25,12 @@ public class AiQuickQuoteController : Controller IUnitOfWork unitOfWork, IAiQuickQuoteService aiService, IPricingCalculationService pricingService, - ApplicationDbContext context, UserManager userManager, ILogger logger) { _unitOfWork = unitOfWork; _aiService = aiService; _pricingService = pricingService; - _context = context; _userManager = userManager; _logger = logger; } @@ -106,9 +102,7 @@ public class AiQuickQuoteController : Controller var walkIn = await GetOrCreateWalkInCustomerAsync(companyId); // Draft status — nullable FK, gracefully absent if lookup not seeded - var draftStatus = await _context.QuoteStatusLookups - .Where(s => s.StatusCode == "DRAFT") - .FirstOrDefaultAsync(); + var draftStatus = await _unitOfWork.QuoteStatusLookups.FirstOrDefaultAsync(s => s.StatusCode == "DRAFT"); var quoteNumber = await GenerateQuoteNumberAsync(companyId); var now = DateTime.UtcNow; @@ -300,21 +294,13 @@ public class AiQuickQuoteController : Controller { var now = DateTime.UtcNow; - var prefs = await _context.CompanyPreferences - .IgnoreQueryFilters() - .Where(p => p.CompanyId == companyId && !p.IsDeleted) - .Select(p => new { p.QuoteNumberPrefix }) - .FirstOrDefaultAsync(); + var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync( + p => p.CompanyId == companyId && !p.IsDeleted, ignoreQueryFilters: true); var quotePrefix = !string.IsNullOrWhiteSpace(prefs?.QuoteNumberPrefix) ? prefs.QuoteNumberPrefix : "QT"; var prefix = $"{quotePrefix}-{now:yy}{now:MM}"; - var lastQuoteNumber = await _context.Quotes - .IgnoreQueryFilters() - .Where(q => q.CompanyId == companyId && q.QuoteNumber.StartsWith(prefix)) - .OrderByDescending(q => q.QuoteNumber) - .Select(q => q.QuoteNumber) - .FirstOrDefaultAsync(); + var lastQuoteNumber = await _unitOfWork.Quotes.GetLastQuoteNumberByPrefixAsync(companyId, prefix); int nextNumber = 1; if (lastQuoteNumber != null) diff --git a/src/PowderCoating.Web/Controllers/AiUsageReportController.cs b/src/PowderCoating.Web/Controllers/AiUsageReportController.cs index ddc64b0..2d6987e 100644 --- a/src/PowderCoating.Web/Controllers/AiUsageReportController.cs +++ b/src/PowderCoating.Web/Controllers/AiUsageReportController.cs @@ -1,7 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using PowderCoating.Infrastructure.Data; +using PowderCoating.Application.Interfaces; +using PowderCoating.Core.Interfaces; using PowderCoating.Shared.Constants; namespace PowderCoating.Web.Controllers; @@ -9,108 +9,74 @@ namespace PowderCoating.Web.Controllers; [Authorize(Policy = AppConstants.Policies.SuperAdminOnly)] public class AiUsageReportController : Controller { - private readonly ApplicationDbContext _context; + private readonly IUnitOfWork _unitOfWork; + private readonly IAiUsageReportService _aiUsageReport; private readonly ILogger _logger; - public AiUsageReportController(ApplicationDbContext context, ILogger logger) + public AiUsageReportController( + IUnitOfWork unitOfWork, + IAiUsageReportService aiUsageReport, + ILogger logger) { - _context = context; + _unitOfWork = unitOfWork; + _aiUsageReport = aiUsageReport; _logger = logger; } /// /// Platform-wide AI usage report. Shows per-company call counts, photo upload totals, top /// feature used, and a usage tier so SuperAdmins can identify abusive or unusually heavy tenants. - /// All queries use IgnoreQueryFilters() where needed to cross tenant boundaries. + /// Companies and plan configs come from IUnitOfWork; AiUsageLogs aggregations and photo counts + /// come from IAiUsageReportService (which runs SQL GROUP BY queries via ApplicationDbContext). /// public async Task Index() { try { - var now = DateTime.UtcNow; - var todayStart = now.Date; - var last7Start = todayStart.AddDays(-7); - var last30Start = todayStart.AddDays(-30); - - // Companies (non-deleted only) - var companies = await _context.Companies - .IgnoreQueryFilters() + var companies = (await _unitOfWork.Companies.GetAllAsync(ignoreQueryFilters: true)) .Where(c => !c.IsDeleted) .Select(c => new { c.Id, c.CompanyName, c.SubscriptionPlan, c.IsActive }) - .ToListAsync(); + .ToList(); - // Plan display names from SubscriptionPlanConfig - var planConfigs = await _context.Set() - .IgnoreQueryFilters() - .Where(p => !p.IsDeleted) - .Select(p => new { p.Plan, p.DisplayName }) - .ToListAsync(); + var planConfigs = await _unitOfWork.SubscriptionPlanConfigs.GetAllAsync(); var planNames = planConfigs.ToDictionary(p => p.Plan, p => p.DisplayName); - // All-time usage grouped by company — the four count windows are computed in SQL - var usageByCompany = await _context.AiUsageLogs - .GroupBy(l => l.CompanyId) - .Select(g => new - { - CompanyId = g.Key, - Today = g.Count(l => l.CalledAt >= todayStart), - Last7Days = g.Count(l => l.CalledAt >= last7Start), - Last30Days = g.Count(l => l.CalledAt >= last30Start), - AllTime = g.Count() - }) - .ToListAsync(); + var data = await _aiUsageReport.GetReportDataAsync(); - // Top feature per company over the last 30 days - var featureStats = await _context.AiUsageLogs - .Where(l => l.CalledAt >= last30Start) - .GroupBy(l => new { l.CompanyId, l.Feature }) - .Select(g => new { g.Key.CompanyId, g.Key.Feature, Count = g.Count() }) - .ToListAsync(); - - var topFeatureByCompany = featureStats + var topFeatureByCompany = data.FeatureStats .GroupBy(f => f.CompanyId) .ToDictionary( g => g.Key, g => g.OrderByDescending(f => f.Count).First().Feature); - // Feature breakdown per company (last 30 days) - var featureBreakdownByCompany = featureStats + var featureBreakdownByCompany = data.FeatureStats .GroupBy(f => f.CompanyId) .ToDictionary( g => g.Key, g => g.ToDictionary(f => f.Feature, f => f.Count)); - // Total AI photos per company (all time, including deleted photos) - var photoCounts = await _context.QuotePhotos - .IgnoreQueryFilters() - .Where(p => p.IsAiAnalysisPhoto && !p.IsDeleted) - .GroupBy(p => p.CompanyId) - .Select(g => new { CompanyId = g.Key, Count = g.Count() }) - .ToDictionaryAsync(x => x.CompanyId, x => x.Count); - - // Build report rows - var usageDict = usageByCompany.ToDictionary(u => u.CompanyId); + var usageDict = data.UsageByCompany.ToDictionary(u => u.CompanyId); var rows = companies.Select(c => { usageDict.TryGetValue(c.Id, out var u); - photoCounts.TryGetValue(c.Id, out var photos); + data.PhotoCountsByCompany.TryGetValue(c.Id, out var photos); topFeatureByCompany.TryGetValue(c.Id, out var topFeature); featureBreakdownByCompany.TryGetValue(c.Id, out var breakdown); planNames.TryGetValue(c.SubscriptionPlan, out var planName); return new AiUsageReportRow { - CompanyId = c.Id, - CompanyName = c.CompanyName, - Plan = planName ?? $"Plan {c.SubscriptionPlan}", - IsActive = c.IsActive, - Today = u?.Today ?? 0, - Last7Days = u?.Last7Days ?? 0, - Last30Days = u?.Last30Days ?? 0, - AllTime = u?.AllTime ?? 0, - PhotoCount = photos, - TopFeature = topFeature, + CompanyId = c.Id, + CompanyName = c.CompanyName, + Plan = planName ?? $"Plan {c.SubscriptionPlan}", + IsActive = c.IsActive, + Today = u?.Today ?? 0, + Last7Days = u?.Last7Days ?? 0, + Last30Days = u?.Last30Days ?? 0, + AllTime = u?.AllTime ?? 0, + PhotoCount = photos, + TopFeature = topFeature, FeatureBreakdown = breakdown ?? [] }; }) @@ -118,15 +84,14 @@ public class AiUsageReportController : Controller .ThenByDescending(r => r.AllTime) .ToList(); - // Platform totals for summary cards var vm = new AiUsageReportViewModel { - Rows = rows, - TotalCallsLast30Days = rows.Sum(r => r.Last30Days), - TotalCallsToday = rows.Sum(r => r.Today), - CompaniesActiveToday = rows.Count(r => r.Today > 0), - TotalPhotosUploaded = rows.Sum(r => r.PhotoCount), - MostActiveCompany = rows.FirstOrDefault(r => r.Last30Days > 0)?.CompanyName ?? "—" + Rows = rows, + TotalCallsLast30Days = rows.Sum(r => r.Last30Days), + TotalCallsToday = rows.Sum(r => r.Today), + CompaniesActiveToday = rows.Count(r => r.Today > 0), + TotalPhotosUploaded = rows.Sum(r => r.PhotoCount), + MostActiveCompany = rows.FirstOrDefault(r => r.Last30Days > 0)?.CompanyName ?? "—" }; return View(vm); diff --git a/src/PowderCoating.Web/Controllers/AnnouncementsController.cs b/src/PowderCoating.Web/Controllers/AnnouncementsController.cs index 4e6614c..f01f85d 100644 --- a/src/PowderCoating.Web/Controllers/AnnouncementsController.cs +++ b/src/PowderCoating.Web/Controllers/AnnouncementsController.cs @@ -1,9 +1,8 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; using PowderCoating.Application.Interfaces; using PowderCoating.Core.Entities; -using PowderCoating.Infrastructure.Data; +using PowderCoating.Core.Interfaces; using PowderCoating.Shared.Constants; namespace PowderCoating.Web.Controllers; @@ -11,12 +10,12 @@ namespace PowderCoating.Web.Controllers; [Authorize(Policy = AppConstants.Policies.SuperAdminOnly)] public class AnnouncementsController : Controller { - private readonly ApplicationDbContext _db; + private readonly IUnitOfWork _unitOfWork; private readonly IInAppNotificationService _inApp; - public AnnouncementsController(ApplicationDbContext db, IInAppNotificationService inApp) + public AnnouncementsController(IUnitOfWork unitOfWork, IInAppNotificationService inApp) { - _db = db; + _unitOfWork = unitOfWork; _inApp = inApp; } @@ -25,18 +24,18 @@ public class AnnouncementsController : Controller /// public async Task Index() { - var announcements = await _db.Announcements + var announcements = (await _unitOfWork.Announcements.GetAllAsync()) .OrderByDescending(a => a.CreatedAt) - .ToListAsync(); + .ToList(); return View(announcements); } /// /// Shows the announcement creation form with sensible defaults: starts now, dismissible, and active. /// - public IActionResult Create() + public async Task Create() { - PopulateDropdowns(); + await PopulateDropdownsAsync(); return View(new Announcement { StartsAt = DateTime.Now, IsDismissible = true, IsActive = true }); } @@ -46,7 +45,7 @@ public class AnnouncementsController : Controller [HttpPost, ValidateAntiForgeryToken] public async Task Create(Announcement model) { - if (!ModelState.IsValid) { PopulateDropdowns(); return View(model); } + if (!ModelState.IsValid) { await PopulateDropdownsAsync(); return View(model); } model.CreatedByUserId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? ""; model.CreatedByUserName = User.Identity?.Name ?? "SuperAdmin"; @@ -54,10 +53,9 @@ public class AnnouncementsController : Controller model.StartsAt = model.StartsAt.ToUniversalTime(); if (model.ExpiresAt.HasValue) model.ExpiresAt = model.ExpiresAt.Value.ToUniversalTime(); - _db.Announcements.Add(model); - await _db.SaveChangesAsync(); + await _unitOfWork.Announcements.AddAsync(model); + await _unitOfWork.CompleteAsync(); - // Dispatch as in-app notifications to targeted companies await DispatchNotificationsAsync(model); TempData["Success"] = "Announcement created and sent as notifications."; @@ -69,9 +67,9 @@ public class AnnouncementsController : Controller /// public async Task Edit(int id) { - var announcement = await _db.Announcements.FindAsync(id); + var announcement = await _unitOfWork.Announcements.GetByIdAsync(id); if (announcement == null) return NotFound(); - PopulateDropdowns(); + await PopulateDropdownsAsync(); return View(announcement); } @@ -81,9 +79,9 @@ public class AnnouncementsController : Controller [HttpPost, ValidateAntiForgeryToken] public async Task Edit(int id, Announcement model) { - if (!ModelState.IsValid) { PopulateDropdowns(); return View(model); } + if (!ModelState.IsValid) { await PopulateDropdownsAsync(); return View(model); } - var existing = await _db.Announcements.FindAsync(id); + var existing = await _unitOfWork.Announcements.GetByIdAsync(id); if (existing == null) return NotFound(); existing.Title = model.Title; @@ -98,7 +96,7 @@ public class AnnouncementsController : Controller existing.IsActive = model.IsActive; existing.UpdatedAt = DateTime.UtcNow; - await _db.SaveChangesAsync(); + await _unitOfWork.CompleteAsync(); TempData["Success"] = "Announcement updated."; return RedirectToAction(nameof(Index)); } @@ -109,49 +107,42 @@ public class AnnouncementsController : Controller [HttpPost, ValidateAntiForgeryToken] public async Task Delete(int id) { - var announcement = await _db.Announcements.FindAsync(id); + var announcement = await _unitOfWork.Announcements.GetByIdAsync(id); if (announcement == null) return NotFound(); - _db.Announcements.Remove(announcement); - await _db.SaveChangesAsync(); + await _unitOfWork.Announcements.DeleteAsync(announcement); + await _unitOfWork.CompleteAsync(); TempData["Success"] = "Announcement deleted."; return RedirectToAction(nameof(Index)); } /// - /// Fans out the announcement as in-app notifications to each matching company. IgnoreQueryFilters is required to reach all active companies regardless of tenant context (this runs as SuperAdmin). Filtering by Target/Plan/Company happens before the foreach so only relevant tenants receive the notification. + /// Fans out the announcement as in-app notifications to each matching company. IgnoreQueryFilters is required to reach all active companies regardless of tenant context. Filtering by Target/Plan/Company happens after the fetch so only relevant tenants receive the notification. /// private async Task DispatchNotificationsAsync(Announcement model) { - IQueryable companyQuery = _db.Companies - .IgnoreQueryFilters() - .Where(c => !c.IsDeleted && c.IsActive); + var companies = (await _unitOfWork.Companies.FindAsync( + c => !c.IsDeleted && c.IsActive, ignoreQueryFilters: true)).ToList(); if (model.Target == "Plan" && model.TargetPlan.HasValue) - companyQuery = companyQuery.Where(c => c.SubscriptionPlan == model.TargetPlan.Value); + companies = companies.Where(c => c.SubscriptionPlan == model.TargetPlan.Value).ToList(); else if (model.Target == "Company" && model.TargetCompanyId.HasValue) - companyQuery = companyQuery.Where(c => c.Id == model.TargetCompanyId.Value); + companies = companies.Where(c => c.Id == model.TargetCompanyId.Value).ToList(); - var companyIds = await companyQuery.Select(c => c.Id).ToListAsync(); - - foreach (var companyId in companyIds) - { - await _inApp.CreateAsync( - companyId, - model.Title, - model.Message, - "Announcement"); - } + foreach (var company in companies) + await _inApp.CreateAsync(company.Id, model.Title, model.Message, "Announcement"); } /// - /// Loads company and plan lists into ViewBag for the Create/Edit form dropdowns. Uses AsNoTracking and IgnoreQueryFilters to bypass soft-delete and tenant filters so all companies are available for targeting. + /// Loads company and plan lists into ViewBag for the Create/Edit form dropdowns. Uses IgnoreQueryFilters to bypass soft-delete and tenant filters so all companies are available for targeting. /// - private void PopulateDropdowns() + private async Task PopulateDropdownsAsync() { - ViewBag.Companies = _db.Companies.AsNoTracking().IgnoreQueryFilters() - .Where(c => !c.IsDeleted).OrderBy(c => c.CompanyName) - .Select(c => new { c.Id, c.CompanyName }).ToList(); - ViewBag.PlanConfigs = _db.SubscriptionPlanConfigs.AsNoTracking().IgnoreQueryFilters() - .Where(p => p.IsActive).OrderBy(p => p.SortOrder).ToList(); + ViewBag.Companies = (await _unitOfWork.Companies.FindAsync(c => !c.IsDeleted, ignoreQueryFilters: true)) + .OrderBy(c => c.CompanyName) + .Select(c => new { c.Id, c.CompanyName }) + .ToList(); + ViewBag.PlanConfigs = (await _unitOfWork.SubscriptionPlanConfigs.FindAsync(p => p.IsActive, ignoreQueryFilters: true)) + .OrderBy(p => p.SortOrder) + .ToList(); } } diff --git a/src/PowderCoating.Web/Controllers/AuditLogController.cs b/src/PowderCoating.Web/Controllers/AuditLogController.cs index 86702e0..7d88735 100644 --- a/src/PowderCoating.Web/Controllers/AuditLogController.cs +++ b/src/PowderCoating.Web/Controllers/AuditLogController.cs @@ -14,6 +14,7 @@ namespace PowderCoating.Web.Controllers; /// the application — they are append-only by design to maintain an unambiguous /// trail of changes across all tenants. /// +// Intentional exception: platform audit log with a long PK; append-only infrastructure table outside the business entity graph; same reasoning as SystemLogsController. See docs/DATA_ACCESS_ARCHITECTURE.md — Permanent Exceptions. [Authorize(Policy = AppConstants.Policies.SuperAdminOnly)] public class AuditLogController : Controller { diff --git a/src/PowderCoating.Web/Controllers/BannedIpsController.cs b/src/PowderCoating.Web/Controllers/BannedIpsController.cs index 9a93b3c..f8c7808 100644 --- a/src/PowderCoating.Web/Controllers/BannedIpsController.cs +++ b/src/PowderCoating.Web/Controllers/BannedIpsController.cs @@ -1,9 +1,8 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; using PowderCoating.Core.Entities; -using PowderCoating.Infrastructure.Data; +using PowderCoating.Core.Interfaces; using PowderCoating.Shared.Constants; namespace PowderCoating.Web.Controllers; @@ -15,28 +14,26 @@ namespace PowderCoating.Web.Controllers; [Authorize(Policy = AppConstants.Policies.SuperAdminOnly)] public class BannedIpsController : Controller { - private readonly ApplicationDbContext _db; + private readonly IUnitOfWork _unitOfWork; private readonly UserManager _userManager; private readonly ILogger _logger; public BannedIpsController( - ApplicationDbContext db, + IUnitOfWork unitOfWork, UserManager userManager, ILogger logger) { - _db = db; + _unitOfWork = unitOfWork; _userManager = userManager; _logger = logger; } /// Lists all banned IPs, showing active and expired separately. - // GET: BannedIps public async Task Index() { - var bans = await _db.BannedIps + var bans = (await _unitOfWork.BannedIps.GetAllAsync()) .OrderByDescending(b => b.BannedAt) - .ToListAsync(); - + .ToList(); return View(bans); } @@ -44,9 +41,7 @@ public class BannedIpsController : Controller /// Adds a new IP ban. Rejects obviously invalid formats but doesn't require /// a perfect regex — admins are trusted to enter valid IPs. /// - // POST: BannedIps/Add - [HttpPost] - [ValidateAntiForgeryToken] + [HttpPost, ValidateAntiForgeryToken] public async Task Add(string ipAddress, string? reason, DateTime? expiresAt) { if (string.IsNullOrWhiteSpace(ipAddress)) @@ -57,15 +52,13 @@ public class BannedIpsController : Controller ipAddress = ipAddress.Trim(); - // Basic sanity check — must look like an IPv4 or IPv6 address if (!System.Net.IPAddress.TryParse(ipAddress, out _)) { TempData["Error"] = $"'{ipAddress}' is not a valid IP address."; return RedirectToAction(nameof(Index)); } - // Don't duplicate an active ban for the same IP - var existing = await _db.BannedIps.FirstOrDefaultAsync(b => b.IpAddress == ipAddress && b.IsActive); + var existing = await _unitOfWork.BannedIps.FirstOrDefaultAsync(b => b.IpAddress == ipAddress && b.IsActive); if (existing != null) { TempData["Error"] = $"{ipAddress} already has an active ban (added {existing.BannedAt:MMM dd, yyyy})."; @@ -74,7 +67,7 @@ public class BannedIpsController : Controller var currentUser = await _userManager.GetUserAsync(User); - _db.BannedIps.Add(new BannedIp + await _unitOfWork.BannedIps.AddAsync(new BannedIp { IpAddress = ipAddress, Reason = reason?.Trim(), @@ -83,8 +76,7 @@ public class BannedIpsController : Controller ExpiresAt = expiresAt, IsActive = true }); - - await _db.SaveChangesAsync(); + await _unitOfWork.CompleteAsync(); _logger.LogWarning("IP {IP} banned by {Admin}. Reason: {Reason}", ipAddress, User.Identity?.Name, reason); TempData["Success"] = $"{ipAddress} has been banned."; @@ -92,12 +84,10 @@ public class BannedIpsController : Controller } /// Lifts a ban immediately by marking IsActive = false. - // POST: BannedIps/Lift/5 - [HttpPost] - [ValidateAntiForgeryToken] + [HttpPost, ValidateAntiForgeryToken] public async Task Lift(int id) { - var ban = await _db.BannedIps.FindAsync(id); + var ban = await _unitOfWork.BannedIps.GetByIdAsync(id); if (ban == null) { TempData["Error"] = "Ban not found."; @@ -105,7 +95,7 @@ public class BannedIpsController : Controller } ban.IsActive = false; - await _db.SaveChangesAsync(); + await _unitOfWork.CompleteAsync(); _logger.LogInformation("IP ban lifted for {IP} by {Admin}", ban.IpAddress, User.Identity?.Name); TempData["Success"] = $"Ban on {ban.IpAddress} has been lifted."; @@ -113,25 +103,21 @@ public class BannedIpsController : Controller } /// Permanently deletes a ban record. - // POST: BannedIps/Delete/5 - [HttpPost] - [ValidateAntiForgeryToken] + [HttpPost, ValidateAntiForgeryToken] public async Task Delete(int id) { - var ban = await _db.BannedIps.FindAsync(id); + var ban = await _unitOfWork.BannedIps.GetByIdAsync(id); if (ban != null) { - _db.BannedIps.Remove(ban); - await _db.SaveChangesAsync(); + await _unitOfWork.BannedIps.DeleteAsync(ban); + await _unitOfWork.CompleteAsync(); _logger.LogInformation("IP ban record deleted for {IP} by {Admin}", ban.IpAddress, User.Identity?.Name); TempData["Success"] = $"Ban record for {ban.IpAddress} deleted."; } - return RedirectToAction(nameof(Index)); } /// Returns the requesting client's IP so the admin can pre-fill it quickly. - // GET: BannedIps/MyIp public IActionResult MyIp() { return Json(new { ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown" }); diff --git a/src/PowderCoating.Web/Controllers/BugReportController.cs b/src/PowderCoating.Web/Controllers/BugReportController.cs index 93fad69..307f74b 100644 --- a/src/PowderCoating.Web/Controllers/BugReportController.cs +++ b/src/PowderCoating.Web/Controllers/BugReportController.cs @@ -10,7 +10,6 @@ using PowderCoating.Application.Interfaces; using PowderCoating.Core.Entities; using PowderCoating.Core.Enums; using PowderCoating.Core.Interfaces; -using PowderCoating.Infrastructure.Data; using PowderCoating.Shared.Constants; namespace PowderCoating.Web.Controllers; @@ -22,7 +21,6 @@ public class BugReportController : Controller private readonly IMapper _mapper; private readonly ITenantContext _tenantContext; private readonly UserManager _userManager; - private readonly ApplicationDbContext _context; private readonly IEmailService _emailService; private readonly IAdminNotificationService _adminNotification; private readonly IAzureBlobStorageService _blobService; @@ -40,7 +38,6 @@ public class BugReportController : Controller IMapper mapper, ITenantContext tenantContext, UserManager userManager, - ApplicationDbContext context, IEmailService emailService, IAdminNotificationService adminNotification, IAzureBlobStorageService blobService, @@ -51,7 +48,6 @@ public class BugReportController : Controller _mapper = mapper; _tenantContext = tenantContext; _userManager = userManager; - _context = context; _emailService = emailService; _adminNotification = adminNotification; _blobService = blobService; @@ -153,7 +149,7 @@ public class BugReportController : Controller ContentType = file.ContentType, FileSizeBytes = file.Length }; - _context.BugReportAttachments.Add(attachment); + await _unitOfWork.BugReportAttachments.AddAsync(attachment); uploadedCount++; } else @@ -164,7 +160,7 @@ public class BugReportController : Controller } if (uploadedCount > 0) - await _context.SaveChangesAsync(); + await _unitOfWork.CompleteAsync(); } _logger.LogInformation("Bug report #{Id} submitted by {UserName} ({Company}): {Title} with {AttachmentCount} attachment(s)", @@ -211,16 +207,13 @@ public class BugReportController : Controller pageNumber = Math.Max(1, pageNumber); pageSize = pageSize is 10 or 25 or 50 or 100 ? pageSize : 25; - var query = _context.BugReports - .AsNoTracking() - .IgnoreQueryFilters() - .Where(r => !r.IsDeleted) - .AsQueryable(); + var allReports = (await _unitOfWork.BugReports.GetAllAsync(ignoreQueryFilters: true)) + .AsEnumerable(); if (!string.IsNullOrWhiteSpace(searchTerm)) { var search = searchTerm.ToLower(); - query = query.Where(r => + allReports = allReports.Where(r => r.Title.ToLower().Contains(search) || r.Description.ToLower().Contains(search) || r.SubmittedByUserName.ToLower().Contains(search)); @@ -228,31 +221,31 @@ public class BugReportController : Controller if (!string.IsNullOrWhiteSpace(statusFilter) && Enum.TryParse(statusFilter, out var status)) - query = query.Where(r => r.Status == status); + allReports = allReports.Where(r => r.Status == status); if (!string.IsNullOrWhiteSpace(priorityFilter) && Enum.TryParse(priorityFilter, out var priority)) - query = query.Where(r => r.Priority == priority); + allReports = allReports.Where(r => r.Priority == priority); - query = (sortColumn, sortDirection == "asc") switch + allReports = (sortColumn, sortDirection == "asc") switch { - ("Title", true) => query.OrderBy(r => r.Title), - ("Title", false) => query.OrderByDescending(r => r.Title), - ("Status", true) => query.OrderBy(r => r.Status), - ("Status", false) => query.OrderByDescending(r => r.Status), - ("Priority", true) => query.OrderBy(r => r.Priority), - ("Priority", false) => query.OrderByDescending(r => r.Priority), - ("Submitted", true) => query.OrderBy(r => r.SubmittedByUserName), - ("Submitted", false) => query.OrderByDescending(r => r.SubmittedByUserName), - (_, true) => query.OrderBy(r => r.CreatedAt), - _ => query.OrderByDescending(r => r.CreatedAt) + ("Title", true) => allReports.OrderBy(r => r.Title), + ("Title", false) => allReports.OrderByDescending(r => r.Title), + ("Status", true) => allReports.OrderBy(r => r.Status), + ("Status", false) => allReports.OrderByDescending(r => r.Status), + ("Priority", true) => allReports.OrderBy(r => r.Priority), + ("Priority", false) => allReports.OrderByDescending(r => r.Priority), + ("Submitted", true) => allReports.OrderBy(r => r.SubmittedByUserName), + ("Submitted", false) => allReports.OrderByDescending(r => r.SubmittedByUserName), + (_, true) => allReports.OrderBy(r => r.CreatedAt), + _ => allReports.OrderByDescending(r => r.CreatedAt) }; - var totalCount = await query.CountAsync(); - var items = await query + var totalCount = allReports.Count(); + var items = allReports .Skip((pageNumber - 1) * pageSize) .Take(pageSize) - .ToListAsync(); + .ToList(); var dtos = _mapper.Map>(items); @@ -291,12 +284,10 @@ public class BugReportController : Controller var dto = _mapper.Map(bugReport); - var attachments = await _context.BugReportAttachments - .AsNoTracking() - .IgnoreQueryFilters() - .Where(a => a.BugReportId == id && !a.IsDeleted) + var attachments = (await _unitOfWork.BugReportAttachments.FindAsync( + a => a.BugReportId == id && !a.IsDeleted, ignoreQueryFilters: true)) .OrderBy(a => a.CreatedAt) - .ToListAsync(); + .ToList(); dto.Attachments = _mapper.Map>(attachments); @@ -319,10 +310,7 @@ public class BugReportController : Controller [HttpGet] public async Task Attachment(int id) { - var attachment = await _context.BugReportAttachments - .AsNoTracking() - .IgnoreQueryFilters() - .FirstOrDefaultAsync(a => a.Id == id && !a.IsDeleted); + var attachment = await _unitOfWork.BugReportAttachments.GetByIdAsync(id, ignoreQueryFilters: true); if (attachment == null) return NotFound(); diff --git a/src/PowderCoating.Web/Controllers/CompaniesController.cs b/src/PowderCoating.Web/Controllers/CompaniesController.cs index 5e70f69..2db07b0 100644 --- a/src/PowderCoating.Web/Controllers/CompaniesController.cs +++ b/src/PowderCoating.Web/Controllers/CompaniesController.cs @@ -7,7 +7,7 @@ using PowderCoating.Application.DTOs.Company; using PowderCoating.Application.Interfaces; using PowderCoating.Core.Entities; using PowderCoating.Core.Interfaces; -using PowderCoating.Infrastructure.Data; +using PowderCoating.Core.Interfaces.Services; using PowderCoating.Shared.Constants; using PowderCoating.Web.Extensions; using System.Security.Claims; @@ -24,7 +24,9 @@ public class CompaniesController : Controller private readonly IMapper _mapper; private readonly UserManager _userManager; private readonly ISeedDataService _seedDataService; - private readonly ApplicationDbContext _context; + private readonly ICompanyListService _companyList; + private readonly ICompanyDataPurgeService _companyPurge; + private readonly IAuditLogService _auditLog; private readonly IInAppNotificationService _inApp; private readonly ILogger _logger; @@ -33,7 +35,9 @@ public class CompaniesController : Controller IMapper mapper, UserManager userManager, ISeedDataService seedDataService, - ApplicationDbContext context, + ICompanyListService companyList, + ICompanyDataPurgeService companyPurge, + IAuditLogService auditLog, IInAppNotificationService inApp, ILogger logger) { @@ -41,7 +45,9 @@ public class CompaniesController : Controller _mapper = mapper; _userManager = userManager; _seedDataService = seedDataService; - _context = context; + _companyList = companyList; + _companyPurge = companyPurge; + _auditLog = auditLog; _inApp = inApp; _logger = logger; } @@ -67,88 +73,27 @@ public class CompaniesController : Controller pageNumber = Math.Max(1, pageNumber); pageSize = pageSize is 10 or 25 or 50 or 100 ? pageSize : 25; - var query = _context.Companies - .AsNoTracking() - .IgnoreQueryFilters() - .Where(c => !c.IsDeleted) - .AsQueryable(); - - if (!string.IsNullOrWhiteSpace(searchTerm)) - { - var s = searchTerm.ToLower(); - query = query.Where(c => - c.CompanyName.ToLower().Contains(s) || - (c.CompanyCode != null && c.CompanyCode.ToLower().Contains(s)) || - (c.PrimaryContactEmail != null && c.PrimaryContactEmail.ToLower().Contains(s)) || - (c.Phone != null && c.Phone.ToLower().Contains(s))); - } - - query = (sortColumn, sortDirection == "asc") switch - { - ("CompanyName", true) => query.OrderBy(c => c.CompanyName), - ("CompanyName", false) => query.OrderByDescending(c => c.CompanyName), - ("Plan", true) => query.OrderBy(c => c.SubscriptionPlan), - ("Plan", false) => query.OrderByDescending(c => c.SubscriptionPlan), - ("Status", true) => query.OrderBy(c => c.IsActive), - ("Status", false) => query.OrderByDescending(c => c.IsActive), - ("Created", true) => query.OrderBy(c => c.CreatedAt), - ("Created", false) => query.OrderByDescending(c => c.CreatedAt), - _ => query.OrderBy(c => c.CompanyName) - }; - - var totalCount = await query.CountAsync(); - var companies = await query - .Include(c => c.Users) - .Skip((pageNumber - 1) * pageSize) - .Take(pageSize) - .ToListAsync(); + var (companies, totalCount) = await _companyList.GetPagedAsync( + searchTerm, sortColumn, sortDirection, pageNumber, pageSize); var companyDtos = _mapper.Map>(companies); - // Populate job/quote/customer counts efficiently via group queries - if (companyDtos.Any()) + if (companyDtos.Count > 0) { var ids = companyDtos.Select(c => c.Id).ToList(); - - var jobCounts = await _context.Jobs.IgnoreQueryFilters() - .Where(j => ids.Contains(j.CompanyId) && !j.IsDeleted) - .GroupBy(j => j.CompanyId) - .Select(g => new { g.Key, Count = g.Count() }) - .ToDictionaryAsync(x => x.Key, x => x.Count); - - var quoteCounts = await _context.Quotes.IgnoreQueryFilters() - .Where(q => ids.Contains(q.CompanyId) && !q.IsDeleted) - .GroupBy(q => q.CompanyId) - .Select(g => new { g.Key, Count = g.Count() }) - .ToDictionaryAsync(x => x.Key, x => x.Count); - - var customerCounts = await _context.Customers.IgnoreQueryFilters() - .Where(c => ids.Contains(c.CompanyId) && !c.IsDeleted) - .GroupBy(c => c.CompanyId) - .Select(g => new { g.Key, Count = g.Count() }) - .ToDictionaryAsync(x => x.Key, x => x.Count); - - var wizardData = await _context.CompanyPreferences.IgnoreQueryFilters() - .Where(p => ids.Contains(p.CompanyId) && p.SetupWizardCompleted) - .Select(p => new - { - p.CompanyId, - p.SetupWizardCompletedAt, - p.SetupWizardCompletedByName - }) - .ToDictionaryAsync(x => x.CompanyId); + var summary = await _companyList.GetCountSummaryAsync(ids); foreach (var dto in companyDtos) { - dto.JobCount = jobCounts.GetValueOrDefault(dto.Id, 0); - dto.QuoteCount = quoteCounts.GetValueOrDefault(dto.Id, 0); - dto.CustomerCount = customerCounts.GetValueOrDefault(dto.Id, 0); + dto.JobCount = summary.JobCounts.GetValueOrDefault(dto.Id, 0); + dto.QuoteCount = summary.QuoteCounts.GetValueOrDefault(dto.Id, 0); + dto.CustomerCount = summary.CustomerCounts.GetValueOrDefault(dto.Id, 0); - if (wizardData.TryGetValue(dto.Id, out var w)) + if (summary.WizardInfo.TryGetValue(dto.Id, out var w)) { dto.WizardCompleted = true; - dto.WizardCompletedAt = w.SetupWizardCompletedAt; - dto.WizardCompletedByName = w.SetupWizardCompletedByName; + dto.WizardCompletedAt = w.CompletedAt; + dto.WizardCompletedByName = w.CompletedByName; } } } @@ -380,8 +325,6 @@ public class CompaniesController : Controller } else { - // If user creation failed, we should consider rolling back company creation - // For now, log the error and inform the user _logger.LogError("Failed to create admin user for company {CompanyName}: {Errors}", company.CompanyName, string.Join(", ", result.Errors.Select(e => e.Description))); @@ -441,14 +384,10 @@ public class CompaniesController : Controller public async Task Edit(int id, UpdateCompanyDto model) { if (id != model.Id) - { return NotFound(); - } if (!ModelState.IsValid) - { return View(model); - } try { @@ -473,9 +412,7 @@ public class CompaniesController : Controller } } - // Update company properties _mapper.Map(model, company); - await _unitOfWork.CompleteAsync(); _logger.LogInformation("Company {CompanyName} updated successfully by {User}", @@ -560,7 +497,6 @@ public class CompaniesController : Controller var companyName = company.CompanyName; var userCount = company.Users.Count; - // Soft-delete the company and deactivate all users company.IsDeleted = true; company.IsActive = false; company.UpdatedAt = DateTime.UtcNow; @@ -573,8 +509,7 @@ public class CompaniesController : Controller await _unitOfWork.CompleteAsync(); - // Write audit log - _context.AuditLogs.Add(new AuditLog + await _auditLog.LogAsync(new AuditLog { UserId = adminUserId, UserName = adminName, @@ -588,7 +523,6 @@ public class CompaniesController : Controller Timestamp = DateTime.UtcNow, IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString() }); - await _context.SaveChangesAsync(); _logger.LogWarning("Company {CompanyName} (ID:{CompanyId}) soft-deleted by {User}", companyName, id, adminName); @@ -625,8 +559,7 @@ public class CompaniesController : Controller return RedirectToAction(nameof(Index)); } - var company = await _context.Companies.IgnoreQueryFilters() - .FirstOrDefaultAsync(c => c.Id == id); + var company = await _unitOfWork.Companies.GetByIdAsync(id, ignoreQueryFilters: true); if (company == null) { @@ -640,91 +573,25 @@ public class CompaniesController : Controller try { - // ── Tier 1: Leaf children (must go before their parents) ───────────── - // JobItem children - await _context.JobItemPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.JobItemCoats.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - - // QuoteItem children - await _context.QuoteItemPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.QuoteItemCoats.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - - // AnnouncementDismissals (no CompanyId — delete by user or company-targeted announcement) + // Load user IDs first — needed for announcement-dismissal cleanup in the purge service var userIds = await _userManager.Users.IgnoreQueryFilters() .Where(u => u.CompanyId == id).Select(u => u.Id).ToListAsync(); - var announcementIds = await _context.Announcements - .Where(a => a.TargetCompanyId == id).Select(a => a.Id).ToListAsync(); - await _context.AnnouncementDismissals.IgnoreQueryFilters() - .Where(x => userIds.Contains(x.UserId) || announcementIds.Contains(x.AnnouncementId)) - .ExecuteDeleteAsync(); - // ── Tier 2: Mid-level children ──────────────────────────────────────── - await _context.JobItems.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.QuoteItems.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.JobStatusHistory.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.JobChangeHistories.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.JobPhotos.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.JobNotes.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.JobDailyPriorities.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.QuoteChangeHistories.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.CustomerNotes.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.InventoryTransactions.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.MaintenanceRecords.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.BillLineItems.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.BillPayments.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.Payments.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.InvoiceItems.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.JobPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.QuotePrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); + // Tiers 1-4: bulk delete all business data (service mirrors the original tier ordering) + await _companyPurge.DeleteAllBusinessDataAsync(id, userIds); - // ── Tier 3: Top-level company entities ─────────────────────────────── - // Order matters: child-side of FK must be deleted before parent-side. - // Invoices/Appointments → Customers; Bills/Expenses → Vendors - await _context.Invoices.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.Appointments.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.Jobs.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.Quotes.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.Customers.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.Bills.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.Expenses.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.Vendors.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.InventoryItems.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.Equipment.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.OvenCosts.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.CatalogItems.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.Accounts.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.NotificationLogs.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.NotificationTemplates.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - // Announcements are platform-wide; only delete company-targeted ones (TargetCompanyId) - await _context.Announcements.Where(x => x.TargetCompanyId == id).ExecuteDeleteAsync(); - await _context.BugReports.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.ShopWorkers.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.PrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - - // ── Tier 4: Company configs and lookup tables ───────────────────────── - await _context.CatalogCategories.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.InventoryCategoryLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.JobStatusLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.JobPriorityLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.QuoteStatusLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.AppointmentStatusLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.AppointmentTypeLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.PricingTiers.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.CompanyOperatingCosts.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.CompanyPreferences.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - - // ── Tier 5: Users (via Identity to cascade AspNetUser* tables) ──────── + // Tier 5: delete Identity users so AspNetUser* tables cascade correctly var users = await _userManager.Users.IgnoreQueryFilters() .Where(u => u.CompanyId == id).ToListAsync(); var userCount = users.Count; foreach (var user in users) await _userManager.DeleteAsync(user); - // ── Tier 6: Company record ──────────────────────────────────────────── - await _context.Companies.IgnoreQueryFilters().Where(c => c.Id == id).ExecuteDeleteAsync(); + // Tier 6: delete company record + await _unitOfWork.Companies.DeleteAsync(company); + await _unitOfWork.CompleteAsync(); - // Write audit log (use platform default company context — no companyId since it's gone) - _context.AuditLogs.Add(new AuditLog + await _auditLog.LogAsync(new AuditLog { UserId = adminUserId, UserName = adminName, @@ -738,7 +605,6 @@ public class CompaniesController : Controller Timestamp = DateTime.UtcNow, IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString() }); - await _context.SaveChangesAsync(); _logger.LogWarning("Company {CompanyName} (ID:{CompanyId}) HARD DELETED by {User}. {UserCount} users removed.", companyName, id, adminName, userCount); @@ -764,8 +630,6 @@ public class CompaniesController : Controller /// to record the action. This operation is irreversible. /// // POST: Companies/ResetData/5 - // Permanently hard-deletes all business data for a company while keeping the company record, - // its users, operating costs, preferences, and lookup tables intact. [HttpPost] [ValidateAntiForgeryToken] public async Task ResetData(int id, string confirmation) @@ -776,8 +640,7 @@ public class CompaniesController : Controller return RedirectToAction(nameof(Details), new { id }); } - var company = await _context.Companies.IgnoreQueryFilters() - .FirstOrDefaultAsync(c => c.Id == id); + var company = await _unitOfWork.Companies.GetByIdAsync(id, ignoreQueryFilters: true); if (company == null) { @@ -791,94 +654,9 @@ public class CompaniesController : Controller try { - // ── Tier 0: Grandchildren ───────────────────────────────────────────── - await _context.JobTemplateItemPrepServices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.JobTemplateItemCoats .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.JobItemPrepServices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.JobItemCoats .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.QuoteItemPrepServices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.QuoteItemCoats .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.GiftCertificateRedemptions .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.CreditMemoApplications .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.OvenBatchItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); + await _companyPurge.ResetBusinessDataAsync(id); - // AnnouncementDismissals for company-targeted announcements - var announcementIds = await _context.Announcements - .Where(a => a.TargetCompanyId == id).Select(a => a.Id).ToListAsync(); - if (announcementIds.Any()) - await _context.AnnouncementDismissals.IgnoreQueryFilters() - .Where(x => announcementIds.Contains(x.AnnouncementId)) - .ExecuteDeleteAsync(); - - // ── Tier 1: Children ────────────────────────────────────────────────── - await _context.JobTemplateItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.JobItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.QuoteItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.JobStatusHistory .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.JobChangeHistories .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.JobPhotos .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.JobNotes .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.JobDailyPriorities .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.JobTimeEntries .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.JobPrepServices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.ReworkRecords .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.QuoteChangeHistories.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.QuotePrepServices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.QuotePhotos .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.CustomerNotes .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.InventoryTransactions.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.MaintenanceRecords .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.BillLineItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.BillPayments .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.Payments .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.Deposits .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.InvoiceItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.PurchaseOrderItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.AiItemPredictions .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.PowderUsageLogs .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.ShopWorkerRoleCosts .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.OvenBatches .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.Refunds .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.CreditMemos .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.GiftCertificates .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - - // ── Tier 2: Top-level business entities ────────────────────────────── - // Order matters: child-side of FK must be deleted before parent-side. - // Invoices/Appointments → Customers; Bills/PurchaseOrders/Expenses → Vendors - await _context.Invoices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.Appointments .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.Jobs .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.JobTemplates .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.Quotes .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.Customers .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.Bills .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.PurchaseOrders .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.Expenses .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.Vendors .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.CatalogItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.InventoryItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.Equipment .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.OvenCosts .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.Accounts .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.NotificationLogs .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.ShopWorkers .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.PrepServices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.CatalogCategories.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - await _context.InventoryCategoryLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync(); - // Company-targeted announcements only (platform-wide announcements are left alone) - await _context.Announcements.Where(x => x.TargetCompanyId == id).ExecuteDeleteAsync(); - - // Reset QB migration wizard progress - var prefs = await _context.CompanyPreferences.IgnoreQueryFilters() - .FirstOrDefaultAsync(p => p.CompanyId == id); - if (prefs?.QbMigrationStateJson != null) - { - prefs.QbMigrationStateJson = null; - prefs.UpdatedAt = DateTime.UtcNow; - } - - // Audit log - _context.AuditLogs.Add(new AuditLog + await _auditLog.LogAsync(new AuditLog { UserId = adminUserId, UserName = adminName, @@ -892,7 +670,6 @@ public class CompaniesController : Controller Timestamp = DateTime.UtcNow, IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString() }); - await _context.SaveChangesAsync(); _logger.LogWarning( "Company {CompanyName} (ID:{CompanyId}) ALL BUSINESS DATA RESET by {User}.", @@ -960,9 +737,7 @@ public class CompaniesController : Controller { var company = await _unitOfWork.Companies.GetByIdAsync(model.CompanyId, ignoreQueryFilters: true); if (company != null) - { model.CompanyName = company.CompanyName; - } return View(model); } @@ -976,7 +751,6 @@ public class CompaniesController : Controller return RedirectToAction(nameof(Index)); } - // Check if user already exists var existingUser = await _userManager.FindByEmailAsync(model.Email); if (existingUser != null) { @@ -985,7 +759,6 @@ public class CompaniesController : Controller return View(model); } - // Create admin user for the company var adminUser = new ApplicationUser { UserName = model.Email, @@ -1018,9 +791,7 @@ public class CompaniesController : Controller else { foreach (var error in result.Errors) - { ModelState.AddModelError("", error.Description); - } model.CompanyName = company.CompanyName; return View(model); } @@ -1054,21 +825,13 @@ public class CompaniesController : Controller return NotFound(new { error = "User not found." }); // Use the viewed company's timezone so timestamps match the tenant's local time - var tz = await _context.Companies - .Where(c => c.Id == companyId) - .Select(c => c.TimeZone) - .FirstOrDefaultAsync(); + var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true); + var tz = company?.TimeZone; var logs = new List(); try { - var rawLogs = await _context.AuditLogs - .AsNoTracking() - .Where(l => l.UserId == userId && l.EntityType == "ApplicationUser") - .OrderByDescending(l => l.Timestamp) - .Take(50) - .Select(l => new { l.Action, l.IpAddress, l.Timestamp, l.NewValues }) - .ToListAsync(); + var rawLogs = await _auditLog.GetUserActivityAsync(userId); logs = rawLogs.Select(l => (dynamic)new { diff --git a/src/PowderCoating.Web/Controllers/CompanySettingsController.cs b/src/PowderCoating.Web/Controllers/CompanySettingsController.cs index 546c5e2..9eb6a06 100644 --- a/src/PowderCoating.Web/Controllers/CompanySettingsController.cs +++ b/src/PowderCoating.Web/Controllers/CompanySettingsController.cs @@ -12,6 +12,7 @@ using PowderCoating.Application.Services; using PowderCoating.Core.Entities; using PowderCoating.Core.Enums; using PowderCoating.Core.Interfaces; +using PowderCoating.Core.Interfaces.Services; using PowderCoating.Infrastructure.Data; using PowderCoating.Shared.Constants; using System.Security.Claims; @@ -29,7 +30,7 @@ public class CompanySettingsController : Controller private readonly ILookupCacheService _lookupCache; private readonly IStripeConnectService _stripeConnect; private readonly IConfiguration _configuration; - private readonly ApplicationDbContext _context; + private readonly IAuditLogService _auditLog; private readonly UserManager _userManager; private readonly SignInManager _signInManager; @@ -42,7 +43,7 @@ public class CompanySettingsController : Controller ILookupCacheService lookupCache, IStripeConnectService stripeConnect, IConfiguration configuration, - ApplicationDbContext context, + IAuditLogService auditLog, UserManager userManager, SignInManager signInManager) { @@ -54,7 +55,7 @@ public class CompanySettingsController : Controller _lookupCache = lookupCache; _stripeConnect = stripeConnect; _configuration = configuration; - _context = context; + _auditLog = auditLog; _userManager = userManager; _signInManager = signInManager; } @@ -126,9 +127,7 @@ public class CompanySettingsController : Controller var dto = _mapper.Map(company); // Populate AllowOnlinePayments from subscription plan config - var planConfig = await _context.Set() - .AsNoTracking() - .FirstOrDefaultAsync(p => p.Plan == company.SubscriptionPlan); + var planConfig = await _unitOfWork.SubscriptionPlanConfigs.FirstOrDefaultAsync(p => p.Plan == company.SubscriptionPlan); dto.AllowOnlinePayments = planConfig?.AllowOnlinePayments ?? false; // Flag whether Stripe Connect is configured (non-placeholder client ID) @@ -2805,10 +2804,10 @@ public class CompanySettingsController : Controller if (company == null) return NotFound(); var userCount = await _userManager.Users.CountAsync(u => u.CompanyId == companyId.Value); - var jobCount = await _context.Jobs.CountAsync(j => j.CompanyId == companyId.Value); - var quoteCount = await _context.Quotes.CountAsync(q => q.CompanyId == companyId.Value); - var custCount = await _context.Customers.CountAsync(c => c.CompanyId == companyId.Value); - var invCount = await _context.Invoices.CountAsync(i => i.CompanyId == companyId.Value); + var jobCount = await _unitOfWork.Jobs.CountAsync(j => j.CompanyId == companyId.Value); + var quoteCount = await _unitOfWork.Quotes.CountAsync(q => q.CompanyId == companyId.Value); + var custCount = await _unitOfWork.Customers.CountAsync(c => c.CompanyId == companyId.Value); + var invCount = await _unitOfWork.Invoices.CountAsync(i => i.CompanyId == companyId.Value); ViewBag.CompanyName = company.CompanyName; ViewBag.UserCount = userCount; @@ -2859,10 +2858,10 @@ public class CompanySettingsController : Controller // ── Gather counts for the audit snapshot ───────────────────────── var userCount = company.Users.Count; - var jobCount = await _context.Jobs.CountAsync(j => j.CompanyId == companyId.Value); - var quoteCount = await _context.Quotes.CountAsync(q => q.CompanyId == companyId.Value); - var custCount = await _context.Customers.CountAsync(c => c.CompanyId == companyId.Value); - var invCount = await _context.Invoices.CountAsync(i => i.CompanyId == companyId.Value); + var jobCount = await _unitOfWork.Jobs.CountAsync(j => j.CompanyId == companyId.Value); + var quoteCount = await _unitOfWork.Quotes.CountAsync(q => q.CompanyId == companyId.Value); + var custCount = await _unitOfWork.Customers.CountAsync(c => c.CompanyId == companyId.Value); + var invCount = await _unitOfWork.Invoices.CountAsync(i => i.CompanyId == companyId.Value); // ── Soft-delete the company ─────────────────────────────────────── var now = DateTime.UtcNow; @@ -2881,7 +2880,7 @@ public class CompanySettingsController : Controller await _unitOfWork.CompleteAsync(); // ── Write audit log ─────────────────────────────────────────────── - _context.AuditLogs.Add(new AuditLog + await _auditLog.LogAsync(new AuditLog { UserId = requestingUserId, UserName = requestingUserName, @@ -2899,7 +2898,6 @@ public class CompanySettingsController : Controller Timestamp = now, IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString() }); - await _context.SaveChangesAsync(); _logger.LogWarning( "Self-service account deletion: company {CompanyName} (ID:{CompanyId}) deleted by {User}. " + diff --git a/src/PowderCoating.Web/Controllers/CompanyUsersController.cs b/src/PowderCoating.Web/Controllers/CompanyUsersController.cs index 201b847..548f0d1 100644 --- a/src/PowderCoating.Web/Controllers/CompanyUsersController.cs +++ b/src/PowderCoating.Web/Controllers/CompanyUsersController.cs @@ -9,7 +9,6 @@ using PowderCoating.Application.DTOs.User; using PowderCoating.Application.Interfaces; using PowderCoating.Core.Entities; using PowderCoating.Core.Interfaces; -using PowderCoating.Infrastructure.Data; using PowderCoating.Shared.Constants; namespace PowderCoating.Web.Controllers; @@ -25,7 +24,6 @@ public class CompanyUsersController : Controller private readonly ILogger _logger; private readonly IUnitOfWork _unitOfWork; private readonly ISubscriptionService _subscriptionService; - private readonly ApplicationDbContext _context; private readonly IEmailService _emailService; public CompanyUsersController( @@ -34,7 +32,6 @@ public class CompanyUsersController : Controller ILogger logger, IUnitOfWork unitOfWork, ISubscriptionService subscriptionService, - ApplicationDbContext context, IEmailService emailService) { _userManager = userManager; @@ -42,7 +39,6 @@ public class CompanyUsersController : Controller _logger = logger; _unitOfWork = unitOfWork; _subscriptionService = subscriptionService; - _context = context; _emailService = emailService; } @@ -372,8 +368,8 @@ public class CompanyUsersController : Controller CompanyId = companyId!.Value }; - await _context.ShopWorkers.AddAsync(shopWorker); - await _context.SaveChangesAsync(); + await _unitOfWork.ShopWorkers.AddAsync(shopWorker); + await _unitOfWork.CompleteAsync(); _logger.LogInformation("ShopWorker record created for user {Email}", user.Email); } @@ -639,9 +635,8 @@ public class CompanyUsersController : Controller { // Search by oldEmail so we find the record even when the email just changed var lookupEmail = emailChanged ? oldEmail : user.Email; - var existingShopWorker = await _context.ShopWorkers - .Where(sw => sw.Email == lookupEmail && sw.CompanyId == user.CompanyId) - .ToListAsync(); + var existingShopWorker = (await _unitOfWork.ShopWorkers.FindAsync( + sw => sw.Email == lookupEmail && sw.CompanyId == user.CompanyId)).ToList(); if (!existingShopWorker.Any()) { @@ -656,8 +651,8 @@ public class CompanyUsersController : Controller CompanyId = user.CompanyId }; - await _context.ShopWorkers.AddAsync(shopWorker); - await _context.SaveChangesAsync(); + await _unitOfWork.ShopWorkers.AddAsync(shopWorker); + await _unitOfWork.CompleteAsync(); _logger.LogInformation("ShopWorker record created for user {Email}", user.Email); } @@ -684,7 +679,7 @@ public class CompanyUsersController : Controller shopWorker.Phone = user.PhoneNumber; if (shopWorkerDirty) - await _context.SaveChangesAsync(); + await _unitOfWork.CompleteAsync(); } } diff --git a/src/PowderCoating.Web/Controllers/DashboardController.cs b/src/PowderCoating.Web/Controllers/DashboardController.cs index 902d1dd..ce32d63 100644 --- a/src/PowderCoating.Web/Controllers/DashboardController.cs +++ b/src/PowderCoating.Web/Controllers/DashboardController.cs @@ -1,12 +1,11 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; using PowderCoating.Application.DTOs.Dashboard; using PowderCoating.Application.Interfaces; using PowderCoating.Core.Entities; -using PowderCoating.Core.Enums; // Still needed for MaintenanceStatus +using PowderCoating.Core.Enums; // Still needed for MaintenanceStatus, EquipmentStatus, PaymentMethod using PowderCoating.Core.Interfaces; -using PowderCoating.Infrastructure.Data; +using PowderCoating.Core.Interfaces.Services; using PowderCoating.Shared.Constants; using InvoiceStatus = PowderCoating.Core.Enums.InvoiceStatus; @@ -17,7 +16,9 @@ public class DashboardController : Controller { private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; - private readonly ApplicationDbContext _context; + private readonly IDashboardReadService _dashboardRead; + private readonly ITenantContext _tenantContext; + private readonly ICompanyConfigHealthService _configHealth; private static readonly string[] CompletedStatusCodes = [ @@ -39,14 +40,16 @@ public class DashboardController : Controller "QUALITY_CHECK" ]; - private readonly ITenantContext _tenantContext; - private readonly ICompanyConfigHealthService _configHealth; - - public DashboardController(IUnitOfWork unitOfWork, ILogger logger, ApplicationDbContext context, ITenantContext tenantContext, ICompanyConfigHealthService configHealth) + public DashboardController( + IUnitOfWork unitOfWork, + ILogger logger, + IDashboardReadService dashboardRead, + ITenantContext tenantContext, + ICompanyConfigHealthService configHealth) { _unitOfWork = unitOfWork; _logger = logger; - _context = context; + _dashboardRead = dashboardRead; _tenantContext = tenantContext; _configHealth = configHealth; } @@ -66,23 +69,14 @@ public class DashboardController : Controller try { var today = DateTime.Today; - var startOfMonth = new DateTime(today.Year, today.Month, 1); - var endOfMonth = startOfMonth.AddMonths(1).AddDays(-1); - var lookAheadDate = today.AddDays(7); // Changed to 7 days for expiring quotes + var lookAheadDate = today.AddDays(7); - // Active jobs — filter completed/cancelled statuses at database level - var activeJobs = await _context.Jobs - .Include(j => j.Customer) - .Include(j => j.AssignedUser) - .Include(j => j.JobStatus) - .Include(j => j.JobPriority) - .Where(j => !CompletedStatusCodes.Contains(j.JobStatus.StatusCode)) - .ToListAsync(); + var data = await _dashboardRead.GetIndexDataAsync(today); - var tomorrow = today.AddDays(1); - - // Today's Jobs - var todaysJobsFiltered = activeJobs + // --------------------------------------------------------------- + // Job panels — in-memory split of the pre-fetched activeJobs list + // --------------------------------------------------------------- + var todaysJobsFiltered = data.ActiveJobs .Where(j => (j.ScheduledDate.HasValue && j.ScheduledDate.Value.Date == today) || (j.DueDate.HasValue && j.DueDate.Value.Date == today)); var todaysJobsCount = todaysJobsFiltered.Count(); @@ -93,8 +87,7 @@ public class DashboardController : Controller .Select(MapJobDto) .ToList(); - // Overdue Jobs - var overdueJobsFiltered = activeJobs + var overdueJobsFiltered = data.ActiveJobs .Where(j => j.DueDate.HasValue && j.DueDate.Value.Date < today); var overdueJobsCount = overdueJobsFiltered.Count(); var overdueJobs = overdueJobsFiltered @@ -104,8 +97,7 @@ public class DashboardController : Controller .Select(MapJobDto) .ToList(); - // In-Progress Jobs - var inProgressJobs = activeJobs + var inProgressJobs = data.ActiveJobs .Where(j => InProgressStatusCodes.Contains(j.JobStatus.StatusCode)) .OrderBy(j => j.JobPriority.DisplayOrder) .ThenBy(j => j.ScheduledDate) @@ -113,26 +105,11 @@ public class DashboardController : Controller .Select(MapJobDto) .ToList(); - // Monthly Revenue — aggregate at database level (no need to load all jobs) - var monthlyRevenue = await _context.Jobs - .Include(j => j.JobStatus) - .Where(j => CompletedStatusCodes.Contains(j.JobStatus.StatusCode) - && j.UpdatedAt >= startOfMonth - && j.UpdatedAt <= endOfMonth) - .SumAsync(j => j.FinalPrice); - - // Today's Appointments — filter at database level - var todaysAppointmentsRaw = await _context.Appointments - .Include(a => a.Customer) - .Include(a => a.AppointmentType) - .Include(a => a.AppointmentStatus) - .Include(a => a.AssignedUser) - .Where(a => a.ScheduledStartTime >= today && a.ScheduledStartTime < tomorrow - && a.AppointmentStatus.StatusCode != "CANCELLED") - .OrderBy(a => a.ScheduledStartTime) - .ToListAsync(); - var todaysAppointmentsCount = todaysAppointmentsRaw.Count; - var todaysAppointments = todaysAppointmentsRaw + // --------------------------------------------------------------- + // Appointments + // --------------------------------------------------------------- + var todaysAppointmentsCount = data.TodaysAppointments.Count; + var todaysAppointments = data.TodaysAppointments .Take(10) .Select(a => new DashboardAppointmentDto { @@ -150,9 +127,11 @@ public class DashboardController : Controller AssignedWorkerName = a.AssignedUser?.FullName }).ToList(); + // --------------------------------------------------------------- // Low stock items + // --------------------------------------------------------------- var lowStockAll = await _unitOfWork.InventoryItems.FindAsync( - i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint); + i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint); var lowStockCount = lowStockAll.Count(); var lowStockItems = lowStockAll .OrderBy(i => i.QuantityOnHand) @@ -168,21 +147,10 @@ public class DashboardController : Controller UnitOfMeasure = i.UnitOfMeasure }).ToList(); - // Maintenance records — filter to pending/overdue at database level - var upcomingMaintenance = await _context.MaintenanceRecords - .Include(m => m.Equipment) - .Include(m => m.AssignedUser) - .Where(m => (m.Status == MaintenanceStatus.Scheduled - || m.Status == MaintenanceStatus.InProgress - || m.Status == MaintenanceStatus.Overdue) - && (m.Status == MaintenanceStatus.Overdue || m.ScheduledDate <= lookAheadDate)) - .OrderBy(m => m.Status == MaintenanceStatus.Overdue ? 0 : 1) - .ThenByDescending(m => m.Priority) - .ThenBy(m => m.ScheduledDate) - .Take(10) - .ToListAsync(); - - var upcomingMaintenanceDtos = upcomingMaintenance + // --------------------------------------------------------------- + // Maintenance + // --------------------------------------------------------------- + var upcomingMaintenanceDtos = data.UpcomingMaintenance .Select(m => new DashboardMaintenanceDto { Id = m.Id, @@ -195,14 +163,10 @@ public class DashboardController : Controller AssignedWorkerName = m.AssignedUser?.FullName }).ToList(); - // Pending Quotes — filter to SENT status at database level - var pendingQuotesData = await _context.Quotes - .Include(q => q.Customer) - .Include(q => q.QuoteStatus) - .Where(q => q.QuoteStatus.StatusCode == "SENT") - .ToListAsync(); - - var pendingQuotes = pendingQuotesData + // --------------------------------------------------------------- + // Quotes + // --------------------------------------------------------------- + var pendingQuotes = data.PendingQuotes .OrderBy(q => q.ExpirationDate) .Take(10) .Select(q => new DashboardQuoteDto @@ -221,10 +185,9 @@ public class DashboardController : Controller StatusDisplayName = q.QuoteStatus.DisplayName }).ToList(); - var pendingQuoteValue = pendingQuotesData.Sum(q => q.Total); + var pendingQuoteValue = data.PendingQuotes.Sum(q => q.Total); - // Expiring Quotes (next 7 days) - filter at database level - var expiringQuotes = pendingQuotesData + var expiringQuotes = data.PendingQuotes .Where(q => q.ExpirationDate.HasValue && q.ExpirationDate.Value.Date >= today && q.ExpirationDate.Value.Date <= lookAheadDate) @@ -246,33 +209,17 @@ public class DashboardController : Controller StatusDisplayName = q.QuoteStatus.DisplayName }).ToList(); - // Active Customers + // --------------------------------------------------------------- + // Active customers + // --------------------------------------------------------------- var activeCustomersCount = await _unitOfWork.Customers.CountAsync(c => c.IsActive); // --------------------------------------------------------------- - // Financial data — Invoices & Payments + // Invoices & AR aging // --------------------------------------------------------------- - var openStatuses = new[] { InvoiceStatus.Sent, InvoiceStatus.PartiallyPaid, InvoiceStatus.Overdue }; + var outstandingAr = data.OpenInvoices.Sum(i => i.BalanceDue); - // Open invoices only — filter at database level - var openInvoices = await _context.Invoices - .Include(i => i.Customer) - .Where(i => openStatuses.Contains(i.Status)) - .ToListAsync(); - - var outstandingAr = openInvoices.Sum(i => i.BalanceDue); - - // Invoiced this month — aggregate at database level - var invoicedThisMonth = await _context.Invoices - .Where(i => i.Status != InvoiceStatus.Draft - && i.Status != InvoiceStatus.Voided - && i.Status != InvoiceStatus.WrittenOff - && i.InvoiceDate >= startOfMonth - && i.InvoiceDate <= endOfMonth) - .SumAsync(i => i.Total); - - // Overdue invoices: open and past due date - var overdueInvoicesList = openInvoices + var overdueInvoicesList = data.OpenInvoices .Where(i => i.DueDate.HasValue && i.DueDate.Value.Date < today) .OrderBy(i => i.DueDate) .ToList(); @@ -295,9 +242,9 @@ public class DashboardController : Controller }) .ToList(); - // AR Aging — bucket open invoices by days past due + // AR Aging buckets decimal agingCurrent = 0, aging1To30 = 0, aging31To60 = 0, aging61To90 = 0, agingOver90 = 0; - foreach (var inv in openInvoices) + foreach (var inv in data.OpenInvoices) { if (!inv.DueDate.HasValue || inv.DueDate.Value.Date >= today) { @@ -313,17 +260,10 @@ public class DashboardController : Controller } } - // Payments this month — aggregate at database level - var collectedThisMonth = await _context.Payments - .Where(p => p.PaymentDate >= startOfMonth && p.PaymentDate <= endOfMonth) - .SumAsync(p => p.Amount); - - // Recent payments — load only the 6 most recent - var recentPayments = (await _context.Payments - .Include(p => p.Invoice).ThenInclude(i => i!.Customer) - .OrderByDescending(p => p.PaymentDate) - .Take(6) - .ToListAsync()) + // --------------------------------------------------------------- + // Payments + // --------------------------------------------------------------- + var recentPayments = data.RecentPayments .Select(p => new DashboardPaymentDto { Id = p.Id, @@ -335,44 +275,39 @@ public class DashboardController : Controller PaymentDate = p.PaymentDate, PaymentMethodDisplay = p.PaymentMethod switch { - PowderCoating.Core.Enums.PaymentMethod.Cash => "Cash", - PowderCoating.Core.Enums.PaymentMethod.Check => "Check", - PowderCoating.Core.Enums.PaymentMethod.CreditDebitCard => "Card", - PowderCoating.Core.Enums.PaymentMethod.BankTransferACH => "ACH", - PowderCoating.Core.Enums.PaymentMethod.DigitalPayment => "Digital", + PaymentMethod.Cash => "Cash", + PaymentMethod.Check => "Check", + PaymentMethod.CreditDebitCard => "Card", + PaymentMethod.BankTransferACH => "ACH", + PaymentMethod.DigitalPayment => "Digital", _ => "Other" } }) .ToList(); - // Equipment Alerts - filter at database level + // --------------------------------------------------------------- + // Equipment alerts + // --------------------------------------------------------------- var equipmentAlerts = (await _unitOfWork.Equipment.FindAsync( - e => e.Status == Core.Enums.EquipmentStatus.NeedsMaintenance || - e.Status == Core.Enums.EquipmentStatus.OutOfService)) - .OrderByDescending(e => e.Status == Core.Enums.EquipmentStatus.OutOfService ? 1 : 0) + e => e.Status == EquipmentStatus.NeedsMaintenance || + e.Status == EquipmentStatus.OutOfService)) + .OrderByDescending(e => e.Status == EquipmentStatus.OutOfService ? 1 : 0) .Take(5) .Select(e => new DashboardEquipmentAlertDto { Id = e.Id, EquipmentName = e.EquipmentName, EquipmentType = e.EquipmentType, - Issue = e.Status == Core.Enums.EquipmentStatus.OutOfService ? "Out of Service" : "Needs Maintenance", - Severity = e.Status == Core.Enums.EquipmentStatus.OutOfService ? "Critical" : "Warning", + Issue = e.Status == EquipmentStatus.OutOfService ? "Out of Service" : "Needs Maintenance", + Severity = e.Status == EquipmentStatus.OutOfService ? "Critical" : "Warning", LastMaintenanceDate = e.LastMaintenanceDate, - NextMaintenanceDue = null // Equipment doesn't track next maintenance due date + NextMaintenanceDue = null }).ToList(); - // Recent Activity (last 10 quotes or jobs created in last 30 days) - var last30Days = today.AddDays(-30); - - // Recent quotes — filter to last 30 days at database level - var recentQuotes = (await _context.Quotes - .Include(q => q.Customer) - .Include(q => q.QuoteStatus) - .Where(q => q.CreatedAt >= last30Days) - .OrderByDescending(q => q.CreatedAt) - .Take(5) - .ToListAsync()) + // --------------------------------------------------------------- + // Recent activity + // --------------------------------------------------------------- + var recentQuoteDtos = data.RecentQuotes .Select(q => new DashboardRecentActivityDto { Id = q.Id, @@ -390,14 +325,7 @@ public class DashboardController : Controller Amount = q.Total }); - // Recent jobs — filter to last 30 days at database level - var recentJobs = (await _context.Jobs - .Include(j => j.Customer) - .Include(j => j.JobStatus) - .Where(j => j.CreatedAt >= last30Days) - .OrderByDescending(j => j.CreatedAt) - .Take(5) - .ToListAsync()) + var recentJobDtos = data.RecentJobs .Select(j => new DashboardRecentActivityDto { Id = j.Id, @@ -413,33 +341,15 @@ public class DashboardController : Controller Amount = j.FinalPrice }); - var recentActivity = recentQuotes.Concat(recentJobs) + var recentActivity = recentQuoteDtos.Concat(recentJobDtos) .OrderByDescending(a => a.ActivityDate) .Take(10) .ToList(); - // === POWDER ORDERS NEEDED === - var jobsNeedingPowder = await _context.Jobs - .Include(j => j.Customer) - .Include(j => j.JobStatus) - .Include(j => j.JobItems) - .ThenInclude(i => i.Coats) - .ThenInclude(c => c.InventoryItem) - .ThenInclude(inv => inv!.PrimaryVendor) - .Include(j => j.JobItems) - .ThenInclude(i => i.Coats) - .ThenInclude(c => c.Vendor) - .Where(j => !j.IsDeleted - && !CompletedStatusCodes.Contains(j.JobStatus.StatusCode) - && j.JobItems.Any(i => i.Coats.Any(c => - !c.IsDeleted && - !c.PowderOrdered && - c.PowderToOrder > 0 && - (c.InventoryItemId == null || c.InventoryItem!.QuantityOnHand < c.PowderToOrder)))) - .ToListAsync(); - - // Flatten to individual coat lines that need ordering (with vendor info for grouping) - var powderFlat = jobsNeedingPowder + // --------------------------------------------------------------- + // Powder orders needed + // --------------------------------------------------------------- + var powderFlat = data.JobsNeedingPowder .SelectMany(j => j.JobItems .SelectMany(i => i.Coats .Where(c => !c.IsDeleted && !c.PowderOrdered && c.PowderToOrder > 0 @@ -500,26 +410,10 @@ public class DashboardController : Controller .OrderBy(g => g.VendorName) .ToList(); - // === POWDER ORDERS PLACED (ordered, awaiting receipt) === - var jobsWithOrderedPowder = await _context.Jobs - .Include(j => j.Customer) - .Include(j => j.JobStatus) - .Include(j => j.JobItems) - .ThenInclude(i => i.Coats) - .ThenInclude(c => c.InventoryItem) - .ThenInclude(inv => inv!.PrimaryVendor) - .Include(j => j.JobItems) - .ThenInclude(i => i.Coats) - .ThenInclude(c => c.Vendor) - .Where(j => !j.IsDeleted - && !CompletedStatusCodes.Contains(j.JobStatus.StatusCode) - && j.JobItems.Any(i => i.Coats.Any(c => - !c.IsDeleted && - c.PowderOrdered && - !c.PowderReceived))) - .ToListAsync(); - - var placedFlat = jobsWithOrderedPowder + // --------------------------------------------------------------- + // Powder orders placed + // --------------------------------------------------------------- + var placedFlat = data.JobsWithOrderedPowder .SelectMany(j => j.JobItems .SelectMany(i => i.Coats .Where(c => !c.IsDeleted && c.PowderOrdered && !c.PowderReceived) @@ -584,16 +478,10 @@ public class DashboardController : Controller .OrderBy(g => g.VendorName) .ToList(); - // === BILLS DUE === - var billsDueRaw = await _context.Bills - .Include(b => b.Vendor) - .Where(b => !b.IsDeleted && - (b.Status == BillStatus.Open || b.Status == BillStatus.PartiallyPaid) && - b.Total > b.AmountPaid) - .OrderBy(b => b.DueDate) - .Take(15) - .ToListAsync(); - var billsDue = billsDueRaw.Select(b => new DashboardBillDto + // --------------------------------------------------------------- + // Bills due + // --------------------------------------------------------------- + var billsDue = data.BillsDue.Select(b => new DashboardBillDto { Id = b.Id, BillNumber = b.BillNumber, @@ -608,21 +496,21 @@ public class DashboardController : Controller var vm = new DashboardViewModel { // Counts - ActiveJobsCount = activeJobs.Count(), + ActiveJobsCount = data.ActiveJobs.Count, TodaysJobsCount = todaysJobsCount, OverdueJobsCount = overdueJobsCount, TodaysAppointmentsCount = todaysAppointmentsCount, LowStockCount = lowStockCount, - PendingMaintenanceCount = upcomingMaintenance.Count, - PendingQuotesCount = pendingQuotesData.Count(), + PendingMaintenanceCount = data.UpcomingMaintenance.Count, + PendingQuotesCount = data.PendingQuotes.Count, PendingQuoteValue = pendingQuoteValue, - MonthlyRevenue = monthlyRevenue, + MonthlyRevenue = data.MonthlyRevenue, ActiveCustomersCount = activeCustomersCount, // Financial KPIs OutstandingAr = outstandingAr, - CollectedThisMonth = collectedThisMonth, - InvoicedThisMonth = invoicedThisMonth, + CollectedThisMonth = data.CollectedThisMonth, + InvoicedThisMonth = data.InvoicedThisMonth, OverdueInvoicesCount = overdueInvoicesCount, OverdueInvoicesAmount = overdueInvoicesAmount, AgingCurrent = agingCurrent, @@ -654,7 +542,9 @@ public class DashboardController : Controller PowderOrdersNeeded = powderOrderGroups, PowderOrdersNeededCount = powderFlat.Count, PowderOrdersPlaced = powderPlacedGroups, - PowderOrdersPlacedCount = placedFlat.Count + PowderOrdersPlacedCount = placedFlat.Count, + + TipOfTheDay = data.TipOfTheDay }; // Dropdowns for the "Add Custom Powder to Inventory" modal @@ -671,13 +561,6 @@ public class DashboardController : Controller ViewBag.InventoryCategories = inventoryCategories; ViewBag.VendorList = vendors; - // Random tip of the day - var tips = await _context.DashboardTips - .Where(t => t.IsActive) - .ToListAsync(); - if (tips.Count > 0) - vm.TipOfTheDay = tips[Random.Shared.Next(tips.Count)].TipText; - // Config health check — surface setup gaps to company admins var currentCompanyId = _tenantContext.GetCurrentCompanyId(); if (currentCompanyId.HasValue) @@ -705,11 +588,7 @@ public class DashboardController : Controller { try { - var coat = await _context.JobItemCoats - .Include(c => c.JobItem).ThenInclude(i => i.Job).ThenInclude(j => j.Customer) - .Include(c => c.Vendor) - .Include(c => c.InventoryItem).ThenInclude(i => i!.PrimaryVendor) - .FirstOrDefaultAsync(c => c.Id == coatId); + var coat = await _unitOfWork.JobItemCoats.LoadForOrderMarkingAsync(coatId); if (coat == null) return Json(new { success = false, message = "Coat not found." }); @@ -722,7 +601,7 @@ public class DashboardController : Controller coat.PowderOrdered = true; coat.PowderOrderedAt = DateTime.UtcNow; coat.PowderOrderedByUserId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; - await _context.SaveChangesAsync(); + await _unitOfWork.CompleteAsync(); var vendor = coat.Vendor ?? coat.InventoryItem?.PrimaryVendor; var job = coat.JobItem?.Job; @@ -761,9 +640,9 @@ public class DashboardController : Controller /// Records receipt of a powder shipment against an existing powder order. Sets /// PowderReceived, PowderReceivedLbs, and PowderReceivedAt on the coat, /// and — when the coat is linked to an inventory item — increases QuantityOnHand and - /// writes a Purchase so - /// the stock movement is fully traceable. Company ownership is verified through the parent job - /// because JobItemCoat carries no CompanyId of its own. + /// writes a Purchase so the stock movement is fully + /// traceable. Company ownership is verified through the parent job because JobItemCoat + /// carries no CompanyId of its own. /// [HttpPost] [ValidateAntiForgeryToken] @@ -771,9 +650,8 @@ public class DashboardController : Controller { try { - var coat = await _context.JobItemCoats - .Include(c => c.InventoryItem) - .FirstOrDefaultAsync(c => c.Id == coatId); + // Load coat with inventory item for the stock update + var coat = await _unitOfWork.JobItemCoats.LoadWithInventoryAsync(coatId); if (coat == null) return Json(new { success = false, message = "Coat record not found." }); @@ -781,29 +659,25 @@ public class DashboardController : Controller if (lbsReceived <= 0) return Json(new { success = false, message = "Quantity received must be greater than zero." }); - // Verify ownership — JobItemCoat has no CompanyId, check via parent job - // (We need the job for company check; load it if not already included) + // Verify ownership — JobItemCoat has no CompanyId, check via parent job. + // If JobItem/Job wasn't populated by the initial load, bring in the chain via a second + // query; EF Core identity-map fixup will propagate the navigation to the tracked coat. var coatJobCompanyId = coat.JobItem?.Job?.CompanyId; if (coatJobCompanyId == null) { - // Reload with parent chain if not included - var coatWithJob = await _context.JobItemCoats - .Include(c => c.JobItem).ThenInclude(i => i.Job) - .FirstOrDefaultAsync(c => c.Id == coatId); - coatJobCompanyId = coatWithJob?.JobItem?.Job?.CompanyId; + await _unitOfWork.JobItemCoats.LoadWithJobChainAsync(coatId); + coatJobCompanyId = coat.JobItem?.Job?.CompanyId; } if (!_tenantContext.IsSuperAdmin() && coatJobCompanyId != _tenantContext.GetCurrentCompanyId()) return Json(new { success = false, message = "Access denied." }); var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; - // Mark coat as received coat.PowderReceived = true; coat.PowderReceivedAt = DateTime.UtcNow; coat.PowderReceivedByUserId = userId; coat.PowderReceivedLbs = lbsReceived; - // Update inventory if this coat is linked to an inventory item if (coat.InventoryItemId.HasValue && coat.InventoryItem != null) { var item = coat.InventoryItem; @@ -813,26 +687,24 @@ public class DashboardController : Controller if (coat.PowderCostPerLb.HasValue) item.LastPurchasePrice = coat.PowderCostPerLb.Value; - // Record purchase transaction - var transaction = new PowderCoating.Core.Entities.InventoryTransaction + var transaction = new InventoryTransaction { CompanyId = item.CompanyId, InventoryItemId = item.Id, - TransactionType = PowderCoating.Core.Enums.InventoryTransactionType.Purchase, + TransactionType = InventoryTransactionType.Purchase, Quantity = lbsReceived, UnitCost = coat.PowderCostPerLb ?? item.UnitCost, TotalCost = lbsReceived * (coat.PowderCostPerLb ?? item.UnitCost), TransactionDate = DateTime.UtcNow, - Reference = coat.JobItem != null ? null : null, // loaded below if needed Notes = $"Received {lbsReceived:N2} lbs for job order", BalanceAfter = previousBalance + lbsReceived, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, }; - _context.Set().Add(transaction); + await _unitOfWork.InventoryTransactions.AddAsync(transaction); } - await _context.SaveChangesAsync(); + await _unitOfWork.CompleteAsync(); return Json(new { success = true, updatedInventory = coat.InventoryItemId.HasValue }); } @@ -864,7 +736,7 @@ public class DashboardController : Controller { try { - var coat = await _context.JobItemCoats.FindAsync(coatId); + var coat = await _unitOfWork.JobItemCoats.GetByIdAsync(coatId); if (coat == null) return Json(new { success = false, message = "Coat record not found." }); @@ -877,16 +749,13 @@ public class DashboardController : Controller if (lbsReceived <= 0) return Json(new { success = false, message = "Quantity received must be greater than zero." }); - // Resolve company id from tenant context - var companyId = (await _unitOfWork.InventoryItems.GetAllAsync()).FirstOrDefault()?.CompanyId ?? 0; - // More reliably get CompanyId from the job chain - var jobItem = await _context.JobItems.Include(i => i.Job).FirstOrDefaultAsync(i => i.Coats.Any(c => c.Id == coatId)); - if (jobItem?.Job != null) - companyId = jobItem.Job.CompanyId; + // Resolve company id from the job chain; fall back to tenant context + var jobItem = await _unitOfWork.JobItems.FirstOrDefaultAsync( + i => i.Coats.Any(c => c.Id == coatId), false, i => i.Job); + var companyId = jobItem?.Job?.CompanyId ?? _tenantContext.GetCurrentCompanyId() ?? 0; // Check SKU uniqueness - var existingSku = await _context.InventoryItems.AnyAsync(i => i.SKU == sku.Trim()); - if (existingSku) + if (await _unitOfWork.InventoryItems.AnyAsync(i => i.SKU == sku.Trim())) return Json(new { success = false, message = $"SKU '{sku}' already exists in inventory." }); // Determine category display name for legacy field @@ -925,10 +794,10 @@ public class DashboardController : Controller UpdatedAt = DateTime.UtcNow, }; - _context.InventoryItems.Add(inventoryItem); - await _context.SaveChangesAsync(); + await _unitOfWork.InventoryItems.AddAsync(inventoryItem); + await _unitOfWork.CompleteAsync(); // flush to get inventoryItem.Id - // Record opening stock transaction + // Opening stock transaction var transaction = new InventoryTransaction { CompanyId = companyId, @@ -943,7 +812,7 @@ public class DashboardController : Controller CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, }; - _context.Set().Add(transaction); + await _unitOfWork.InventoryTransactions.AddAsync(transaction); // Mark coat as received and link to the new inventory item var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; @@ -953,19 +822,12 @@ public class DashboardController : Controller coat.PowderReceivedLbs = lbsReceived; coat.InventoryItemId = inventoryItem.Id; - // Scan for other active job coats using the same custom powder and link them - var candidateCoats = await _context.JobItemCoats - .Include(c => c.JobItem) - .Where(c => !c.IsDeleted - && c.Id != coatId - && c.InventoryItemId == null - && c.JobItem.CompanyId == companyId) - .ToListAsync(); + // Scan for sibling coats with the same custom powder and link them to the new item + var candidateCoats = await _unitOfWork.JobItemCoats.GetCandidateCoatsForLinkingAsync(coatId, companyId); int linkedCount = 0; foreach (var other in candidateCoats) { - // Match by color code first (most specific), then fall back to color name bool colorMatch = !string.IsNullOrWhiteSpace(colorCode) ? string.Equals(other.ColorCode?.Trim(), colorCode.Trim(), StringComparison.OrdinalIgnoreCase) : !string.IsNullOrWhiteSpace(colorName) && @@ -973,7 +835,6 @@ public class DashboardController : Controller if (!colorMatch) continue; - // If both coats have a vendor set, they must agree if (primaryVendorId.HasValue && other.VendorId.HasValue && other.VendorId != primaryVendorId) continue; @@ -985,7 +846,7 @@ public class DashboardController : Controller _logger.LogInformation("Linked {Count} additional coat(s) to new inventory item {ItemId} ({SKU})", linkedCount, inventoryItem.Id, inventoryItem.SKU); - await _context.SaveChangesAsync(); + await _unitOfWork.CompleteAsync(); return Json(new { success = true, itemName = inventoryItem.Name, sku = inventoryItem.SKU, linkedCount }); } @@ -1014,13 +875,10 @@ public class DashboardController : Controller var allCompanies = await _unitOfWork.Companies.GetAllAsync(ignoreQueryFilters: true); var companies = allCompanies.Where(c => !c.IsDeleted).ToList(); - var totalUsers = await _context.Users - .Where(u => u.CompanyId > 0) - .CountAsync(); + var totalUsers = await _dashboardRead.GetTotalUserCountAsync(); var graceCutoff = today.AddDays(-AppConstants.SubscriptionConstants.GracePeriodDays); - // Load plan configs from DB so plan display names and distribution are DB-driven var planConfigs = (await _unitOfWork.SubscriptionPlanConfigs.FindAsync( c => c.IsActive, ignoreQueryFilters: true)) .OrderBy(c => c.SortOrder) @@ -1029,7 +887,6 @@ public class DashboardController : Controller var planLookup = planConfigs.ToDictionary(c => c.Plan, c => c.DisplayName); string PlanName(int plan) => planLookup.TryGetValue(plan, out var name) ? name : plan.ToString(); - // Companies needing attention: expired (past grace) or in grace period var companyAlerts = companies .Where(c => c.SubscriptionEndDate.HasValue && c.SubscriptionEndDate.Value.Date < today) .OrderBy(c => c.SubscriptionEndDate) @@ -1066,7 +923,6 @@ public class DashboardController : Controller }) .ToList(); - // Build plan distribution from DB config (sorted by SortOrder) var planDistribution = planConfigs.ToDictionary( c => c.Plan, c => (c.DisplayName, companies.Count(comp => comp.SubscriptionPlan == c.Plan))); diff --git a/src/PowderCoating.Web/Controllers/DashboardTipsController.cs b/src/PowderCoating.Web/Controllers/DashboardTipsController.cs index 0e2d36b..7263166 100644 --- a/src/PowderCoating.Web/Controllers/DashboardTipsController.cs +++ b/src/PowderCoating.Web/Controllers/DashboardTipsController.cs @@ -1,8 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; using PowderCoating.Core.Entities; -using PowderCoating.Infrastructure.Data; +using PowderCoating.Core.Interfaces; using PowderCoating.Shared.Constants; namespace PowderCoating.Web.Controllers; @@ -18,39 +17,37 @@ namespace PowderCoating.Web.Controllers; [Authorize(Policy = AppConstants.Policies.SuperAdminOnly)] public class DashboardTipsController : Controller { - private readonly ApplicationDbContext _db; + private readonly IUnitOfWork _unitOfWork; - public DashboardTipsController(ApplicationDbContext db) + public DashboardTipsController(IUnitOfWork unitOfWork) { - _db = db; + _unitOfWork = unitOfWork; } /// /// Returns a paginated, optionally filtered list of all dashboard tips. /// Active tips are sorted first (then by newest Id) to make the currently - /// live pool easy to review at a glance. ViewBag includes both the filtered - /// count and the global active/total counts for the header summary cards. + /// live pool easy to review at a glance. /// - // GET: /DashboardTips public async Task Index(string? search, bool? activeOnly, int page = 1) { const int pageSize = 25; - var query = _db.DashboardTips.AsQueryable(); + var all = (await _unitOfWork.DashboardTips.GetAllAsync()).ToList(); if (!string.IsNullOrWhiteSpace(search)) - query = query.Where(t => t.TipText.Contains(search)); + all = all.Where(t => t.TipText.Contains(search, StringComparison.OrdinalIgnoreCase)).ToList(); if (activeOnly == true) - query = query.Where(t => t.IsActive); + all = all.Where(t => t.IsActive).ToList(); - var total = await query.CountAsync(); - var tips = await query + var total = all.Count; + var tips = all .OrderByDescending(t => t.IsActive) .ThenByDescending(t => t.Id) .Skip((page - 1) * pageSize) .Take(pageSize) - .ToListAsync(); + .ToList(); ViewBag.Search = search; ViewBag.ActiveOnly = activeOnly ?? false; @@ -58,23 +55,19 @@ public class DashboardTipsController : Controller ViewBag.PageSize = pageSize; ViewBag.Total = total; ViewBag.TotalPages = (int)Math.Ceiling(total / (double)pageSize); - ViewBag.ActiveCount = await _db.DashboardTips.CountAsync(t => t.IsActive); - ViewBag.TotalCount = await _db.DashboardTips.CountAsync(); + ViewBag.ActiveCount = await _unitOfWork.DashboardTips.CountAsync(t => t.IsActive); + ViewBag.TotalCount = await _unitOfWork.DashboardTips.CountAsync(); return View(tips); } /// Returns the Create form with an empty model. - // GET: /DashboardTips/Create public IActionResult Create() => View(new DashboardTip()); /// /// Persists a new dashboard tip. Text is trimmed before saving to prevent /// whitespace-only entries from appearing as blank tiles on the dashboard. - /// Model validation is done manually (rather than relying solely on - /// [Required] attributes) to ensure a meaningful error message is shown. /// - // POST: /DashboardTips/Create [HttpPost, ValidateAntiForgeryToken] public async Task Create(DashboardTip model) { @@ -84,37 +77,33 @@ public class DashboardTipsController : Controller return View(model); } - _db.DashboardTips.Add(new DashboardTip + await _unitOfWork.DashboardTips.AddAsync(new DashboardTip { TipText = model.TipText.Trim(), IsActive = model.IsActive, CreatedAt = DateTime.UtcNow }); - await _db.SaveChangesAsync(); + await _unitOfWork.CompleteAsync(); TempData["Success"] = "Tip added successfully."; return RedirectToAction(nameof(Index)); } /// Returns the Edit form for an existing tip, or 404 if not found. - // GET: /DashboardTips/Edit/5 public async Task Edit(int id) { - var tip = await _db.DashboardTips.FindAsync(id); + var tip = await _unitOfWork.DashboardTips.GetByIdAsync(id); if (tip == null) return NotFound(); return View(tip); } /// - /// Updates text and active flag for an existing tip. Returns the tracked entity - /// (not the posted model) to the view on validation failure so the form shows - /// the database version rather than potentially mangled posted data. + /// Updates text and active flag for an existing tip. /// - // POST: /DashboardTips/Edit/5 [HttpPost, ValidateAntiForgeryToken] public async Task Edit(int id, DashboardTip model) { - var tip = await _db.DashboardTips.FindAsync(id); + var tip = await _unitOfWork.DashboardTips.GetByIdAsync(id); if (tip == null) return NotFound(); if (string.IsNullOrWhiteSpace(model.TipText)) @@ -125,27 +114,25 @@ public class DashboardTipsController : Controller tip.TipText = model.TipText.Trim(); tip.IsActive = model.IsActive; - await _db.SaveChangesAsync(); + await _unitOfWork.CompleteAsync(); TempData["Success"] = "Tip updated."; return RedirectToAction(nameof(Index)); } /// - /// Permanently (hard) deletes a dashboard tip. Tips are platform metadata so + /// Permanently (hard) deletes a dashboard tip. Tips are platform metadata so /// they do not use soft delete — they have no foreign-key relationships to - /// tenant data and nothing references a deleted tip's Id. A missing Id is - /// silently ignored to keep the action idempotent. + /// tenant data and nothing references a deleted tip's Id. /// - // POST: /DashboardTips/Delete/5 [HttpPost, ValidateAntiForgeryToken] public async Task Delete(int id) { - var tip = await _db.DashboardTips.FindAsync(id); + var tip = await _unitOfWork.DashboardTips.GetByIdAsync(id); if (tip != null) { - _db.DashboardTips.Remove(tip); - await _db.SaveChangesAsync(); + await _unitOfWork.DashboardTips.DeleteAsync(tip); + await _unitOfWork.CompleteAsync(); TempData["Success"] = "Tip deleted."; } return RedirectToAction(nameof(Index)); @@ -153,19 +140,15 @@ public class DashboardTipsController : Controller /// /// Flips the IsActive flag on a tip without a full edit round-trip. - /// This lets operators quickly remove a tip from the rotation (deactivate) - /// without deleting it, preserving the ability to reactivate it later. - /// A missing Id is silently ignored to keep the action idempotent. /// - // POST: /DashboardTips/ToggleActive/5 [HttpPost, ValidateAntiForgeryToken] public async Task ToggleActive(int id) { - var tip = await _db.DashboardTips.FindAsync(id); + var tip = await _unitOfWork.DashboardTips.GetByIdAsync(id); if (tip != null) { tip.IsActive = !tip.IsActive; - await _db.SaveChangesAsync(); + await _unitOfWork.CompleteAsync(); } return RedirectToAction(nameof(Index)); } diff --git a/src/PowderCoating.Web/Controllers/DepositsController.cs b/src/PowderCoating.Web/Controllers/DepositsController.cs index 506d649..4d26ab0 100644 --- a/src/PowderCoating.Web/Controllers/DepositsController.cs +++ b/src/PowderCoating.Web/Controllers/DepositsController.cs @@ -7,7 +7,6 @@ using PowderCoating.Application.DTOs.Company; using PowderCoating.Core.Entities; using PowderCoating.Core.Enums; using PowderCoating.Core.Interfaces; -using PowderCoating.Infrastructure.Data; using PowderCoating.Shared.Constants; using QuestPDF.Fluent; using QuestPDF.Helpers; @@ -21,18 +20,15 @@ public class DepositsController : Controller private readonly IUnitOfWork _unitOfWork; private readonly UserManager _userManager; private readonly ILogger _logger; - private readonly ApplicationDbContext _context; public DepositsController( IUnitOfWork unitOfWork, UserManager userManager, - ILogger logger, - ApplicationDbContext context) + ILogger logger) { _unitOfWork = unitOfWork; _userManager = userManager; _logger = logger; - _context = context; } // ----------------------------------------------------------------------- @@ -220,11 +216,11 @@ public class DepositsController : Controller { var prefix = $"DEP-{DateTime.UtcNow:yy}{DateTime.UtcNow.Month:D2}-"; - var existing = await _context.Set() - .IgnoreQueryFilters() - .Where(d => d.CompanyId == companyId && d.ReceiptNumber.StartsWith(prefix)) + var existing = (await _unitOfWork.Deposits.FindAsync( + d => d.CompanyId == companyId && d.ReceiptNumber.StartsWith(prefix), + ignoreQueryFilters: true)) .Select(d => d.ReceiptNumber) - .ToListAsync(); + .ToList(); var maxNum = 0; foreach (var num in existing) diff --git a/src/PowderCoating.Web/Controllers/EmailBroadcastController.cs b/src/PowderCoating.Web/Controllers/EmailBroadcastController.cs index c2ff3df..22ca896 100644 --- a/src/PowderCoating.Web/Controllers/EmailBroadcastController.cs +++ b/src/PowderCoating.Web/Controllers/EmailBroadcastController.cs @@ -12,6 +12,7 @@ using PowderCoating.Shared.Constants; namespace PowderCoating.Web.Controllers; +// Intentional exception: cross-tenant fan-out querying ASP.NET Identity Users table with company joins; Identity entities live outside IUnitOfWork. See docs/DATA_ACCESS_ARCHITECTURE.md — Permanent Exceptions. [Authorize(Policy = AppConstants.Policies.SuperAdminOnly)] public class EmailBroadcastController : Controller { diff --git a/src/PowderCoating.Web/Controllers/ExpensesController.cs b/src/PowderCoating.Web/Controllers/ExpensesController.cs index f91f0e4..a8ca0c8 100644 --- a/src/PowderCoating.Web/Controllers/ExpensesController.cs +++ b/src/PowderCoating.Web/Controllers/ExpensesController.cs @@ -14,7 +14,6 @@ using PowderCoating.Application.Interfaces; using PowderCoating.Core.Entities; using PowderCoating.Core.Enums; using PowderCoating.Core.Interfaces; -using PowderCoating.Infrastructure.Data; namespace PowderCoating.Web.Controllers; @@ -25,7 +24,6 @@ public class ExpensesController : Controller private readonly IMapper _mapper; private readonly UserManager _userManager; private readonly ILogger _logger; - private readonly ApplicationDbContext _context; private readonly IAzureBlobStorageService _blobStorage; private readonly StorageSettings _storageSettings; private readonly IAccountBalanceService _accountBalanceService; @@ -40,7 +38,6 @@ public class ExpensesController : Controller IMapper mapper, UserManager userManager, ILogger logger, - ApplicationDbContext context, IAzureBlobStorageService blobStorage, IOptions storageSettings, IAccountBalanceService accountBalanceService, @@ -51,7 +48,6 @@ public class ExpensesController : Controller _mapper = mapper; _userManager = userManager; _logger = logger; - _context = context; _blobStorage = blobStorage; _storageSettings = storageSettings.Value; _accountBalanceService = accountBalanceService; @@ -80,28 +76,25 @@ public class ExpensesController : Controller [NonAction] public async Task IndexLegacy(string? search, int? accountId, DateTime? from, DateTime? to, int page = 1, int pageSize = 25) { - var query = _context.Expenses - .Include(e => e.Vendor) - .Include(e => e.ExpenseAccount) - .Include(e => e.PaymentAccount) - .Include(e => e.Job) - .Where(e => !e.IsDeleted); + var allExpenses = (await _unitOfWork.Expenses.GetAllAsync( + false, e => e.Vendor, e => e.ExpenseAccount, e => e.PaymentAccount, e => e.Job)) + .AsEnumerable(); if (!string.IsNullOrEmpty(search)) - query = query.Where(e => e.ExpenseNumber.Contains(search) || - e.Memo!.Contains(search) || + allExpenses = allExpenses.Where(e => e.ExpenseNumber.Contains(search) || + (e.Memo != null && e.Memo.Contains(search)) || (e.Vendor != null && e.Vendor.CompanyName.Contains(search))); if (accountId.HasValue) - query = query.Where(e => e.ExpenseAccountId == accountId.Value); + allExpenses = allExpenses.Where(e => e.ExpenseAccountId == accountId.Value); if (from.HasValue) - query = query.Where(e => e.Date >= from.Value); + allExpenses = allExpenses.Where(e => e.Date >= from.Value); if (to.HasValue) - query = query.Where(e => e.Date <= to.Value); + allExpenses = allExpenses.Where(e => e.Date <= to.Value); - var expenses = await query.OrderByDescending(e => e.CreatedAt).ToListAsync(); + var expenses = allExpenses.OrderByDescending(e => e.CreatedAt).ToList(); var dtos = _mapper.Map>(expenses); ViewBag.Search = search; @@ -110,11 +103,11 @@ public class ExpensesController : Controller ViewBag.To = to?.ToString("yyyy-MM-dd"); ViewBag.TotalAmount = dtos.Sum(e => e.Amount); - var expenseAccounts = await _context.Accounts - .Where(a => !a.IsDeleted && a.IsActive && - (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods)) + var expenseAccounts = (await _unitOfWork.Accounts.FindAsync( + a => a.IsActive && + (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods))) .OrderBy(a => a.AccountNumber) - .ToListAsync(); + .ToList(); ViewBag.AccountFilter = expenseAccounts .Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString())) @@ -297,12 +290,8 @@ public class ExpensesController : Controller { if (id == null) return NotFound(); - var expense = await _context.Expenses - .Include(e => e.Vendor) - .Include(e => e.ExpenseAccount) - .Include(e => e.PaymentAccount) - .Include(e => e.Job) - .FirstOrDefaultAsync(e => e.Id == id && !e.IsDeleted); + var expense = await _unitOfWork.Expenses.GetByIdAsync( + id.Value, false, e => e.Vendor, e => e.ExpenseAccount, e => e.PaymentAccount, e => e.Job); if (expense == null) return NotFound(); return View(_mapper.Map(expense)); @@ -445,12 +434,11 @@ public class ExpensesController : Controller private async Task GenerateExpenseNumberAsync() { var prefix = $"EXP-{DateTime.Now:yyMM}-"; - var last = await _context.Expenses - .IgnoreQueryFilters() - .Where(e => e.ExpenseNumber.StartsWith(prefix)) + var last = (await _unitOfWork.Expenses.FindAsync( + e => e.ExpenseNumber.StartsWith(prefix), ignoreQueryFilters: true)) .OrderByDescending(e => e.ExpenseNumber) .Select(e => e.ExpenseNumber) - .FirstOrDefaultAsync(); + .FirstOrDefault(); int next = 1; if (last != null && int.TryParse(last[prefix.Length..], out int num)) diff --git a/src/PowderCoating.Web/Controllers/InAppNotificationsController.cs b/src/PowderCoating.Web/Controllers/InAppNotificationsController.cs index 954bfef..8b45c19 100644 --- a/src/PowderCoating.Web/Controllers/InAppNotificationsController.cs +++ b/src/PowderCoating.Web/Controllers/InAppNotificationsController.cs @@ -1,8 +1,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; using PowderCoating.Core.Interfaces; -using PowderCoating.Infrastructure.Data; using PowderCoating.Web.Extensions; namespace PowderCoating.Web.Controllers; @@ -10,12 +8,12 @@ namespace PowderCoating.Web.Controllers; [Authorize] public class InAppNotificationsController : Controller { - private readonly ApplicationDbContext _db; + private readonly IUnitOfWork _unitOfWork; private readonly ITenantContext _tenant; - public InAppNotificationsController(ApplicationDbContext db, ITenantContext tenant) + public InAppNotificationsController(IUnitOfWork unitOfWork, ITenantContext tenant) { - _db = db; + _unitOfWork = unitOfWork; _tenant = tenant; } @@ -27,23 +25,15 @@ public class InAppNotificationsController : Controller pageNumber = Math.Max(1, pageNumber); pageSize = pageSize is 10 or 25 or 50 ? pageSize : 25; - IQueryable query; + var all = _tenant.IsPlatformAdmin() + ? (await _unitOfWork.InAppNotifications.FindAsync( + n => !n.IsDeleted && n.CompanyId == 0, ignoreQueryFilters: true)).ToList() + : (await _unitOfWork.InAppNotifications.GetAllAsync()).ToList(); - if (_tenant.IsPlatformAdmin()) - { - query = _db.InAppNotifications - .IgnoreQueryFilters() - .Where(n => !n.IsDeleted && n.CompanyId == 0); - } - else - { - query = _db.InAppNotifications.AsQueryable(); - } + var totalCount = all.Count; - var totalCount = await query.CountAsync(); - - var items = await query - .AsNoTracking() + var tz = ViewBag.CompanyTimeZone as string; + var items = all .OrderByDescending(n => n.CreatedAt) .Skip((pageNumber - 1) * pageSize) .Take(pageSize) @@ -58,7 +48,7 @@ public class InAppNotificationsController : Controller n.ReadAt, CreatedAt = n.CreatedAt }) - .ToListAsync(); + .ToList(); ViewBag.TotalCount = totalCount; ViewBag.PageNumber = pageNumber; @@ -68,31 +58,22 @@ public class InAppNotificationsController : Controller } /// - /// AJAX endpoint that returns the 20 most recent notifications (read and unread) for the bell dropdown. The response includes a count of unread items so the badge can be updated without a separate round-trip. + /// AJAX endpoint that returns the 20 most recent notifications (read and unread) for the bell dropdown. /// [HttpGet] public async Task Recent() { - IQueryable query; - - if (_tenant.IsPlatformAdmin()) - { - query = _db.InAppNotifications - .IgnoreQueryFilters() - .Where(n => !n.IsDeleted && n.CompanyId == 0); - } - else - { - query = _db.InAppNotifications.AsQueryable(); - } + var all = _tenant.IsPlatformAdmin() + ? (await _unitOfWork.InAppNotifications.FindAsync( + n => !n.IsDeleted && n.CompanyId == 0, ignoreQueryFilters: true)).ToList() + : (await _unitOfWork.InAppNotifications.GetAllAsync()).ToList(); var tz = ViewBag.CompanyTimeZone as string; - var items = await query - .AsNoTracking() + var items = all .OrderByDescending(n => n.CreatedAt) .Take(20) .Select(n => new { n.Id, n.Title, n.Message, n.Link, n.NotificationType, n.IsRead, n.CreatedAt }) - .ToListAsync(); + .ToList(); var unreadCount = items.Count(n => !n.IsRead); return Json(new { count = unreadCount, items = items.Select(n => new { @@ -102,34 +83,19 @@ public class InAppNotificationsController : Controller } /// - /// AJAX endpoint that returns only unread notifications (up to 20) plus an unread count for the bell badge. Used by the initial page load poll; after that the bell relies on Recent to show history. + /// AJAX endpoint that returns only unread notifications (up to 20) plus an unread count for the bell badge. /// [HttpGet] public async Task Unread() { - IQueryable query; - - if (_tenant.IsPlatformAdmin()) - { - // SuperAdmins see only platform-level notifications (CompanyId = 0) - query = _db.InAppNotifications - .IgnoreQueryFilters() - .Where(n => !n.IsDeleted && n.CompanyId == 0 && !n.IsRead); - } - else - { - // Regular users see their company's notifications (global filter handles tenant isolation) - query = _db.InAppNotifications.Where(n => !n.IsRead); - } + var items = _tenant.IsPlatformAdmin() + ? (await _unitOfWork.InAppNotifications.FindAsync( + n => !n.IsDeleted && n.CompanyId == 0 && !n.IsRead, ignoreQueryFilters: true)) + .OrderByDescending(n => n.CreatedAt).Take(20).ToList() + : (await _unitOfWork.InAppNotifications.FindAsync(n => !n.IsRead)) + .OrderByDescending(n => n.CreatedAt).Take(20).ToList(); var tz = ViewBag.CompanyTimeZone as string; - var items = await query - .AsNoTracking() - .OrderByDescending(n => n.CreatedAt) - .Take(20) - .Select(n => new { n.Id, n.Title, n.Message, n.Link, n.NotificationType, n.CreatedAt }) - .ToListAsync(); - return Json(new { count = items.Count, items = items.Select(n => new { n.Id, n.Title, n.Message, n.Link, n.NotificationType, CreatedAt = n.CreatedAt.Tz(tz).ToString("MMM d, h:mm tt") @@ -137,44 +103,38 @@ public class InAppNotificationsController : Controller } /// - /// Marks a single notification as read and records the timestamp. Tenant isolation is enforced manually for SuperAdmins (CompanyId 0 filter) because IgnoreQueryFilters bypasses the global company filter. + /// Marks a single notification as read. Tenant isolation is enforced manually for SuperAdmins (CompanyId 0 filter) because IgnoreQueryFilters bypasses the global company filter. /// [HttpPost] public async Task MarkRead(int id) { var notification = _tenant.IsPlatformAdmin() - ? await _db.InAppNotifications.IgnoreQueryFilters().FirstOrDefaultAsync(n => n.Id == id && n.CompanyId == 0 && !n.IsDeleted) - : await _db.InAppNotifications.FirstOrDefaultAsync(n => n.Id == id); + ? await _unitOfWork.InAppNotifications.FirstOrDefaultAsync( + n => n.Id == id && n.CompanyId == 0 && !n.IsDeleted, ignoreQueryFilters: true) + : await _unitOfWork.InAppNotifications.GetByIdAsync(id); if (notification == null) return NotFound(); notification.IsRead = true; notification.ReadAt = DateTime.UtcNow; notification.UpdatedAt = DateTime.UtcNow; - await _db.SaveChangesAsync(); + await _unitOfWork.CompleteAsync(); return Json(new { success = true }); } /// - /// Marks every unread notification as read for the current user's scope in a single SaveChanges call for efficiency. Returns the count of items marked so the UI can update the badge without refetching. + /// Marks every unread notification as read for the current user's scope in a single SaveChanges call. /// [HttpPost] public async Task MarkAllRead() { var now = DateTime.UtcNow; - List unread; - if (_tenant.IsPlatformAdmin()) - { - unread = await _db.InAppNotifications.IgnoreQueryFilters() - .Where(n => !n.IsDeleted && n.CompanyId == 0 && !n.IsRead) - .ToListAsync(); - } - else - { - unread = await _db.InAppNotifications.Where(n => !n.IsRead).ToListAsync(); - } + var unread = _tenant.IsPlatformAdmin() + ? (await _unitOfWork.InAppNotifications.FindAsync( + n => !n.IsDeleted && n.CompanyId == 0 && !n.IsRead, ignoreQueryFilters: true)).ToList() + : (await _unitOfWork.InAppNotifications.FindAsync(n => !n.IsRead)).ToList(); foreach (var n in unread) { @@ -183,7 +143,7 @@ public class InAppNotificationsController : Controller n.UpdatedAt = now; } - await _db.SaveChangesAsync(); + await _unitOfWork.CompleteAsync(); return Json(new { success = true, count = unread.Count }); } } diff --git a/src/PowderCoating.Web/Controllers/InventoryController.cs b/src/PowderCoating.Web/Controllers/InventoryController.cs index 2a0c0e9..be94cbf 100644 --- a/src/PowderCoating.Web/Controllers/InventoryController.cs +++ b/src/PowderCoating.Web/Controllers/InventoryController.cs @@ -11,8 +11,6 @@ using PowderCoating.Core.Entities; using PowderCoating.Core.Enums; using PowderCoating.Core.Interfaces; using Microsoft.AspNetCore.Identity; -using PowderCoating.Infrastructure.Data; -using Microsoft.EntityFrameworkCore; using QRCoder; using System.Drawing; using System.Drawing.Imaging; @@ -29,7 +27,6 @@ public class InventoryController : Controller private readonly IMeasurementConversionService _measurementService; private readonly IInventoryAiLookupService _aiLookupService; private readonly ISubscriptionService _subscriptionService; - private readonly ApplicationDbContext _context; private readonly UserManager _userManager; public InventoryController( @@ -40,7 +37,6 @@ public class InventoryController : Controller IMeasurementConversionService measurementService, IInventoryAiLookupService aiLookupService, ISubscriptionService subscriptionService, - ApplicationDbContext context, UserManager userManager) { _unitOfWork = unitOfWork; @@ -50,7 +46,6 @@ public class InventoryController : Controller _measurementService = measurementService; _aiLookupService = aiLookupService; _subscriptionService = subscriptionService; - _context = context; _userManager = userManager; } @@ -166,12 +161,12 @@ public class InventoryController : Controller TotalCount = totalCount }; - // Push stats and category list to the database rather than loading all rows - var statsBase = _context.InventoryItems; - ViewBag.Categories = await statsBase.Select(i => i.Category).Where(c => c != null).Distinct().OrderBy(c => c).ToListAsync(); - ViewBag.StatsLowStockCount = await statsBase.CountAsync(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint); - ViewBag.StatsActiveCount = await statsBase.CountAsync(i => i.IsActive); - ViewBag.StatsTotalValue = await statsBase.SumAsync(i => (decimal?)i.QuantityOnHand * i.UnitCost) ?? 0m; + // Load all items once to compute sidebar stats and category list in memory + var allItems = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList(); + ViewBag.Categories = allItems.Select(i => i.Category).Where(c => c != null).Distinct().OrderBy(c => c).ToList(); + ViewBag.StatsLowStockCount = allItems.Count(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint); + ViewBag.StatsActiveCount = allItems.Count(i => i.IsActive); + ViewBag.StatsTotalValue = allItems.Sum(i => (decimal?)i.QuantityOnHand * i.UnitCost) ?? 0m; // Set ViewBag for sorting and filters ViewBag.SearchTerm = searchTerm; @@ -560,20 +555,8 @@ public class InventoryController : Controller if (string.IsNullOrEmpty(colorName) && string.IsNullOrEmpty(name)) return Json(new { success = true, photos = Array.Empty(), totalCount = 0, page, pageSize }); - IQueryable query = _context.JobPhotos - .AsNoTracking() - .Include(p => p.Job).ThenInclude(j => j.Customer) - .Where(p => !p.IsDeleted && p.Tags != null && p.Tags != ""); - - if (!string.IsNullOrEmpty(colorName) && !string.IsNullOrEmpty(name) && colorName != name) - query = query.Where(p => p.Tags!.ToLower().Contains(colorName) || p.Tags!.ToLower().Contains(name)); - else if (!string.IsNullOrEmpty(colorName)) - query = query.Where(p => p.Tags!.ToLower().Contains(colorName)); - else - query = query.Where(p => p.Tags!.ToLower().Contains(name!)); - // Exact tag match (avoid "Black" matching "Gloss Black Semi-Gloss") - var allMatches = await query.OrderByDescending(p => p.UploadedDate).ToListAsync(); + var allMatches = await _unitOfWork.JobPhotos.GetTaggedPhotosAsync(colorName, name); var searchTerms = new[] { colorName, name } .Where(s => !string.IsNullOrEmpty(s)) @@ -620,15 +603,7 @@ public class InventoryController : Controller { try { - var photos = await _context.JobPhotos - .AsNoTracking() - .Include(p => p.Job).ThenInclude(j => j.Customer) - .Include(p => p.Job).ThenInclude(j => j.JobItems).ThenInclude(ji => ji.Coats) - .Where(p => !p.IsDeleted && - p.Job != null && - p.Job.JobItems.Any(ji => ji.Coats.Any(c => c.InventoryItemId == id))) - .OrderByDescending(p => p.UploadedDate) - .ToListAsync(); + var photos = await _unitOfWork.JobPhotos.GetPhotosByPowderItemAsync(id); var totalCount = photos.Count; var paged = photos @@ -728,12 +703,12 @@ public class InventoryController : Controller { try { - var allCoatings = await _context.InventoryItems - .AsNoTracking() - .Include(i => i.InventoryCategory) - .Where(i => !i.IsDeleted && i.InventoryCategory != null && i.InventoryCategory.IsCoating) + var allCoatings = (await _unitOfWork.InventoryItems.FindAsync( + i => i.InventoryCategory != null && i.InventoryCategory.IsCoating, + false, + i => i.InventoryCategory)) .OrderBy(i => i.Manufacturer).ThenBy(i => i.ColorName).ThenBy(i => i.Name) - .ToListAsync(); + .ToList(); // Distinct manufacturer list for filter dropdown ViewBag.Manufacturers = allCoatings @@ -955,25 +930,39 @@ public class InventoryController : Controller var userId = _userManager.GetUserId(User); - var myJobs = await _context.Jobs - .AsNoTracking() - .Include(j => j.Customer) - .Include(j => j.JobStatus) - .Where(j => !j.IsDeleted && !j.JobStatus.IsTerminalStatus && j.AssignedUserId == userId) + var myJobs = (await _unitOfWork.Jobs.FindAsync( + j => !j.JobStatus.IsTerminalStatus && j.AssignedUserId == userId, + false, + j => j.Customer, + j => j.JobStatus)) .OrderBy(j => j.JobNumber) - .Select(j => new ScanJobOption { Id = j.Id, JobNumber = j.JobNumber, CustomerName = j.Customer != null ? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName) : "No Customer" }) - .ToListAsync(); + .Select(j => new ScanJobOption + { + Id = j.Id, + JobNumber = j.JobNumber, + CustomerName = j.Customer != null + ? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName) + : "No Customer" + }) + .ToList(); var myJobIds = myJobs.Select(j => j.Id).ToHashSet(); - var otherJobs = await _context.Jobs - .AsNoTracking() - .Include(j => j.Customer) - .Include(j => j.JobStatus) - .Where(j => !j.IsDeleted && !j.JobStatus.IsTerminalStatus && !myJobIds.Contains(j.Id)) + var otherJobs = (await _unitOfWork.Jobs.FindAsync( + j => !j.JobStatus.IsTerminalStatus && !myJobIds.Contains(j.Id), + false, + j => j.Customer, + j => j.JobStatus)) .OrderByDescending(j => j.CreatedAt) .Take(100) - .Select(j => new ScanJobOption { Id = j.Id, JobNumber = j.JobNumber, CustomerName = j.Customer != null ? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName) : "No Customer" }) - .ToListAsync(); + .Select(j => new ScanJobOption + { + Id = j.Id, + JobNumber = j.JobNumber, + CustomerName = j.Customer != null + ? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName) + : "No Customer" + }) + .ToList(); ViewBag.ItemDto = _mapper.Map(item); ViewBag.MyJobs = myJobs; @@ -1169,29 +1158,8 @@ public class InventoryController : Controller .ToList(); // Build transactions query - var txnQuery = _context.InventoryTransactions - .AsNoTracking() - .Include(t => t.InventoryItem) - .Include(t => t.PurchaseOrder) - .Include(t => t.Job) - .Where(t => !t.IsDeleted); - - if (inventoryItemId.HasValue) - txnQuery = txnQuery.Where(t => t.InventoryItemId == inventoryItemId.Value); - - if (dateFrom.HasValue) - txnQuery = txnQuery.Where(t => t.TransactionDate >= dateFrom.Value); - - if (dateTo.HasValue) - txnQuery = txnQuery.Where(t => t.TransactionDate < dateTo.Value.AddDays(1)); - - if (!string.IsNullOrWhiteSpace(typeFilter) && Enum.TryParse(typeFilter, out var parsedType)) - txnQuery = txnQuery.Where(t => t.TransactionType == parsedType); - - var transactions = await txnQuery - .OrderByDescending(t => t.TransactionDate) - .Take(500) - .ToListAsync(); + InventoryTransactionType? parsedType = !string.IsNullOrWhiteSpace(typeFilter) && Enum.TryParse(typeFilter, out var pt) ? pt : null; + var transactions = await _unitOfWork.InventoryTransactions.GetForLedgerAsync(inventoryItemId, dateFrom, dateTo, parsedType); // Resolve JobId for legacy JobUsage transactions that stored job number in Reference but not JobId var unresolvedRefs = transactions @@ -1202,36 +1170,12 @@ public class InventoryController : Controller var jobRefLookup = new Dictionary(); if (unresolvedRefs.Any()) { - var matched = await _context.Jobs - .AsNoTracking() - .Where(j => unresolvedRefs.Contains(j.JobNumber)) - .Select(j => new { j.Id, j.JobNumber }) - .ToListAsync(); + var matched = await _unitOfWork.Jobs.FindAsync(j => unresolvedRefs.Contains(j.JobNumber)); jobRefLookup = matched.ToDictionary(j => j.JobNumber, j => (j.Id, j.JobNumber)); } - // Build powder usage logs query - var usageQuery = _context.PowderUsageLogs - .AsNoTracking() - .Include(u => u.Job).ThenInclude(j => j.Customer) - .Include(u => u.InventoryItem) - .Include(u => u.JobItemCoat) - .Where(u => !u.IsDeleted); - - if (inventoryItemId.HasValue) - usageQuery = usageQuery.Where(u => u.InventoryItemId == inventoryItemId.Value); - - if (dateFrom.HasValue) - usageQuery = usageQuery.Where(u => u.RecordedAt >= dateFrom.Value); - - if (dateTo.HasValue) - usageQuery = usageQuery.Where(u => u.RecordedAt < dateTo.Value.AddDays(1)); - - // Exclude JobUsage type from transactions when showing usage tab (avoid double-counting display) - var usageLogs = await usageQuery - .OrderByDescending(u => u.RecordedAt) - .Take(500) - .ToListAsync(); + // Powder usage logs with dynamic date + item filters + var usageLogs = await _unitOfWork.PowderUsageLogs.GetForLedgerAsync(inventoryItemId, dateFrom, dateTo); InventoryItem? selectedItem = null; if (inventoryItemId.HasValue) diff --git a/src/PowderCoating.Web/Controllers/JobTemplatesController.cs b/src/PowderCoating.Web/Controllers/JobTemplatesController.cs index 707cb00..5434cb4 100644 --- a/src/PowderCoating.Web/Controllers/JobTemplatesController.cs +++ b/src/PowderCoating.Web/Controllers/JobTemplatesController.cs @@ -1,12 +1,9 @@ using Microsoft.AspNetCore.Authorization; using PowderCoating.Shared.Constants; -using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; -using Microsoft.EntityFrameworkCore; using PowderCoating.Core.Entities; using PowderCoating.Core.Interfaces; -using PowderCoating.Infrastructure.Data; namespace PowderCoating.Web.Controllers; @@ -23,35 +20,28 @@ public class JobTemplatesController : Controller { private readonly IUnitOfWork _unitOfWork; private readonly ITenantContext _tenantContext; - private readonly ApplicationDbContext _context; public JobTemplatesController( IUnitOfWork unitOfWork, - ITenantContext tenantContext, - ApplicationDbContext context) + ITenantContext tenantContext) { _unitOfWork = unitOfWork; _tenantContext = tenantContext; - _context = context; } /// /// Displays all non-deleted job templates for the current company, ordered by name, with their - /// linked customer and item counts. Uses the direct _context query (bypassing - /// IUnitOfWork) to leverage EF Core's filtered includes (.Include(t => t.Items)) - /// which are not exposed through the generic repository pattern. + /// linked customer and item counts. Multi-tenancy and soft-delete scoping are handled by global + /// query filters; the typed repository provides the ThenInclude chain for items. /// public async Task Index() { - var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; - - var templates = await _context.JobTemplates - .Include(t => t.Customer) - .Include(t => t.Items) - .Where(t => !t.IsDeleted && t.CompanyId == companyId) - .OrderBy(t => t.Name) - .ToListAsync(); + var templates = await _unitOfWork.JobTemplates.GetAllAsync( + false, + t => t.Customer, + t => t.Items); + templates = templates.OrderBy(t => t.Name).ToList(); return View(templates); } @@ -59,22 +49,12 @@ public class JobTemplatesController : Controller /// Shows the full template detail including all non-deleted items, each with their coats /// (including the linked inventory item for color/powder info) and prep services (including the /// prep service entity for the service name). Soft-deleted items, coats, and prep services are - /// excluded via EF filtered includes so the view reflects the current active configuration. + /// excluded via filtered includes in the typed repository. /// public async Task Details(int id) { - var template = await _context.JobTemplates - .Include(t => t.Customer) - .Include(t => t.Items.Where(i => !i.IsDeleted)) - .ThenInclude(i => i.Coats.Where(c => !c.IsDeleted)) - .ThenInclude(c => c.InventoryItem) - .Include(t => t.Items.Where(i => !i.IsDeleted)) - .ThenInclude(i => i.PrepServices.Where(p => !p.IsDeleted)) - .ThenInclude(p => p.PrepService) - .FirstOrDefaultAsync(t => t.Id == id && !t.IsDeleted); - + var template = await _unitOfWork.JobTemplates.LoadForDetailsAsync(id); if (template == null) return NotFound(); - return View(template); } @@ -85,9 +65,7 @@ public class JobTemplatesController : Controller /// public async Task Edit(int id) { - var template = await _context.JobTemplates - .FirstOrDefaultAsync(t => t.Id == id && !t.IsDeleted); - + var template = await _unitOfWork.JobTemplates.GetByIdAsync(id); if (template == null) return NotFound(); await PopulateCustomerDropdown(template.CustomerId); @@ -150,12 +128,7 @@ public class JobTemplatesController : Controller { var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; - var job = await _context.Jobs - .Include(j => j.JobItems.Where(i => !i.IsDeleted)) - .ThenInclude(i => i.Coats.Where(c => !c.IsDeleted)) - .Include(j => j.JobItems.Where(i => !i.IsDeleted)) - .ThenInclude(i => i.PrepServices.Where(p => !p.IsDeleted)) - .FirstOrDefaultAsync(j => j.Id == jobId && !j.IsDeleted); + var job = await _unitOfWork.Jobs.LoadForTemplateSnapshotAsync(jobId); if (job == null) return NotFound(); @@ -254,18 +227,7 @@ public class JobTemplatesController : Controller [HttpGet] public async Task GetTemplatesJson() { - var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; - - var templates = await _context.JobTemplates - .Include(t => t.Customer) - .Include(t => t.Items.Where(i => !i.IsDeleted)) - .ThenInclude(i => i.Coats.Where(c => !c.IsDeleted)) - .Include(t => t.Items.Where(i => !i.IsDeleted)) - .ThenInclude(i => i.PrepServices.Where(p => !p.IsDeleted)) - .ThenInclude(p => p.PrepService) - .Where(t => !t.IsDeleted && t.CompanyId == companyId && t.IsActive) - .OrderBy(t => t.Name) - .ToListAsync(); + var templates = await _unitOfWork.JobTemplates.GetAllActiveWithFullIncludesAsync(); var result = templates.Select(t => new { diff --git a/src/PowderCoating.Web/Controllers/JobsPriorityController.cs b/src/PowderCoating.Web/Controllers/JobsPriorityController.cs index 1e966c1..1756b55 100644 --- a/src/PowderCoating.Web/Controllers/JobsPriorityController.cs +++ b/src/PowderCoating.Web/Controllers/JobsPriorityController.cs @@ -9,7 +9,6 @@ using PowderCoating.Application.Services; using PowderCoating.Core.Entities; using PowderCoating.Core.Enums; using PowderCoating.Core.Interfaces; -using PowderCoating.Infrastructure.Data; using PowderCoating.Shared.Constants; using PowderCoating.Web.Hubs; @@ -19,7 +18,6 @@ namespace PowderCoating.Web.Controllers; public class JobsPriorityController : Controller { private readonly IUnitOfWork _unitOfWork; - private readonly ApplicationDbContext _context; private readonly ILogger _logger; private readonly UserManager _userManager; private readonly ITenantContext _tenantContext; @@ -27,14 +25,12 @@ public class JobsPriorityController : Controller public JobsPriorityController( IUnitOfWork unitOfWork, - ApplicationDbContext context, ILogger logger, UserManager userManager, ITenantContext tenantContext, IHubContext shopHub) { _unitOfWork = unitOfWork; - _context = context; _logger = logger; _userManager = userManager; _tenantContext = tenantContext; @@ -63,13 +59,7 @@ public class JobsPriorityController : Controller var today = date?.Date ?? DateTime.Today; // Get all jobs scheduled for today with related data - var jobs = await _context.Jobs - .Include(j => j.Customer) - .Include(j => j.JobStatus) - .Include(j => j.JobPriority) - .Include(j => j.AssignedUser) - .Where(j => j.ScheduledDate.HasValue && j.ScheduledDate.Value.Date == today && !j.IsDeleted) - .ToListAsync(); + var jobs = await _unitOfWork.Jobs.GetScheduledJobsForDateAsync(today); // Get existing priority records for today var existingPriorities = await _unitOfWork.JobDailyPriorities @@ -108,15 +98,14 @@ public class JobsPriorityController : Controller .ToListAsync(); // Get maintenance records scheduled for today (Scheduled or InProgress) - var maintenanceItems = await _context.MaintenanceRecords - .Include(m => m.Equipment) - .Include(m => m.AssignedUser) - .Where(m => m.ScheduledDate.Date == today && !m.IsDeleted && - (m.Status == MaintenanceStatus.Scheduled || - m.Status == MaintenanceStatus.InProgress)) + var maintenanceItems = (await _unitOfWork.MaintenanceRecords.FindAsync( + m => m.ScheduledDate.Date == today && + (m.Status == MaintenanceStatus.Scheduled || m.Status == MaintenanceStatus.InProgress), + false, + m => m.Equipment, m => m.AssignedUser)) .OrderByDescending(m => (int)m.Priority) .ThenBy(m => m.ScheduledDate) - .ToListAsync(); + .ToList(); ViewBag.ScheduledDate = today; ViewBag.MaintenanceItems = maintenanceItems; @@ -378,14 +367,10 @@ public class JobsPriorityController : Controller { try { - var record = await _context.MaintenanceRecords.FindAsync(maintenanceId); + var record = await _unitOfWork.MaintenanceRecords.GetByIdAsync(maintenanceId); if (record == null || record.IsDeleted) return Json(new { success = false, message = "Maintenance record not found" }); - // FindAsync bypasses global query filters — verify company ownership explicitly - if (!_tenantContext.IsSuperAdmin() && record.CompanyId != _tenantContext.GetCurrentCompanyId()) - return Json(new { success = false, message = "Access denied." }); - string workerName = "Unassigned"; if (!string.IsNullOrEmpty(workerId)) { @@ -402,7 +387,7 @@ public class JobsPriorityController : Controller } record.UpdatedAt = DateTime.UtcNow; - await _context.SaveChangesAsync(); + await _unitOfWork.CompleteAsync(); return Json(new { success = true, message = "Worker assigned successfully", workerName }); } diff --git a/src/PowderCoating.Web/Controllers/NotificationLogsController.cs b/src/PowderCoating.Web/Controllers/NotificationLogsController.cs index fa040c1..9e628d6 100644 --- a/src/PowderCoating.Web/Controllers/NotificationLogsController.cs +++ b/src/PowderCoating.Web/Controllers/NotificationLogsController.cs @@ -1,10 +1,9 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; using PowderCoating.Application.DTOs.Common; using PowderCoating.Application.DTOs.Notification; using PowderCoating.Core.Enums; -using PowderCoating.Infrastructure.Data; +using PowderCoating.Core.Interfaces; using PowderCoating.Shared.Constants; namespace PowderCoating.Web.Controllers; @@ -14,31 +13,23 @@ namespace PowderCoating.Web.Controllers; /// CanManageJobs policy (Managers and above within a company). /// The platform-wide equivalent for SuperAdmins lives in /// . -/// Uses directly to enable LINQ projections -/// that avoid loading full message bodies into memory on the list page. /// [Authorize(Policy = AppConstants.Policies.CanManageJobs)] public class NotificationLogsController : Controller { - private readonly ApplicationDbContext _context; + private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; - public NotificationLogsController(ApplicationDbContext context, ILogger logger) + public NotificationLogsController(IUnitOfWork unitOfWork, ILogger logger) { - _context = context; + _unitOfWork = unitOfWork; _logger = logger; } /// - /// Displays a paginated, filterable list of notification log entries for the - /// current company. Supports filtering by free-text search, channel (Email/SMS), - /// delivery status, notification type, and an optional job context. - /// - /// The pageSize is validated against an allowlist (10/25/50/100) and - /// defaults to 25 to prevent callers from requesting arbitrarily large result sets. - /// Sorting defaults to most-recent-first (SentAt DESC) because operators - /// almost always want to see the latest delivery attempts first. - /// + /// Displays a paginated, filterable list of notification log entries for the current company. + /// Supports filtering by free-text search, channel, delivery status, notification type, and + /// an optional job context. Page size is validated against an allowlist (10/25/50/100). /// // GET: /NotificationLogs public async Task Index( @@ -55,74 +46,35 @@ public class NotificationLogsController : Controller pageNumber = Math.Max(1, pageNumber); pageSize = pageSize is 10 or 25 or 50 or 100 ? pageSize : 25; - var query = _context.NotificationLogs - .AsNoTracking() - .Include(n => n.Customer) - .Include(n => n.Job) - .Include(n => n.Quote) - .AsQueryable(); + NotificationChannel? channel = Enum.TryParse(channelFilter, out var ch) ? ch : null; + NotificationStatus? status = Enum.TryParse(statusFilter, out var st) ? st : null; + NotificationType? type = Enum.TryParse(typeFilter, out var ty) ? ty : null; - // Filters - if (jobId.HasValue) - query = query.Where(n => n.JobId == jobId.Value); + var (logs, totalCount) = await _unitOfWork.NotificationLogs.GetPagedFilteredAsync( + pageNumber, pageSize, searchTerm, channel, status, type, jobId, + sortColumn ?? "SentAt", sortDirection); - if (!string.IsNullOrWhiteSpace(searchTerm)) + var items = logs.Select(n => new NotificationLogDto { - var search = searchTerm.ToLower(); - query = query.Where(n => - n.RecipientName.ToLower().Contains(search) || - n.Recipient.ToLower().Contains(search) || - (n.Subject != null && n.Subject.ToLower().Contains(search)) || - (n.Job != null && n.Job.JobNumber.ToLower().Contains(search)) || - (n.Quote != null && n.Quote.QuoteNumber.ToLower().Contains(search))); - } - - if (!string.IsNullOrWhiteSpace(channelFilter) && Enum.TryParse(channelFilter, out var channel)) - query = query.Where(n => n.Channel == channel); - - if (!string.IsNullOrWhiteSpace(statusFilter) && Enum.TryParse(statusFilter, out var status)) - query = query.Where(n => n.Status == status); - - if (!string.IsNullOrWhiteSpace(typeFilter) && Enum.TryParse(typeFilter, out var type)) - query = query.Where(n => n.NotificationType == type); - - // Sorting - query = (sortColumn ?? "SentAt") switch - { - "RecipientName" => sortDirection == "asc" ? query.OrderBy(n => n.RecipientName) : query.OrderByDescending(n => n.RecipientName), - "Channel" => sortDirection == "asc" ? query.OrderBy(n => n.Channel) : query.OrderByDescending(n => n.Channel), - "Status" => sortDirection == "asc" ? query.OrderBy(n => n.Status) : query.OrderByDescending(n => n.Status), - "Type" => sortDirection == "asc" ? query.OrderBy(n => n.NotificationType) : query.OrderByDescending(n => n.NotificationType), - _ => sortDirection == "asc" ? query.OrderBy(n => n.SentAt) : query.OrderByDescending(n => n.SentAt) - }; - - var totalCount = await query.CountAsync(); - - var items = await query - .Skip((pageNumber - 1) * pageSize) - .Take(pageSize) - .Select(n => new NotificationLogDto - { - Id = n.Id, - Channel = n.Channel, - NotificationType = n.NotificationType, - Status = n.Status, - RecipientName = n.RecipientName, - Recipient = n.Recipient, - Subject = n.Subject, - Message = n.Message, - ErrorMessage = n.ErrorMessage, - SentAt = n.SentAt, - CustomerId = n.CustomerId, - JobId = n.JobId, - QuoteId = n.QuoteId, - JobNumber = n.Job != null ? n.Job.JobNumber : null, - QuoteNumber = n.Quote != null ? n.Quote.QuoteNumber : null, - CustomerName = n.Customer != null - ? (n.Customer.CompanyName ?? $"{n.Customer.ContactFirstName} {n.Customer.ContactLastName}".Trim()) - : null - }) - .ToListAsync(); + Id = n.Id, + Channel = n.Channel, + NotificationType = n.NotificationType, + Status = n.Status, + RecipientName = n.RecipientName, + Recipient = n.Recipient, + Subject = n.Subject, + Message = n.Message, + ErrorMessage = n.ErrorMessage, + SentAt = n.SentAt, + CustomerId = n.CustomerId, + JobId = n.JobId, + QuoteId = n.QuoteId, + JobNumber = n.Job?.JobNumber, + QuoteNumber = n.Quote?.QuoteNumber, + CustomerName = n.Customer != null + ? (n.Customer.CompanyName ?? $"{n.Customer.ContactFirstName} {n.Customer.ContactLastName}".Trim()) + : null + }).ToList(); var pagedResult = new PagedResult { @@ -144,22 +96,17 @@ public class NotificationLogsController : Controller } /// - /// Displays the full details of a single notification log entry including the - /// complete message body and any error message, which are omitted from the list - /// view to keep response sizes small. Loads related Customer, Job, and Quote - /// navigation properties to resolve display names. + /// Displays the full details of a single notification log entry including the complete message + /// body and any error message, which are omitted from the list view. Loads related Customer, + /// Job, and Quote navigation properties to resolve display names. /// // GET: /NotificationLogs/Details/5 public async Task Details(int id) { try { - var log = await _context.NotificationLogs - .AsNoTracking() - .Include(n => n.Customer) - .Include(n => n.Job) - .Include(n => n.Quote) - .FirstOrDefaultAsync(n => n.Id == id); + var log = await _unitOfWork.NotificationLogs.GetByIdAsync( + id, false, n => n.Customer, n => n.Job, n => n.Quote); if (log == null) return NotFound(); diff --git a/src/PowderCoating.Web/Controllers/PasskeyController.cs b/src/PowderCoating.Web/Controllers/PasskeyController.cs index 486c2a2..6ae73e6 100644 --- a/src/PowderCoating.Web/Controllers/PasskeyController.cs +++ b/src/PowderCoating.Web/Controllers/PasskeyController.cs @@ -20,6 +20,7 @@ namespace PowderCoating.Web.Controllers; /// matches automatically on localhost, dev, staging, and production without any /// environment-specific configuration. /// +// Intentional exception: WebAuthn/FIDO2 identity infrastructure. UserPasskeys is an ASP.NET Identity concern not exposed through IUnitOfWork; the anonymous login path has no tenant context; FIDO2 async callbacks capture _db by closure. See docs/DATA_ACCESS_ARCHITECTURE.md — Permanent Exceptions. [Route("[controller]/[action]")] public class PasskeyController : Controller { diff --git a/src/PowderCoating.Web/Controllers/PlatformNotificationsController.cs b/src/PowderCoating.Web/Controllers/PlatformNotificationsController.cs index 3b94769..568f52c 100644 --- a/src/PowderCoating.Web/Controllers/PlatformNotificationsController.cs +++ b/src/PowderCoating.Web/Controllers/PlatformNotificationsController.cs @@ -1,8 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; using PowderCoating.Core.Enums; -using PowderCoating.Infrastructure.Data; +using PowderCoating.Core.Interfaces; using PowderCoating.Shared.Constants; namespace PowderCoating.Web.Controllers; @@ -10,28 +9,21 @@ namespace PowderCoating.Web.Controllers; /// /// SuperAdmin-only cross-company notification log viewer. /// The company-scoped version lives in NotificationLogsController (CanManageJobs policy). +/// Uses IgnoreQueryFilters throughout to bypass the multi-tenancy global filter and see +/// logs from all companies simultaneously. /// [Authorize(Policy = AppConstants.Policies.SuperAdminOnly)] public class PlatformNotificationsController : Controller { - private readonly ApplicationDbContext _db; + private readonly IUnitOfWork _unitOfWork; - public PlatformNotificationsController(ApplicationDbContext db) => _db = db; + public PlatformNotificationsController(IUnitOfWork unitOfWork) => _unitOfWork = unitOfWork; /// - /// Renders a paginated, filterable cross-company notification log view visible - /// only to SuperAdmins. Unlike the tenant-scoped - /// , this action uses - /// IgnoreQueryFilters() to bypass the multi-tenancy global filter and - /// see logs from all companies simultaneously. - /// - /// Company names are resolved in a single follow-up query after paging (not via - /// a JOIN) because company data lives outside the notification-log table's usual - /// query scope, and a separate query keeps the main sort/filter logic clean. - /// Summary counts (total, failed, last 24 h) are computed as independent queries - /// against the full unfiltered dataset so the summary reflects platform-wide - /// health regardless of the active filters. - /// + /// Renders a paginated, filterable cross-company notification log view visible only to SuperAdmins. + /// All filter/sort/paging is applied in-memory after a single filtered repository fetch. + /// Company names are resolved in a follow-up batch query keyed on the page's distinct company IDs. + /// Summary counts reflect the full unfiltered dataset so the health cards are accurate regardless of active filters. /// public async Task Index( int? companyId, @@ -47,41 +39,38 @@ public class PlatformNotificationsController : Controller pageSize = pageSize is 25 or 50 or 100 ? pageSize : 50; page = Math.Max(1, page); - var query = _db.NotificationLogs - .AsNoTracking() - .IgnoreQueryFilters() - .Where(n => !n.IsDeleted) - .AsQueryable(); + // Load all non-deleted logs across all tenants, then filter in-memory. + var all = (await _unitOfWork.NotificationLogs.FindAsync(n => !n.IsDeleted, ignoreQueryFilters: true)) + .AsEnumerable(); if (companyId.HasValue) - query = query.Where(n => n.CompanyId == companyId); + all = all.Where(n => n.CompanyId == companyId); if (!string.IsNullOrWhiteSpace(type) && Enum.TryParse(type, out var typeEnum)) - query = query.Where(n => n.NotificationType == typeEnum); + all = all.Where(n => n.NotificationType == typeEnum); if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse(status, out var statusEnum)) - query = query.Where(n => n.Status == statusEnum); + all = all.Where(n => n.Status == statusEnum); if (!string.IsNullOrWhiteSpace(channel) && Enum.TryParse(channel, out var channelEnum)) - query = query.Where(n => n.Channel == channelEnum); + all = all.Where(n => n.Channel == channelEnum); if (!string.IsNullOrWhiteSpace(search)) - query = query.Where(n => - n.RecipientName.Contains(search) || - n.Recipient.Contains(search) || - (n.Subject != null && n.Subject.Contains(search))); + all = all.Where(n => + n.RecipientName.Contains(search, StringComparison.OrdinalIgnoreCase) || + n.Recipient.Contains(search, StringComparison.OrdinalIgnoreCase) || + (n.Subject != null && n.Subject.Contains(search, StringComparison.OrdinalIgnoreCase))); if (from.HasValue) - query = query.Where(n => n.SentAt >= from.Value.Date); + all = all.Where(n => n.SentAt >= from.Value.Date); if (to.HasValue) - query = query.Where(n => n.SentAt < to.Value.Date.AddDays(1)); + all = all.Where(n => n.SentAt < to.Value.Date.AddDays(1)); - query = query.OrderByDescending(n => n.SentAt); + var filtered = all.OrderByDescending(n => n.SentAt).ToList(); + var totalCount = filtered.Count; - var totalCount = await query.CountAsync(); - - var items = await query + var items = filtered .Skip((page - 1) * pageSize) .Take(pageSize) .Select(n => new PlatformNotificationRow @@ -97,30 +86,26 @@ public class PlatformNotificationsController : Controller ErrorMessage = n.ErrorMessage, SentAt = n.SentAt }) - .ToListAsync(); + .ToList(); - // Resolve company names for the rows in one query + // Resolve company names for the page's rows in one query var cids = items.Select(i => i.CompanyId).Distinct().ToList(); - var companyNames = await _db.Companies.AsNoTracking().IgnoreQueryFilters() - .Where(c => cids.Contains(c.Id)) - .ToDictionaryAsync(c => c.Id, c => c.CompanyName); - + var companyNames = (await _unitOfWork.Companies.FindAsync(c => cids.Contains(c.Id), ignoreQueryFilters: true)) + .ToDictionary(c => c.Id, c => c.CompanyName); foreach (var item in items) item.CompanyName = companyNames.GetValueOrDefault(item.CompanyId); // Sidebar company list for filter dropdown - var companies = await _db.Companies.AsNoTracking().IgnoreQueryFilters() - .Where(c => !c.IsDeleted) + var companies = (await _unitOfWork.Companies.FindAsync(c => !c.IsDeleted, ignoreQueryFilters: true)) .OrderBy(c => c.CompanyName) .Select(c => new { c.Id, c.CompanyName }) - .ToListAsync(); + .ToList(); - // Summary counts - ViewBag.TotalCount = await _db.NotificationLogs.IgnoreQueryFilters().CountAsync(n => !n.IsDeleted); - ViewBag.FailedCount = await _db.NotificationLogs.IgnoreQueryFilters() - .CountAsync(n => !n.IsDeleted && n.Status == NotificationStatus.Failed); - ViewBag.Last24hCount = await _db.NotificationLogs.IgnoreQueryFilters() - .CountAsync(n => !n.IsDeleted && n.SentAt >= DateTime.UtcNow.AddHours(-24)); + // Summary counts from the unfiltered dataset + var allLogs = await _unitOfWork.NotificationLogs.FindAsync(n => !n.IsDeleted, ignoreQueryFilters: true); + ViewBag.TotalCount = allLogs.Count(); + ViewBag.FailedCount = allLogs.Count(n => n.Status == NotificationStatus.Failed); + ViewBag.Last24hCount = allLogs.Count(n => n.SentAt >= DateTime.UtcNow.AddHours(-24)); ViewBag.Companies = companies; ViewBag.CompanyIdFilter = companyId; @@ -139,23 +124,17 @@ public class PlatformNotificationsController : Controller } /// - /// Returns the full notification log entry for the given , - /// including the complete message body and error details. The owning company name - /// is resolved separately (rather than via navigation property) because company - /// records are in a different query-filter context. + /// Returns the full notification log entry for the given id, including the complete message body and error details. /// public async Task Details(int id) { - var log = await _db.NotificationLogs.AsNoTracking().IgnoreQueryFilters() - .FirstOrDefaultAsync(n => n.Id == id); + var log = await _unitOfWork.NotificationLogs.FirstOrDefaultAsync(n => n.Id == id, ignoreQueryFilters: true); if (log == null) return NotFound(); string? companyName = null; if (log.CompanyId > 0) - companyName = await _db.Companies.AsNoTracking().IgnoreQueryFilters() - .Where(c => c.Id == log.CompanyId) - .Select(c => c.CompanyName) - .FirstOrDefaultAsync(); + companyName = (await _unitOfWork.Companies.FirstOrDefaultAsync( + c => c.Id == log.CompanyId, ignoreQueryFilters: true))?.CompanyName; ViewBag.CompanyName = companyName; return View(log); diff --git a/src/PowderCoating.Web/Controllers/QuoteApprovalController.cs b/src/PowderCoating.Web/Controllers/QuoteApprovalController.cs index c4854c3..367f9f3 100644 --- a/src/PowderCoating.Web/Controllers/QuoteApprovalController.cs +++ b/src/PowderCoating.Web/Controllers/QuoteApprovalController.cs @@ -1,11 +1,10 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.SignalR; -using Microsoft.EntityFrameworkCore; using PowderCoating.Application.Interfaces; using PowderCoating.Core.Entities; using PowderCoating.Core.Enums; -using PowderCoating.Infrastructure.Data; +using PowderCoating.Core.Interfaces; using PowderCoating.Shared.Constants; using PowderCoating.Web.Hubs; using PowderCoating.Web.ViewModels; @@ -22,7 +21,7 @@ namespace PowderCoating.Web.Controllers; [EnableRateLimiting(AppConstants.RateLimitPolicies.Public)] public class QuoteApprovalController : Controller { - private readonly ApplicationDbContext _db; + private readonly IUnitOfWork _unitOfWork; private readonly INotificationService _notifications; private readonly IInAppNotificationService _inApp; private readonly IStripeConnectService _stripeConnect; @@ -31,7 +30,7 @@ public class QuoteApprovalController : Controller private readonly IHubContext _hub; public QuoteApprovalController( - ApplicationDbContext db, + IUnitOfWork unitOfWork, INotificationService notifications, IInAppNotificationService inApp, IStripeConnectService stripeConnect, @@ -39,7 +38,7 @@ public class QuoteApprovalController : Controller IConfiguration configuration, IHubContext hub) { - _db = db; + _unitOfWork = unitOfWork; _notifications = notifications; _inApp = inApp; _stripeConnect = stripeConnect; @@ -50,11 +49,8 @@ public class QuoteApprovalController : Controller /// /// Renders the main customer-facing approval page showing quote line items, totals, and - /// Approve/Decline buttons. The [ActionName("View")] attribute overrides the method name - /// so the route is /quote-approval/{token} without exposing the internal method name. - /// All token validation (expiry, already-acted) is centralised in . + /// Approve/Decline buttons. /// - // GET /quote-approval/{token} [HttpGet("{token}")] [ActionName("View")] public async Task ShowApprovalPage(string token) @@ -67,19 +63,15 @@ public class QuoteApprovalController : Controller } /// - /// Shows the contact-details confirmation step for prospect (non-customer) quotes. Prospects - /// have no CustomerId on the quote, so we collect their contact information before - /// finalising the approval. Quotes already linked to a customer skip this step and go directly - /// to — a customer's details are already on file. + /// Shows the contact-details confirmation step for prospect (non-customer) quotes. + /// Quotes already linked to a customer skip this step and go directly to ApproveInternal. /// - // GET /quote-approval/{token}/confirm-details [HttpGet("{token}/confirm-details")] public async Task ConfirmDetails(string token) { var (quote, errorResult) = await ValidateTokenAsync(token); if (errorResult != null) return errorResult; - // Only prospects need to fill in details; linked customers go straight through. if (quote!.CustomerId.HasValue) return await ApproveInternal(token, quote); @@ -88,13 +80,10 @@ public class QuoteApprovalController : Controller } /// - /// Handles the contact-detail form submission from prospects. Requires at minimum a name and - /// either an email or phone number — this minimal validation mirrors the fields required to - /// later convert the quote to a customer record. On success, persists the prospect contact - /// details to the quote and delegates to so the approval and - /// audit trail are written in a single path shared with the customer flow. + /// Handles the contact-detail form submission from prospects. Persists contact details to the + /// quote then delegates to ApproveInternal so the approval and audit trail are written in a single + /// path shared with the customer flow. /// - // POST /quote-approval/{token}/confirm-details [HttpPost("{token}/confirm-details")] [ValidateAntiForgeryToken] public async Task SubmitDetails(string token, @@ -110,7 +99,6 @@ public class QuoteApprovalController : Controller var (quote, errorResult) = await ValidateTokenAsync(token); if (errorResult != null) return errorResult; - // Require at minimum a name and either email or phone if (string.IsNullOrWhiteSpace(contactName) || (string.IsNullOrWhiteSpace(email) && string.IsNullOrWhiteSpace(phone))) { @@ -127,7 +115,6 @@ public class QuoteApprovalController : Controller return base.View("ConfirmDetails", model); } - // Update prospect fields on the quote quote!.ProspectContactName = contactName?.Trim(); quote.ProspectEmail = email?.Trim(); quote.ProspectPhone = phone?.Trim(); @@ -137,18 +124,15 @@ public class QuoteApprovalController : Controller quote.ProspectState = state?.Trim(); quote.ProspectZipCode = zipCode?.Trim(); quote.UpdatedAt = DateTime.UtcNow; - await _db.SaveChangesAsync(); + await _unitOfWork.CompleteAsync(); return await ApproveInternal(token, quote); } /// - /// Entry point for the Approve button on the approval page. For prospect quotes, redirects to - /// the contact-details form rather than approving immediately. For customer-linked quotes, - /// delegates directly to . This two-path design keeps the main - /// approval page simple (one Approve button) while still collecting required prospect data. + /// Entry point for the Approve button. For prospect quotes, redirects to the contact-details form. + /// For customer-linked quotes, delegates directly to ApproveInternal. /// - // POST /quote-approval/{token}/approve [HttpPost("{token}/approve")] [ValidateAntiForgeryToken] public async Task Approve(string token) @@ -156,7 +140,6 @@ public class QuoteApprovalController : Controller var (quote, errorResult) = await ValidateTokenAsync(token); if (errorResult != null) return errorResult; - // Prospect quotes collect contact details first if (!quote!.CustomerId.HasValue) { var model = await BuildViewModelAsync(quote, token); @@ -167,22 +150,15 @@ public class QuoteApprovalController : Controller } /// - /// Core approval logic shared by both the customer path () and the prospect - /// path (). Sets the quote status to the company's designated - /// IsApprovedStatus lookup entry, records ApprovalTokenUsedAt to prevent token - /// reuse, clears any prior decline reason (customers can re-approve after declining), and writes - /// a QuoteChangeHistory audit entry with ChangedByUserId = null to indicate the - /// action was performed by the customer rather than a staff member. After persisting, a SignalR - /// push notifies logged-in staff in real time. If the quote requires a deposit and the company - /// has Stripe Connect active, a time-limited deposit payment link token (7 days) is generated - /// and saved so the confirmation page can surface a "Pay deposit online" button. Prospect quotes - /// skip the deposit link because there is no Customer row to attach a Deposit to. + /// Core approval logic shared by the customer path and the prospect path. Sets the quote status, + /// records ApprovalTokenUsedAt, writes an audit entry, pushes a SignalR notification to staff, + /// and optionally generates a deposit payment link token if Stripe Connect is active. /// private async Task ApproveInternal(string token, Quote quote) { - var approvedStatus = await _db.QuoteStatusLookups - .IgnoreQueryFilters() - .FirstOrDefaultAsync(s => s.CompanyId == quote.CompanyId && s.IsApprovedStatus && !s.IsDeleted); + var approvedStatus = await _unitOfWork.QuoteStatusLookups.FirstOrDefaultAsync( + s => s.CompanyId == quote.CompanyId && s.IsApprovedStatus && !s.IsDeleted, + ignoreQueryFilters: true); var oldStatusName = quote.QuoteStatus?.DisplayName ?? "Unknown"; @@ -199,9 +175,9 @@ public class QuoteApprovalController : Controller quote.DeclineReason = null; } - await _db.SaveChangesAsync(); + await _unitOfWork.CompleteAsync(); - var approveEntry = new PowderCoating.Core.Entities.QuoteChangeHistory + var approveEntry = new QuoteChangeHistory { QuoteId = quote.Id, ChangedByUserId = null, @@ -215,8 +191,8 @@ public class QuoteApprovalController : Controller CompanyId = quote.CompanyId, CreatedAt = DateTime.UtcNow }; - _db.QuoteChangeHistories.Add(approveEntry); - await _db.SaveChangesAsync(); + await _unitOfWork.QuoteChangeHistories.AddAsync(approveEntry); + await _unitOfWork.CompleteAsync(); await _hub.Clients.Group($"company-{quote.CompanyId}").SendAsync("QuoteActedByCustomer", new { @@ -246,20 +222,19 @@ public class QuoteApprovalController : Controller _logger.LogWarning(ex, "Failed to send approval notification for quote {QuoteId}", quote.Id); } - // Generate deposit payment link if this quote requires a deposit and the company has Stripe Connect if (quote.RequiresDeposit && quote.DepositPercent > 0 - && quote.CustomerId.HasValue // prospects don't have a Customer row to attach a Deposit to + && quote.CustomerId.HasValue && quote.DepositAmountPaid <= 0) { - var company = await _db.Companies.AsNoTracking() - .FirstOrDefaultAsync(c => c.Id == quote.CompanyId && !c.IsDeleted); + var company = await _unitOfWork.Companies.FirstOrDefaultAsync( + c => c.Id == quote.CompanyId && !c.IsDeleted); if (company?.StripeConnectStatus == StripeConnectStatus.Active) { quote.DepositPaymentLinkToken = Guid.NewGuid().ToString("N"); quote.DepositPaymentLinkExpiresAt = DateTime.UtcNow.AddDays(7); - await _db.SaveChangesAsync(); + await _unitOfWork.CompleteAsync(); } } @@ -267,15 +242,9 @@ public class QuoteApprovalController : Controller } /// - /// Handles the customer declining a quote. Requires a non-empty reason (enforced with a - /// field-level error, not ModelState) so staff always know why a quote was rejected. The - /// decline reason is truncated to 1000 characters before persistence to avoid oversized inputs. - /// The customer's IP is recorded (DeclinedByIp) for audit purposes. A SignalR event and - /// in-app notification are pushed to staff. The token is marked used (ApprovalTokenUsedAt) - /// so the customer cannot approve after declining via the same link — they must request a new - /// link from the shop. + /// Handles the customer declining a quote. Records the decline reason, marks the token used, + /// pushes a SignalR notification and in-app notification to staff. /// - // POST /quote-approval/{token}/decline [HttpPost("{token}/decline")] [ValidateAntiForgeryToken] public async Task Decline(string token, [FromForm] string reason) @@ -290,13 +259,12 @@ public class QuoteApprovalController : Controller return base.View("ApprovalPage", model); } - // Find the rejected status for this company (by flag, or fall back to StatusCode) - var rejectedStatus = await _db.QuoteStatusLookups - .IgnoreQueryFilters() - .FirstOrDefaultAsync(s => s.CompanyId == quote!.CompanyId && s.IsRejectedStatus && !s.IsDeleted) - ?? await _db.QuoteStatusLookups - .IgnoreQueryFilters() - .FirstOrDefaultAsync(s => s.CompanyId == quote!.CompanyId && s.StatusCode == "REJECTED" && !s.IsDeleted); + var rejectedStatus = await _unitOfWork.QuoteStatusLookups.FirstOrDefaultAsync( + s => s.CompanyId == quote!.CompanyId && s.IsRejectedStatus && !s.IsDeleted, + ignoreQueryFilters: true) + ?? await _unitOfWork.QuoteStatusLookups.FirstOrDefaultAsync( + s => s.CompanyId == quote!.CompanyId && s.StatusCode == "REJECTED" && !s.IsDeleted, + ignoreQueryFilters: true); var oldDeclineStatusName = quote!.QuoteStatus?.DisplayName ?? "Unknown"; @@ -308,10 +276,9 @@ public class QuoteApprovalController : Controller quote.DeclinedByIp = HttpContext.Connection.RemoteIpAddress?.ToString(); quote.ApprovalTokenUsedAt = DateTime.UtcNow; - await _db.SaveChangesAsync(); + await _unitOfWork.CompleteAsync(); - // Audit log — decline - var declineEntry = new PowderCoating.Core.Entities.QuoteChangeHistory + var declineEntry = new QuoteChangeHistory { QuoteId = quote.Id, ChangedByUserId = null, @@ -323,10 +290,9 @@ public class QuoteApprovalController : Controller CompanyId = quote.CompanyId, CreatedAt = DateTime.UtcNow }; - _db.QuoteChangeHistories.Add(declineEntry); - await _db.SaveChangesAsync(); + await _unitOfWork.QuoteChangeHistories.AddAsync(declineEntry); + await _unitOfWork.CompleteAsync(); - // Push real-time toast to any logged-in company users await _hub.Clients.Group($"company-{quote.CompanyId}").SendAsync("QuoteActedByCustomer", new { approved = false, @@ -359,40 +325,30 @@ public class QuoteApprovalController : Controller } /// - /// Renders the post-action confirmation page shown after the customer approves or declines. - /// Does NOT re-validate the token against the used/expired guards in - /// — by this point the token is already marked used, so those - /// guards would incorrectly redirect to AlreadyActed. Instead, it only checks that the quote - /// exists. The action query-string value ("approved" / "declined") is set by the - /// redirect from or and drives the - /// confirmation message in the view. The deposit link token is only surfaced if it is present - /// and not yet expired. + /// Renders the post-action confirmation page. Does NOT re-validate the token against the + /// used/expired guards — by this point the token is already marked used. /// - // GET /quote-approval/{token}/confirmation [HttpGet("{token}/confirmation")] public async Task Confirmation(string token, [FromQuery] string action) { - var quote = await _db.Quotes - .IgnoreQueryFilters() - .Include(q => q.Customer) - .FirstOrDefaultAsync(q => q.ApprovalToken == token && !q.IsDeleted); + var quote = await _unitOfWork.Quotes.FirstOrDefaultAsync( + q => q.ApprovalToken == token && !q.IsDeleted, + ignoreQueryFilters: true, + q => q.Customer); if (quote == null) return base.View("InvalidToken"); - var company = await _db.Companies - .IgnoreQueryFilters() - .FirstOrDefaultAsync(c => c.Id == quote.CompanyId); + var company = await _unitOfWork.Companies.FirstOrDefaultAsync( + c => c.Id == quote.CompanyId, ignoreQueryFilters: true); - var prefs = await _db.CompanyPreferences - .IgnoreQueryFilters() - .FirstOrDefaultAsync(p => p.CompanyId == quote.CompanyId && !p.IsDeleted); + var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync( + p => p.CompanyId == quote.CompanyId && !p.IsDeleted, ignoreQueryFilters: true); var depositAmount = quote.RequiresDeposit && quote.DepositPercent > 0 ? Math.Round(quote.Total * (quote.DepositPercent / 100m), 2) : 0m; - // Only surface the deposit link if it's valid and not expired var depositToken = (!string.IsNullOrEmpty(quote.DepositPaymentLinkToken) && quote.DepositPaymentLinkExpiresAt > DateTime.UtcNow) ? quote.DepositPaymentLinkToken @@ -424,26 +380,16 @@ public class QuoteApprovalController : Controller // ----------------------------------------------------------------------- /// - /// Validates the approval token and returns the quote if it is still actionable, or an - /// IActionResult error view if not. Checks in order: token exists, not expired, not - /// already used (ApprovalTokenUsedAt != null), and not already in a terminal status - /// (approved, rejected, or converted). The terminal-status check is a belt-and-suspenders guard - /// for cases where the status was changed by staff inside the app after the token was issued but - /// before the customer clicked. Uses IgnoreQueryFilters because the customer has no - /// tenant context. Loads QuoteItems, QuoteStatus, and Customer eagerly to - /// avoid N+1 queries in . + /// Validates the approval token and returns the quote if still actionable. Uses + /// IQuoteRepository.GetByApprovalTokenAsync which loads with IgnoreQueryFilters (the portal is + /// unauthenticated — no tenant context exists on the request). /// private async Task<(Quote? quote, IActionResult? errorResult)> ValidateTokenAsync(string token) { if (string.IsNullOrWhiteSpace(token)) return (null, base.View("InvalidToken")); - var quote = await _db.Quotes - .IgnoreQueryFilters() - .Include(q => q.QuoteItems) - .Include(q => q.QuoteStatus) - .Include(q => q.Customer) - .FirstOrDefaultAsync(q => q.ApprovalToken == token && !q.IsDeleted); + var quote = await _unitOfWork.Quotes.GetByApprovalTokenAsync(token); if (quote == null) return (null, base.View("InvalidToken")); @@ -462,7 +408,6 @@ public class QuoteApprovalController : Controller return (null, base.View("AlreadyActed", actedModel)); } - // Also check terminal status if (quote.QuoteStatus != null && (quote.QuoteStatus.IsApprovedStatus || quote.QuoteStatus.IsRejectedStatus || quote.QuoteStatus.IsConvertedStatus)) { @@ -476,22 +421,16 @@ public class QuoteApprovalController : Controller } /// - /// Builds the QuoteApprovalViewModel from a validated quote. Fetches company details and - /// preferences (e.g. from-email address for the contact section) separately because they are - /// not eagerly loaded by . Soft-deleted line items are filtered - /// out so the customer only sees active items. Also maps all prospect contact fields so the - /// ConfirmDetails view can pre-populate them if the prospect has previously started and - /// returned to the approval page. + /// Builds the QuoteApprovalViewModel from a validated quote. Fetches company details and + /// preferences separately because they are not eagerly loaded by ValidateTokenAsync. /// private async Task BuildViewModelAsync(Quote quote, string token) { - var company = await _db.Companies - .IgnoreQueryFilters() - .FirstOrDefaultAsync(c => c.Id == quote.CompanyId); + var company = await _unitOfWork.Companies.FirstOrDefaultAsync( + c => c.Id == quote.CompanyId, ignoreQueryFilters: true); - var prefs = await _db.CompanyPreferences - .IgnoreQueryFilters() - .FirstOrDefaultAsync(p => p.CompanyId == quote.CompanyId && !p.IsDeleted); + var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync( + p => p.CompanyId == quote.CompanyId && !p.IsDeleted, ignoreQueryFilters: true); var items = (quote.QuoteItems ?? new List()) .Where(i => !i.IsDeleted) @@ -537,9 +476,7 @@ public class QuoteApprovalController : Controller } /// - /// Resolves the display name for the customer or prospect on a quote. Prefers the company name - /// for commercial customers, falls back to contact first/last name, then to prospect fields, and - /// finally to the generic "Valued Customer" sentinel so the view always has something to show. + /// Resolves the display name for the customer or prospect on a quote. /// private static string GetCustomerName(Quote quote) { diff --git a/src/PowderCoating.Web/Controllers/ReleaseNotesController.cs b/src/PowderCoating.Web/Controllers/ReleaseNotesController.cs index 220083b..adca6a5 100644 --- a/src/PowderCoating.Web/Controllers/ReleaseNotesController.cs +++ b/src/PowderCoating.Web/Controllers/ReleaseNotesController.cs @@ -1,9 +1,8 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; using PowderCoating.Application.Interfaces; using PowderCoating.Core.Entities; -using PowderCoating.Infrastructure.Data; +using PowderCoating.Core.Interfaces; using PowderCoating.Shared.Constants; namespace PowderCoating.Web.Controllers; @@ -19,17 +18,16 @@ namespace PowderCoating.Web.Controllers; /// public class ReleaseNotesController : Controller { - private readonly ApplicationDbContext _db; + private readonly IUnitOfWork _unitOfWork; private readonly IInAppNotificationService _inApp; - public ReleaseNotesController(ApplicationDbContext db, IInAppNotificationService inApp) + public ReleaseNotesController(IUnitOfWork unitOfWork, IInAppNotificationService inApp) { - _db = db; + _unitOfWork = unitOfWork; _inApp = inApp; } // ── Public: Changelog ──────────────────────────────────────────────────── - // Visible to all authenticated users /// /// Renders the public changelog — shows only published release notes ordered @@ -39,54 +37,39 @@ public class ReleaseNotesController : Controller [Authorize] public async Task Index() { - var notes = await _db.ReleaseNotes - .AsNoTracking() - .Where(r => r.IsPublished) + var notes = (await _unitOfWork.ReleaseNotes.FindAsync(r => r.IsPublished)) .OrderByDescending(r => r.ReleasedAt) .ThenByDescending(r => r.Id) - .ToListAsync(); - + .ToList(); return View(notes); } // ── SuperAdmin: Manage ─────────────────────────────────────────────────── /// - /// Returns the SuperAdmin management list of all release notes (published and - /// draft alike), ordered newest-first. Unlike there is no - /// IsPublished filter here so admins can see and edit drafts. + /// Returns the SuperAdmin management list of all release notes (published and draft alike), ordered newest-first. /// [Authorize(Policy = AppConstants.Policies.SuperAdminOnly)] public async Task Manage() { - var notes = await _db.ReleaseNotes - .AsNoTracking() + var notes = (await _unitOfWork.ReleaseNotes.GetAllAsync()) .OrderByDescending(r => r.ReleasedAt) .ThenByDescending(r => r.Id) - .ToListAsync(); - + .ToList(); return View(notes); } /// - /// Returns the Create form pre-populated with today's UTC date and the "Feature" - /// tag as sensible defaults for new entries, reducing data-entry friction. + /// Returns the Create form pre-populated with today's UTC date and the "Feature" tag as sensible defaults. /// [Authorize(Policy = AppConstants.Policies.SuperAdminOnly)] public IActionResult Create() { - return View(new ReleaseNote - { - ReleasedAt = DateTime.UtcNow, - Tag = "Feature" - }); + return View(new ReleaseNote { ReleasedAt = DateTime.UtcNow, Tag = "Feature" }); } /// - /// Persists a new release note and captures the creating SuperAdmin's identity - /// (CreatedByUserId / CreatedByUserName) for audit purposes. - /// New notes start unpublished by default unless the form explicitly sets - /// IsPublished = true, giving authors a chance to review before going live. + /// Persists a new release note. New notes start unpublished unless the form explicitly sets IsPublished = true. /// [HttpPost, ValidateAntiForgeryToken] [Authorize(Policy = AppConstants.Policies.SuperAdminOnly)] @@ -99,8 +82,8 @@ public class ReleaseNotesController : Controller model.CreatedByUserId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; model.CreatedByUserName = User.Identity?.Name; - _db.ReleaseNotes.Add(model); - await _db.SaveChangesAsync(); + await _unitOfWork.ReleaseNotes.AddAsync(model); + await _unitOfWork.CompleteAsync(); if (model.IsPublished) await NotifyAllTenantsAsync(model); @@ -109,22 +92,18 @@ public class ReleaseNotesController : Controller return RedirectToAction(nameof(Manage)); } - /// - /// Returns the Edit form loaded from the database by primary key. - /// + /// Returns the Edit form loaded from the database by primary key. [Authorize(Policy = AppConstants.Policies.SuperAdminOnly)] public async Task Edit(int id) { - var note = await _db.ReleaseNotes.FindAsync(id); + var note = await _unitOfWork.ReleaseNotes.GetByIdAsync(id); if (note == null) return NotFound(); return View(note); } /// - /// Applies the edited values to the tracked entity. Uses explicit field - /// mapping (rather than _db.Entry(model).State = Modified) to prevent - /// over-posting attacks and to ensure audit fields like CreatedAt and - /// CreatedByUserId are never overwritten. + /// Applies the edited values to the tracked entity using explicit field mapping to prevent + /// over-posting attacks and ensure audit fields are never overwritten. /// [HttpPost, ValidateAntiForgeryToken] [Authorize(Policy = AppConstants.Policies.SuperAdminOnly)] @@ -134,40 +113,37 @@ public class ReleaseNotesController : Controller if (!ModelState.IsValid) return View(model); - var note = await _db.ReleaseNotes.FindAsync(id); + var note = await _unitOfWork.ReleaseNotes.GetByIdAsync(id); if (note == null) return NotFound(); - note.Version = model.Version; - note.Title = model.Title; - note.Body = model.Body; - note.Tag = model.Tag; - note.IsPublished= model.IsPublished; - note.ReleasedAt = model.ReleasedAt; - note.UpdatedAt = DateTime.UtcNow; + note.Version = model.Version; + note.Title = model.Title; + note.Body = model.Body; + note.Tag = model.Tag; + note.IsPublished = model.IsPublished; + note.ReleasedAt = model.ReleasedAt; + note.UpdatedAt = DateTime.UtcNow; - await _db.SaveChangesAsync(); + await _unitOfWork.CompleteAsync(); TempData["Success"] = $"Release note v{note.Version} updated."; return RedirectToAction(nameof(Manage)); } /// - /// Toggles the published state of a release note. Publishing makes the note - /// immediately visible to all authenticated users via ; - /// un-publishing hides it without permanently deleting it so it can be revised - /// and re-published later. + /// Toggles the published state of a release note. Publishing fires an in-app notification to all tenants. /// [HttpPost, ValidateAntiForgeryToken] [Authorize(Policy = AppConstants.Policies.SuperAdminOnly)] public async Task TogglePublish(int id) { - var note = await _db.ReleaseNotes.FindAsync(id); + var note = await _unitOfWork.ReleaseNotes.GetByIdAsync(id); if (note == null) return NotFound(); var wasPublished = note.IsPublished; note.IsPublished = !note.IsPublished; note.UpdatedAt = DateTime.UtcNow; - await _db.SaveChangesAsync(); + await _unitOfWork.CompleteAsync(); if (note.IsPublished && !wasPublished) await NotifyAllTenantsAsync(note); @@ -179,29 +155,24 @@ public class ReleaseNotesController : Controller } /// - /// Permanently (hard) deletes a release note. This is intentional — release notes - /// are platform metadata, not business data, so they do not use soft delete. - /// Use to hide a note without permanent removal. + /// Permanently (hard) deletes a release note. Release notes are platform metadata and do not use soft delete. /// [HttpPost, ValidateAntiForgeryToken] [Authorize(Policy = AppConstants.Policies.SuperAdminOnly)] public async Task Delete(int id) { - var note = await _db.ReleaseNotes.FindAsync(id); + var note = await _unitOfWork.ReleaseNotes.GetByIdAsync(id); if (note == null) return NotFound(); - _db.ReleaseNotes.Remove(note); - await _db.SaveChangesAsync(); + await _unitOfWork.ReleaseNotes.DeleteAsync(note); + await _unitOfWork.CompleteAsync(); TempData["Success"] = $"Release note v{note.Version} deleted."; return RedirectToAction(nameof(Manage)); } /// - /// Fans out a "What's New" in-app notification to every active tenant company when - /// a release note transitions to published. Notification fires exactly once per - /// publish event — re-publishing after unpublishing will send a second notification, - /// which is intentional (the content may have changed). + /// Fans out a "What's New" in-app notification to every active tenant company when a release note is published. /// private Task NotifyAllTenantsAsync(ReleaseNote note) { diff --git a/src/PowderCoating.Web/Controllers/ReportsController.cs b/src/PowderCoating.Web/Controllers/ReportsController.cs index e3c5547..d0e9ddc 100644 --- a/src/PowderCoating.Web/Controllers/ReportsController.cs +++ b/src/PowderCoating.Web/Controllers/ReportsController.cs @@ -9,7 +9,6 @@ using PowderCoating.Application.Interfaces; using PowderCoating.Core.Enums; using PowderCoating.Core.Interfaces; using PowderCoating.Core.Entities; -using PowderCoating.Infrastructure.Data; using PowderCoating.Shared.Constants; using PowderCoating.Web.ViewModels.Reports; @@ -20,17 +19,19 @@ public class ReportsController : Controller { private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; - private readonly ApplicationDbContext _context; + private readonly IFinancialReportService _financialReports; + private readonly IOperationalReportService _operationalReports; private readonly IPdfService _pdfService; private readonly UserManager _userManager; private readonly IAccountingAiService _accountingAi; private readonly IAiUsageLogger _usageLogger; - public ReportsController(IUnitOfWork unitOfWork, ILogger logger, ApplicationDbContext context, IPdfService pdfService, UserManager userManager, IAccountingAiService accountingAi, IAiUsageLogger usageLogger) + public ReportsController(IUnitOfWork unitOfWork, ILogger logger, IFinancialReportService financialReports, IOperationalReportService operationalReports, IPdfService pdfService, UserManager userManager, IAccountingAiService accountingAi, IAiUsageLogger usageLogger) { _unitOfWork = unitOfWork; _logger = logger; - _context = context; + _financialReports = financialReports; + _operationalReports = operationalReports; _pdfService = pdfService; _userManager = userManager; _accountingAi = accountingAi; @@ -493,11 +494,7 @@ public class ReportsController : Controller .ToList(); // === EXPENSE / AP ANALYTICS === - var allBills = await _context.Bills - .Include(b => b.Vendor) - .Include(b => b.Payments.Where(p => !p.IsDeleted)) - .Where(b => !b.IsDeleted && b.Status != BillStatus.Voided) - .ToListAsync(); + var allBills = await _operationalReports.GetActiveBillsAsync(); var totalBilled = allBills.Sum(b => b.Total); var totalBillsPaid = allBills.Sum(b => b.AmountPaid); @@ -536,10 +533,7 @@ public class ReportsController : Controller .ToList(); // Expenses by account - var allExpenses = await _context.Expenses - .Include(e => e.ExpenseAccount) - .Where(e => !e.IsDeleted) - .ToListAsync(); + var allExpenses = await _operationalReports.GetAllExpensesAsync(); var expensesByAccount = allExpenses .Where(e => e.ExpenseAccount != null) @@ -664,10 +658,7 @@ public class ReportsController : Controller .ToList(); // === JOB CYCLE TIME === - var allStatusHistory = await _context.JobStatusHistory - .Include(h => h.FromStatus) - .Include(h => h.ToStatus) - .ToListAsync(); + var allStatusHistory = await _operationalReports.GetAllJobStatusHistoryAsync(); var historyByJob = allStatusHistory .GroupBy(h => h.JobId) @@ -1002,102 +993,10 @@ public class ReportsController : Controller public async Task ProfitAndLoss(DateTime? from, DateTime? to) { if (!AllowAccounting()) return RedirectToAction(nameof(Landing)); - var fromDate = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date; - var toDate = (to ?? DateTime.Today).Date; - var toEnd = toDate.AddDays(1).AddTicks(-1); - - var companyName = await GetCompanyNameAsync(); - - // ── Revenue: InvoiceItems posted to revenue accounts ────────────────── - var revenueByAccount = await _context.InvoiceItems - .Where(ii => ii.RevenueAccountId != null - && ii.Invoice.Status != InvoiceStatus.Draft - && ii.Invoice.Status != InvoiceStatus.Voided - && ii.Invoice.InvoiceDate >= fromDate && ii.Invoice.InvoiceDate <= toEnd) - .GroupBy(ii => ii.RevenueAccountId!.Value) - .Select(g => new { AccountId = g.Key, Amount = g.Sum(ii => ii.TotalPrice) }) - .ToListAsync(); - - // Unlinked invoice totals (items without a revenue account) → lump into a default "Sales" bucket - var unlinkedRevenue = await _context.InvoiceItems - .Where(ii => ii.RevenueAccountId == null - && ii.Invoice.Status != InvoiceStatus.Draft - && ii.Invoice.Status != InvoiceStatus.Voided - && ii.Invoice.InvoiceDate >= fromDate && ii.Invoice.InvoiceDate <= toEnd) - .SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0; - - var revenueAccounts = await _context.Accounts - .Where(a => a.AccountType == AccountType.Revenue && a.IsActive) - .ToDictionaryAsync(a => a.Id); - - var revenueLines = revenueByAccount - .Where(r => revenueAccounts.ContainsKey(r.AccountId)) - .Select(r => new FinancialReportLine - { - AccountId = r.AccountId, - AccountNumber = revenueAccounts[r.AccountId].AccountNumber, - AccountName = revenueAccounts[r.AccountId].Name, - Amount = r.Amount - }) - .OrderBy(l => l.AccountNumber) - .ToList(); - - if (unlinkedRevenue > 0) - revenueLines.Add(new FinancialReportLine { AccountNumber = "—", AccountName = "Other Sales (unclassified)", Amount = unlinkedRevenue }); - - // ── COGS & Expenses: Expenses + BillLineItems by account type ───────── - // Direct expenses - var directByAccount = await _context.Expenses - .Where(e => e.Date >= fromDate && e.Date <= toEnd) - .GroupBy(e => e.ExpenseAccountId) - .Select(g => new { AccountId = g.Key, Amount = g.Sum(e => e.Amount) }) - .ToListAsync(); - - // Bill line items (skip lines with no account — QB-imported without account mapping) - var billLinesByAccount = await _context.BillLineItems - .Where(bli => bli.AccountId != null - && bli.Bill.Status != BillStatus.Draft - && bli.Bill.Status != BillStatus.Voided - && bli.Bill.BillDate >= fromDate && bli.Bill.BillDate <= toEnd) - .GroupBy(bli => bli.AccountId!.Value) - .Select(g => new { AccountId = g.Key, Amount = g.Sum(bli => bli.Amount) }) - .ToListAsync(); - - // Merge the two expense sources per account - var expenseAmounts = new Dictionary(); - foreach (var e in directByAccount) - expenseAmounts[e.AccountId] = expenseAmounts.GetValueOrDefault(e.AccountId) + e.Amount; - foreach (var b in billLinesByAccount) - expenseAmounts[b.AccountId] = expenseAmounts.GetValueOrDefault(b.AccountId) + b.Amount; - - var expAccounts = await _context.Accounts - .Where(a => (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods) && a.IsActive) - .ToDictionaryAsync(a => a.Id); - - var cogsLines = new List(); - var expenseLines = new List(); - - foreach (var (accountId, amount) in expenseAmounts.OrderBy(kv => expAccounts.ContainsKey(kv.Key) ? expAccounts[kv.Key].AccountNumber : "999")) - { - if (!expAccounts.TryGetValue(accountId, out var acct)) continue; - var line = new FinancialReportLine { AccountId = accountId, AccountNumber = acct.AccountNumber, AccountName = acct.Name, Amount = amount }; - if (acct.AccountType == AccountType.CostOfGoods) cogsLines.Add(line); - else expenseLines.Add(line); - } - - var dto = new ProfitAndLossDto - { - From = fromDate, - To = toDate, - CompanyName = companyName, - RevenueLines = revenueLines, - TotalRevenue = revenueLines.Sum(l => l.Amount), - CogsLines = cogsLines, - TotalCogs = cogsLines.Sum(l => l.Amount), - ExpenseLines = expenseLines, - TotalExpenses = expenseLines.Sum(l => l.Amount), - }; - + var fromDate = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date; + var toDate = (to ?? DateTime.Today).Date; + var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0; + var dto = await _financialReports.GetProfitAndLossAsync(companyId, fromDate, toDate); return View(dto); } @@ -1116,157 +1015,9 @@ public class ReportsController : Controller public async Task BalanceSheet(DateTime? asOf) { if (!AllowAccounting()) return RedirectToAction(nameof(Landing)); - var asOfDate = (asOf ?? DateTime.Today).Date; - var asOfEnd = asOfDate.AddDays(1).AddTicks(-1); - - var companyName = await GetCompanyNameAsync(); - - // ── Pre-compute balance contributions per account (batch queries) ────── - - // Asset: payments deposited INTO account (DEBIT) — exclude voided/written-off invoices - var depositsByAcct = await _context.Payments - .Where(p => p.PaymentDate <= asOfEnd && p.DepositAccountId != null - && p.Invoice.Status != InvoiceStatus.Voided - && p.Invoice.Status != InvoiceStatus.WrittenOff) - .GroupBy(p => p.DepositAccountId!.Value) - .Select(g => new { Id = g.Key, Amount = g.Sum(p => p.Amount) }) - .ToDictionaryAsync(g => g.Id, g => g.Amount); - - // Asset: expenses paid FROM account (CREDIT) - var expFromByAcct = await _context.Expenses - .Where(e => e.Date <= asOfEnd) - .GroupBy(e => e.PaymentAccountId) - .Select(g => new { Id = g.Key, Amount = g.Sum(e => e.Amount) }) - .ToDictionaryAsync(g => g.Id, g => g.Amount); - - // Asset: bill payments FROM account (CREDIT) - var bpFromByAcct = await _context.BillPayments - .Where(bp => bp.PaymentDate <= asOfEnd) - .GroupBy(bp => bp.BankAccountId) - .Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) }) - .ToDictionaryAsync(g => g.Id, g => g.Amount); - - // Liability: bills posted to AP account (CREDIT) - var billsByApAcct = await _context.Bills - .Where(b => b.Status != BillStatus.Draft && b.Status != BillStatus.Voided && b.BillDate <= asOfEnd) - .GroupBy(b => b.APAccountId) - .Select(g => new { Id = g.Key, Amount = g.Sum(b => b.Total) }) - .ToDictionaryAsync(g => g.Id, g => g.Amount); - - // Liability: bill payments reducing AP (DEBIT) - var bpByApAcct = await _context.BillPayments - .Where(bp => bp.PaymentDate <= asOfEnd) - .GroupBy(bp => bp.Bill.APAccountId) - .Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) }) - .ToDictionaryAsync(g => g.Id, g => g.Amount); - - // Liability: sales tax payable (CREDIT) - var taxByAcct = await _context.Invoices - .Where(i => i.SalesTaxAccountId != null && i.TaxAmount > 0 - && i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided - && i.InvoiceDate <= asOfEnd) - .GroupBy(i => i.SalesTaxAccountId!.Value) - .Select(g => new { Id = g.Key, Amount = g.Sum(i => i.TaxAmount) }) - .ToDictionaryAsync(g => g.Id, g => g.Amount); - - // AR total (used for AR sub-type accounts) - var arDebits = await _context.Invoices.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.InvoiceDate <= asOfEnd).SumAsync(i => (decimal?)i.Total) ?? 0; - var arCredits = await _context.Payments.Where(p => p.PaymentDate <= asOfEnd - && p.Invoice.Status != InvoiceStatus.Voided - && p.Invoice.Status != InvoiceStatus.WrittenOff).SumAsync(p => (decimal?)p.Amount) ?? 0; - - // ── Retained earnings = net P&L from inception ──────────────────────── - var lifetimeRevenue = await _context.InvoiceItems - .Where(ii => ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.InvoiceDate <= asOfEnd) - .SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0; - var lifetimeCogs = await _context.Expenses.Where(e => e.Date <= asOfEnd).Include(e => e.ExpenseAccount).SumAsync(e => (decimal?)e.Amount) ?? 0; - var lifetimeBillCosts = await _context.BillLineItems.Where(bli => bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Voided && bli.Bill.BillDate <= asOfEnd).SumAsync(bli => (decimal?)bli.Amount) ?? 0; - var retainedEarnings = lifetimeRevenue - lifetimeCogs - lifetimeBillCosts; - - // ── Compute balance for each account ────────────────────────────────── - decimal ComputeBalance(Account a) - { - bool normalDebit = a.AccountType == AccountType.Asset; - decimal debits = 0, credits = 0; - - if (a.AccountSubType == AccountSubType.AccountsReceivable) - { - debits = arDebits; credits = arCredits; - } - else if (a.AccountSubType == AccountSubType.AccountsPayable) - { - credits = billsByApAcct.GetValueOrDefault(a.Id); - debits = bpByApAcct.GetValueOrDefault(a.Id); - } - else - { - debits += depositsByAcct.GetValueOrDefault(a.Id); - credits += expFromByAcct.GetValueOrDefault(a.Id); - credits += bpFromByAcct.GetValueOrDefault(a.Id); - credits += taxByAcct.GetValueOrDefault(a.Id); - } - - decimal opening = (a.OpeningBalanceDate == null || a.OpeningBalanceDate.Value.Date <= asOfDate) - ? a.OpeningBalance : 0; - decimal net = normalDebit ? debits - credits : credits - debits; - return opening + net; - } - - FinancialReportLine ToLine(Account a) => new() - { - AccountId = a.Id, - AccountNumber = a.AccountNumber, - AccountName = a.Name, - Amount = ComputeBalance(a) - }; - - // Load accounts by type - var accounts = await _context.Accounts.Where(a => a.IsActive).OrderBy(a => a.AccountNumber).ToListAsync(); - - var assetAccts = accounts.Where(a => a.AccountType == AccountType.Asset).ToList(); - var liabilityAccts = accounts.Where(a => a.AccountType == AccountType.Liability).ToList(); - var equityAccts = accounts.Where(a => a.AccountType == AccountType.Equity).ToList(); - - var currentAssets = assetAccts - .Where(a => a.AccountSubType is AccountSubType.Checking or AccountSubType.Savings - or AccountSubType.AccountsReceivable or AccountSubType.Inventory or AccountSubType.OtherCurrentAsset) - .Select(ToLine).ToList(); - var fixedAssets = assetAccts - .Where(a => a.AccountSubType == AccountSubType.FixedAsset) - .Select(ToLine).ToList(); - var otherAssets = assetAccts - .Where(a => a.AccountSubType == AccountSubType.OtherAsset) - .Select(ToLine).ToList(); - - var currentLiabilities = liabilityAccts - .Where(a => a.AccountSubType is AccountSubType.AccountsPayable or AccountSubType.CreditCard or AccountSubType.OtherCurrentLiability) - .Select(ToLine).ToList(); - var longTermLiabilities = liabilityAccts - .Where(a => a.AccountSubType == AccountSubType.LongTermLiability) - .Select(ToLine).ToList(); - - var equityLines = equityAccts.Select(ToLine).ToList(); - - var totalAssets = currentAssets.Sum(l => l.Amount) + fixedAssets.Sum(l => l.Amount) + otherAssets.Sum(l => l.Amount); - var totalLiabilities = currentLiabilities.Sum(l => l.Amount) + longTermLiabilities.Sum(l => l.Amount); - var totalEquity = equityLines.Sum(l => l.Amount) + retainedEarnings; - - var dto = new BalanceSheetDto - { - AsOf = asOfDate, - CompanyName = companyName, - CurrentAssets = currentAssets, - FixedAssets = fixedAssets, - OtherAssets = otherAssets, - TotalAssets = totalAssets, - CurrentLiabilities = currentLiabilities, - LongTermLiabilities = longTermLiabilities, - TotalLiabilities = totalLiabilities, - EquityLines = equityLines, - RetainedEarnings = retainedEarnings, - TotalEquity = totalEquity, - }; - + var asOfDate = (asOf ?? DateTime.Today).Date; + var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0; + var dto = await _financialReports.GetBalanceSheetAsync(companyId, asOfDate); return View(dto); } @@ -1282,85 +1033,9 @@ public class ReportsController : Controller public async Task ArAging(DateTime? asOf) { if (!AllowAccounting()) return RedirectToAction(nameof(Landing)); - var asOfDate = (asOf ?? DateTime.Today).Date; - var asOfEnd = asOfDate.AddDays(1).AddTicks(-1); - - var companyName = await GetCompanyNameAsync(); - - var openInvoices = await _context.Invoices - .Include(i => i.Customer) - .Where(i => i.Status != InvoiceStatus.Draft - && i.Status != InvoiceStatus.Voided - && i.Status != InvoiceStatus.Paid - && i.InvoiceDate <= asOfEnd - && (i.Total - i.AmountPaid - i.CreditApplied - i.GiftCertificateRedeemed) > 0) - .OrderBy(i => i.Customer!.CompanyName) - .ThenBy(i => i.DueDate) - .ToListAsync(); - - var customerGroups = openInvoices - .GroupBy(i => new { i.CustomerId, i.Customer!.CompanyName, i.Customer.ContactFirstName, i.Customer.ContactLastName, i.Customer.IsCommercial }); - - static string AgingBucket(int daysOverdue) => daysOverdue switch - { - <= 0 => "current", - <= 30 => "1-30", - <= 60 => "31-60", - <= 90 => "61-90", - _ => "90+" - }; - - var customers = new List(); - - foreach (var grp in customerGroups) - { - var customerName = grp.Key.IsCommercial - ? grp.Key.CompanyName - : $"{grp.Key.ContactFirstName} {grp.Key.ContactLastName}".Trim(); - - var custDto = new ArAgingCustomerDto { CustomerId = grp.Key.CustomerId, CustomerName = customerName }; - - foreach (var inv in grp) - { - var balance = inv.BalanceDue; - var daysOverdue = inv.DueDate.HasValue ? (int)(asOfDate - inv.DueDate.Value.Date).TotalDays : 0; - var bucket = AgingBucket(daysOverdue); - - custDto.Invoices.Add(new ArAgingInvoiceDto - { - InvoiceId = inv.Id, - InvoiceNumber = inv.InvoiceNumber, - InvoiceDate = inv.InvoiceDate, - DueDate = inv.DueDate, - BalanceDue = balance, - DaysOverdue = daysOverdue - }); - - switch (bucket) - { - case "current": custDto.TotalCurrent += balance; break; - case "1-30": custDto.Total1to30 += balance; break; - case "31-60": custDto.Total31to60 += balance; break; - case "61-90": custDto.Total61to90 += balance; break; - default: custDto.TotalOver90 += balance; break; - } - } - - customers.Add(custDto); - } - - var dto = new ArAgingReportDto - { - AsOf = asOfDate, - CompanyName = companyName, - Customers = customers.OrderByDescending(c => c.TotalBalance).ToList(), - TotalCurrent = customers.Sum(c => c.TotalCurrent), - Total1to30 = customers.Sum(c => c.Total1to30), - Total31to60 = customers.Sum(c => c.Total31to60), - Total61to90 = customers.Sum(c => c.Total61to90), - TotalOver90 = customers.Sum(c => c.TotalOver90), - }; - + var asOfDate = (asOf ?? DateTime.Today).Date; + var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0; + var dto = await _financialReports.GetArAgingAsync(companyId, asOfDate); return View(dto); } @@ -1375,90 +1050,10 @@ public class ReportsController : Controller // GET: /Reports/SalesAndIncome public async Task SalesAndIncome(DateTime? from, DateTime? to) { - var fromDate = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date; - var toDate = (to ?? DateTime.Today).Date; - var toEnd = toDate.AddDays(1).AddTicks(-1); - - var companyName = await GetCompanyNameAsync(); - - var invoices = await _context.Invoices - .Include(i => i.Customer) - .Include(i => i.Payments) - .Where(i => i.Status != InvoiceStatus.Draft - && i.Status != InvoiceStatus.Voided - && i.InvoiceDate >= fromDate && i.InvoiceDate <= toEnd) - .OrderBy(i => i.InvoiceDate) - .ToListAsync(); - - // Payments collected within the period (may differ from invoice dates) - var collectedInPeriod = await _context.Payments - .Where(p => p.PaymentDate >= fromDate && p.PaymentDate <= toEnd) - .SumAsync(p => (decimal?)p.Amount) ?? 0; - - // By customer - var byCustomer = invoices - .GroupBy(i => new { i.CustomerId, Name = i.Customer!.IsCommercial - ? i.Customer.CompanyName - : $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim() }) - .Select(g => new SalesByCustomerDto - { - CustomerId = g.Key.CustomerId, - CustomerName = g.Key.Name, - InvoiceCount = g.Count(), - TotalInvoiced = g.Sum(i => i.Total), - TotalPaid = g.Sum(i => i.AmountPaid), - BalanceDue = g.Sum(i => i.BalanceDue), - }) - .OrderByDescending(c => c.TotalInvoiced) - .ToList(); - - // By month - var byMonth = invoices - .GroupBy(i => new { i.InvoiceDate.Year, i.InvoiceDate.Month }) - .Select(g => new SalesByMonthDto - { - Year = g.Key.Year, - Month = g.Key.Month, - Label = new DateTime(g.Key.Year, g.Key.Month, 1).ToString("MMM yyyy"), - TotalInvoiced = g.Sum(i => i.Total), - TotalCollected = g.Sum(i => i.AmountPaid), - InvoiceCount = g.Count(), - }) - .OrderBy(m => m.Year).ThenBy(m => m.Month) - .ToList(); - - // Invoice detail lines - var invoiceLines = invoices.Select(i => new SalesInvoiceLineDto - { - InvoiceId = i.Id, - InvoiceNumber = i.InvoiceNumber, - CustomerName = i.Customer!.IsCommercial ? i.Customer.CompanyName : $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim(), - InvoiceDate = i.InvoiceDate, - DueDate = i.DueDate, - Status = i.Status.ToString(), - SubTotal = i.SubTotal, - TaxAmount = i.TaxAmount, - Total = i.Total, - AmountPaid = i.AmountPaid, - BalanceDue = i.BalanceDue, - }).ToList(); - - var dto = new SalesIncomeReportDto - { - From = fromDate, - To = toDate, - CompanyName = companyName, - TotalInvoiced = invoices.Sum(i => i.Total), - TotalCollected = collectedInPeriod, - TotalTax = invoices.Sum(i => i.TaxAmount), - TotalDiscount = invoices.Sum(i => i.DiscountAmount), - InvoiceCount = invoices.Count, - CustomerCount = invoices.Select(i => i.CustomerId).Distinct().Count(), - ByCustomer = byCustomer, - ByMonth = byMonth, - Invoices = invoiceLines, - }; - + var fromDate = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date; + var toDate = (to ?? DateTime.Today).Date; + var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0; + var dto = await _financialReports.GetSalesAndIncomeAsync(companyId, fromDate, toDate); return View(dto); } @@ -1476,64 +1071,10 @@ public class ReportsController : Controller public async Task ProfitAndLossPdf(DateTime? from, DateTime? to, bool inline = false) { if (!AllowAccounting()) return RedirectToAction(nameof(Landing)); - var fromDate = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date; - var toDate = (to ?? DateTime.Today).Date; - var toEnd = toDate.AddDays(1).AddTicks(-1); - var companyName = await GetCompanyNameAsync(); - - var revenueByAccount = await _context.InvoiceItems - .Where(ii => ii.RevenueAccountId != null - && ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided - && ii.Invoice.InvoiceDate >= fromDate && ii.Invoice.InvoiceDate <= toEnd) - .GroupBy(ii => ii.RevenueAccountId!.Value) - .Select(g => new { AccountId = g.Key, Amount = g.Sum(ii => ii.TotalPrice) }) - .ToListAsync(); - - var unlinkedRevenue = await _context.InvoiceItems - .Where(ii => ii.RevenueAccountId == null - && ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided - && ii.Invoice.InvoiceDate >= fromDate && ii.Invoice.InvoiceDate <= toEnd) - .SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0; - - var revenueAccounts = await _context.Accounts.Where(a => a.AccountType == AccountType.Revenue && a.IsActive).ToDictionaryAsync(a => a.Id); - - var revenueLines = revenueByAccount - .Where(r => revenueAccounts.ContainsKey(r.AccountId)) - .Select(r => new FinancialReportLine { AccountId = r.AccountId, AccountNumber = revenueAccounts[r.AccountId].AccountNumber, AccountName = revenueAccounts[r.AccountId].Name, Amount = r.Amount }) - .OrderBy(l => l.AccountNumber).ToList(); - - if (unlinkedRevenue > 0) - revenueLines.Add(new FinancialReportLine { AccountNumber = "—", AccountName = "Other Sales (unclassified)", Amount = unlinkedRevenue }); - - var directByAccount = await _context.Expenses.Where(e => e.Date >= fromDate && e.Date <= toEnd) - .GroupBy(e => e.ExpenseAccountId).Select(g => new { AccountId = g.Key, Amount = g.Sum(e => e.Amount) }).ToListAsync(); - - var billLinesByAccount = await _context.BillLineItems - .Where(bli => bli.AccountId != null && bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Voided && bli.Bill.BillDate >= fromDate && bli.Bill.BillDate <= toEnd) - .GroupBy(bli => bli.AccountId!.Value).Select(g => new { AccountId = g.Key, Amount = g.Sum(bli => bli.Amount) }).ToListAsync(); - - var expenseAmounts = new Dictionary(); - foreach (var e in directByAccount) expenseAmounts[e.AccountId] = expenseAmounts.GetValueOrDefault(e.AccountId) + e.Amount; - foreach (var b in billLinesByAccount) expenseAmounts[b.AccountId] = expenseAmounts.GetValueOrDefault(b.AccountId) + b.Amount; - - var expAccounts = await _context.Accounts.Where(a => (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods) && a.IsActive).ToDictionaryAsync(a => a.Id); - - var cogsLines = new List(); var expenseLines = new List(); - foreach (var (accountId, amount) in expenseAmounts.OrderBy(kv => expAccounts.ContainsKey(kv.Key) ? expAccounts[kv.Key].AccountNumber : "999")) - { - if (!expAccounts.TryGetValue(accountId, out var acct)) continue; - var line = new FinancialReportLine { AccountId = accountId, AccountNumber = acct.AccountNumber, AccountName = acct.Name, Amount = amount }; - if (acct.AccountType == AccountType.CostOfGoods) cogsLines.Add(line); else expenseLines.Add(line); - } - - var dto = new ProfitAndLossDto - { - From = fromDate, To = toDate, CompanyName = companyName, - RevenueLines = revenueLines, TotalRevenue = revenueLines.Sum(l => l.Amount), - CogsLines = cogsLines, TotalCogs = cogsLines.Sum(l => l.Amount), - ExpenseLines = expenseLines, TotalExpenses = expenseLines.Sum(l => l.Amount), - }; - + var fromDate = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date; + var toDate = (to ?? DateTime.Today).Date; + var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0; + var dto = await _financialReports.GetProfitAndLossAsync(companyId, fromDate, toDate); var pdfBytes = await _pdfService.GenerateProfitAndLossPdfAsync(dto); return inline ? File(pdfBytes, "application/pdf") : File(pdfBytes, "application/pdf", $"ProfitAndLoss-{fromDate:yyyyMMdd}-{toDate:yyyyMMdd}.pdf"); } @@ -1546,75 +1087,9 @@ public class ReportsController : Controller public async Task BalanceSheetPdf(DateTime? asOf, bool inline = false) { if (!AllowAccounting()) return RedirectToAction(nameof(Landing)); - var asOfDate = (asOf ?? DateTime.Today).Date; - var asOfEnd = asOfDate.AddDays(1).AddTicks(-1); - var companyName = await GetCompanyNameAsync(); - - var depositsByAcct = await _context.Payments.Where(p => p.PaymentDate <= asOfEnd && p.DepositAccountId != null) - .GroupBy(p => p.DepositAccountId!.Value).Select(g => new { Id = g.Key, Amount = g.Sum(p => p.Amount) }).ToDictionaryAsync(g => g.Id, g => g.Amount); - - var expFromByAcct = await _context.Expenses.Where(e => e.Date <= asOfEnd) - .GroupBy(e => e.PaymentAccountId).Select(g => new { Id = g.Key, Amount = g.Sum(e => e.Amount) }).ToDictionaryAsync(g => g.Id, g => g.Amount); - - var bpFromByAcct = await _context.BillPayments.Where(bp => bp.PaymentDate <= asOfEnd) - .GroupBy(bp => bp.BankAccountId).Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) }).ToDictionaryAsync(g => g.Id, g => g.Amount); - - var billsByApAcct = await _context.Bills.Where(b => b.Status != BillStatus.Draft && b.Status != BillStatus.Voided && b.BillDate <= asOfEnd) - .GroupBy(b => b.APAccountId).Select(g => new { Id = g.Key, Amount = g.Sum(b => b.Total) }).ToDictionaryAsync(g => g.Id, g => g.Amount); - - var bpByApAcct = await _context.BillPayments.Where(bp => bp.PaymentDate <= asOfEnd) - .GroupBy(bp => bp.Bill.APAccountId).Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) }).ToDictionaryAsync(g => g.Id, g => g.Amount); - - var taxByAcct = await _context.Invoices.Where(i => i.SalesTaxAccountId != null && i.TaxAmount > 0 && i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.InvoiceDate <= asOfEnd) - .GroupBy(i => i.SalesTaxAccountId!.Value).Select(g => new { Id = g.Key, Amount = g.Sum(i => i.TaxAmount) }).ToDictionaryAsync(g => g.Id, g => g.Amount); - - var arDebits = await _context.Invoices.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.InvoiceDate <= asOfEnd).SumAsync(i => (decimal?)i.Total) ?? 0; - var arCredits = await _context.Payments.Where(p => p.PaymentDate <= asOfEnd).SumAsync(p => (decimal?)p.Amount) ?? 0; - - var lifetimeRevenue = await _context.InvoiceItems.Where(ii => ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.InvoiceDate <= asOfEnd).SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0; - var lifetimeCogs = await _context.Expenses.Where(e => e.Date <= asOfEnd).Include(e => e.ExpenseAccount).SumAsync(e => (decimal?)e.Amount) ?? 0; - var lifetimeBillCosts = await _context.BillLineItems.Where(bli => bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Voided && bli.Bill.BillDate <= asOfEnd).SumAsync(bli => (decimal?)bli.Amount) ?? 0; - var retainedEarnings = lifetimeRevenue - lifetimeCogs - lifetimeBillCosts; - - var accounts = await _context.Accounts.Where(a => a.IsActive).OrderBy(a => a.AccountNumber).ToListAsync(); - - decimal ComputeBalance(Account a) - { - bool normalDebit = a.AccountType == AccountType.Asset; - decimal debits = 0, credits = 0; - if (a.AccountSubType == AccountSubType.AccountsReceivable) { debits = arDebits; credits = arCredits; } - else if (a.AccountSubType == AccountSubType.AccountsPayable) { credits = billsByApAcct.GetValueOrDefault(a.Id); debits = bpByApAcct.GetValueOrDefault(a.Id); } - else { debits += depositsByAcct.GetValueOrDefault(a.Id); credits += expFromByAcct.GetValueOrDefault(a.Id); credits += bpFromByAcct.GetValueOrDefault(a.Id); credits += taxByAcct.GetValueOrDefault(a.Id); } - decimal opening = (a.OpeningBalanceDate == null || a.OpeningBalanceDate.Value.Date <= asOfDate) ? a.OpeningBalance : 0; - decimal net = normalDebit ? debits - credits : credits - debits; - return opening + net; - } - - FinancialReportLine ToLine(Account a) => new() { AccountId = a.Id, AccountNumber = a.AccountNumber, AccountName = a.Name, Amount = ComputeBalance(a) }; - - var assetAccts = accounts.Where(a => a.AccountType == AccountType.Asset).ToList(); - var liabilityAccts = accounts.Where(a => a.AccountType == AccountType.Liability).ToList(); - var equityAccts = accounts.Where(a => a.AccountType == AccountType.Equity).ToList(); - - var currentAssets = assetAccts.Where(a => a.AccountSubType is AccountSubType.Checking or AccountSubType.Savings or AccountSubType.AccountsReceivable or AccountSubType.Inventory or AccountSubType.OtherCurrentAsset).Select(ToLine).ToList(); - var fixedAssets = assetAccts.Where(a => a.AccountSubType == AccountSubType.FixedAsset).Select(ToLine).ToList(); - var otherAssets = assetAccts.Where(a => a.AccountSubType == AccountSubType.OtherAsset).Select(ToLine).ToList(); - var currentLiabilities = liabilityAccts.Where(a => a.AccountSubType is AccountSubType.AccountsPayable or AccountSubType.CreditCard or AccountSubType.OtherCurrentLiability).Select(ToLine).ToList(); - var longTermLiabilities = liabilityAccts.Where(a => a.AccountSubType == AccountSubType.LongTermLiability).Select(ToLine).ToList(); - var equityLines = equityAccts.Select(ToLine).ToList(); - - var totalAssets = currentAssets.Sum(l => l.Amount) + fixedAssets.Sum(l => l.Amount) + otherAssets.Sum(l => l.Amount); - var totalLiabilities = currentLiabilities.Sum(l => l.Amount) + longTermLiabilities.Sum(l => l.Amount); - var totalEquity = equityLines.Sum(l => l.Amount) + retainedEarnings; - - var dto = new BalanceSheetDto - { - AsOf = asOfDate, CompanyName = companyName, - CurrentAssets = currentAssets, FixedAssets = fixedAssets, OtherAssets = otherAssets, TotalAssets = totalAssets, - CurrentLiabilities = currentLiabilities, LongTermLiabilities = longTermLiabilities, TotalLiabilities = totalLiabilities, - EquityLines = equityLines, RetainedEarnings = retainedEarnings, TotalEquity = totalEquity, - }; - + var asOfDate = (asOf ?? DateTime.Today).Date; + var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0; + var dto = await _financialReports.GetBalanceSheetAsync(companyId, asOfDate); var pdfBytes = await _pdfService.GenerateBalanceSheetPdfAsync(dto); return inline ? File(pdfBytes, "application/pdf") : File(pdfBytes, "application/pdf", $"BalanceSheet-{asOfDate:yyyyMMdd}.pdf"); } @@ -1627,46 +1102,9 @@ public class ReportsController : Controller public async Task ArAgingPdf(DateTime? asOf, bool inline = false) { if (!AllowAccounting()) return RedirectToAction(nameof(Landing)); - var asOfDate = (asOf ?? DateTime.Today).Date; - var asOfEnd = asOfDate.AddDays(1).AddTicks(-1); - var companyName = await GetCompanyNameAsync(); - - var openInvoices = await _context.Invoices - .Include(i => i.Customer) - .Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided - && i.Status != InvoiceStatus.Paid && i.InvoiceDate <= asOfEnd - && (i.Total - i.AmountPaid - i.CreditApplied - i.GiftCertificateRedeemed) > 0) - .OrderBy(i => i.Customer!.CompanyName).ThenBy(i => i.DueDate) - .ToListAsync(); - - var customerGroups = openInvoices.GroupBy(i => new { i.CustomerId, i.Customer!.CompanyName, i.Customer.ContactFirstName, i.Customer.ContactLastName, i.Customer.IsCommercial }); - - static string AgingBucket(int d) => d switch { <= 0 => "current", <= 30 => "1-30", <= 60 => "31-60", <= 90 => "61-90", _ => "90+" }; - - var customers = new List(); - foreach (var grp in customerGroups) - { - var customerName = grp.Key.IsCommercial ? grp.Key.CompanyName : $"{grp.Key.ContactFirstName} {grp.Key.ContactLastName}".Trim(); - var custDto = new ArAgingCustomerDto { CustomerId = grp.Key.CustomerId, CustomerName = customerName }; - foreach (var inv in grp) - { - var balance = inv.BalanceDue; - var daysOverdue = inv.DueDate.HasValue ? (int)(asOfDate - inv.DueDate.Value.Date).TotalDays : 0; - custDto.Invoices.Add(new ArAgingInvoiceDto { InvoiceId = inv.Id, InvoiceNumber = inv.InvoiceNumber, InvoiceDate = inv.InvoiceDate, DueDate = inv.DueDate, BalanceDue = balance, DaysOverdue = daysOverdue }); - switch (AgingBucket(daysOverdue)) { case "current": custDto.TotalCurrent += balance; break; case "1-30": custDto.Total1to30 += balance; break; case "31-60": custDto.Total31to60 += balance; break; case "61-90": custDto.Total61to90 += balance; break; default: custDto.TotalOver90 += balance; break; } - } - customers.Add(custDto); - } - - var dto = new ArAgingReportDto - { - AsOf = asOfDate, CompanyName = companyName, - Customers = customers.OrderByDescending(c => c.TotalBalance).ToList(), - TotalCurrent = customers.Sum(c => c.TotalCurrent), Total1to30 = customers.Sum(c => c.Total1to30), - Total31to60 = customers.Sum(c => c.Total31to60), Total61to90 = customers.Sum(c => c.Total61to90), - TotalOver90 = customers.Sum(c => c.TotalOver90), - }; - + var asOfDate = (asOf ?? DateTime.Today).Date; + var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0; + var dto = await _financialReports.GetArAgingAsync(companyId, asOfDate); var pdfBytes = await _pdfService.GenerateArAgingPdfAsync(dto); return inline ? File(pdfBytes, "application/pdf") : File(pdfBytes, "application/pdf", $"AR-Aging-{asOfDate:yyyyMMdd}.pdf"); } @@ -1678,48 +1116,10 @@ public class ReportsController : Controller // GET: /Reports/SalesAndIncomePdf public async Task SalesAndIncomePdf(DateTime? from, DateTime? to, bool inline = false) { - var fromDate = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date; - var toDate = (to ?? DateTime.Today).Date; - var toEnd = toDate.AddDays(1).AddTicks(-1); - var companyName = await GetCompanyNameAsync(); - - var invoices = await _context.Invoices - .Include(i => i.Customer).Include(i => i.Payments) - .Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided - && i.InvoiceDate >= fromDate && i.InvoiceDate <= toEnd) - .OrderBy(i => i.InvoiceDate).ToListAsync(); - - var collectedInPeriod = await _context.Payments - .Where(p => p.PaymentDate >= fromDate && p.PaymentDate <= toEnd) - .SumAsync(p => (decimal?)p.Amount) ?? 0; - - var byCustomer = invoices - .GroupBy(i => new { i.CustomerId, Name = i.Customer!.IsCommercial ? i.Customer.CompanyName : $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim() }) - .Select(g => new SalesByCustomerDto { CustomerId = g.Key.CustomerId, CustomerName = g.Key.Name, InvoiceCount = g.Count(), TotalInvoiced = g.Sum(i => i.Total), TotalPaid = g.Sum(i => i.AmountPaid), BalanceDue = g.Sum(i => i.BalanceDue) }) - .OrderByDescending(c => c.TotalInvoiced).ToList(); - - var byMonth = invoices - .GroupBy(i => new { i.InvoiceDate.Year, i.InvoiceDate.Month }) - .Select(g => new SalesByMonthDto { Year = g.Key.Year, Month = g.Key.Month, Label = new DateTime(g.Key.Year, g.Key.Month, 1).ToString("MMM yyyy"), TotalInvoiced = g.Sum(i => i.Total), TotalCollected = g.Sum(i => i.AmountPaid), InvoiceCount = g.Count() }) - .OrderBy(m => m.Year).ThenBy(m => m.Month).ToList(); - - var invoiceLines = invoices.Select(i => new SalesInvoiceLineDto - { - InvoiceId = i.Id, InvoiceNumber = i.InvoiceNumber, - CustomerName = i.Customer!.IsCommercial ? i.Customer.CompanyName : $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim(), - InvoiceDate = i.InvoiceDate, DueDate = i.DueDate, Status = i.Status.ToString(), - SubTotal = i.SubTotal, TaxAmount = i.TaxAmount, Total = i.Total, AmountPaid = i.AmountPaid, BalanceDue = i.BalanceDue, - }).ToList(); - - var dto = new SalesIncomeReportDto - { - From = fromDate, To = toDate, CompanyName = companyName, - TotalInvoiced = invoices.Sum(i => i.Total), TotalCollected = collectedInPeriod, - TotalTax = invoices.Sum(i => i.TaxAmount), TotalDiscount = invoices.Sum(i => i.DiscountAmount), - InvoiceCount = invoices.Count, CustomerCount = invoices.Select(i => i.CustomerId).Distinct().Count(), - ByCustomer = byCustomer, ByMonth = byMonth, Invoices = invoiceLines, - }; - + var fromDate = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date; + var toDate = (to ?? DateTime.Today).Date; + var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0; + var dto = await _financialReports.GetSalesAndIncomeAsync(companyId, fromDate, toDate); var pdfBytes = await _pdfService.GenerateSalesAndIncomePdfAsync(dto); return inline ? File(pdfBytes, "application/pdf") : File(pdfBytes, "application/pdf", $"SalesAndIncome-{fromDate:yyyyMMdd}-{toDate:yyyyMMdd}.pdf"); } @@ -1996,8 +1396,8 @@ public class ReportsController : Controller if (!AllowAccounting()) return RedirectToAction(nameof(Landing)); var now = DateTime.UtcNow; var today = DateTime.Today; - var allBills = await _context.Bills.Include(b => b.Vendor).Include(b => b.Payments.Where(p => !p.IsDeleted)).Where(b => !b.IsDeleted && b.Status != BillStatus.Voided).ToListAsync(); - var allExpenses = await _context.Expenses.Include(e => e.ExpenseAccount).Where(e => !e.IsDeleted).ToListAsync(); + var allBills = await _operationalReports.GetActiveBillsAsync(); + var allExpenses = await _operationalReports.GetAllExpensesAsync(); var outstandingBills = allBills.Where(b => b.BalanceDue > 0).ToList(); var apAgingBuckets = new List { new() { Label = "Current (0–30 days)" }, new() { Label = "31–60 days" }, new() { Label = "61–90 days" }, new() { Label = "Over 90 days" } }; @@ -2116,7 +1516,7 @@ public class ReportsController : Controller var now = DateTime.UtcNow; var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" }; var completedJobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.JobStatus)).Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode) && j.CompletedDate.HasValue).ToList(); - var allStatusHistory = await _context.JobStatusHistory.Include(h => h.FromStatus).Include(h => h.ToStatus).ToListAsync(); + var allStatusHistory = await _operationalReports.GetAllJobStatusHistoryAsync(); var historyByJob = allStatusHistory.GroupBy(h => h.JobId).ToDictionary(g => g.Key, g => g.OrderBy(h => h.ChangedDate).ToList()); var statusDisplayOrder = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING", "MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK" }; var statusTimings = new Dictionary Days)>(); @@ -2293,10 +1693,7 @@ public class ReportsController : Controller .Count(i => i.DueDate.HasValue && (today - i.DueDate.Value).TotalDays > 30); // Expenses by account - var allExpenses = await _context.Expenses - .Include(e => e.ExpenseAccount) - .Where(e => !e.IsDeleted) - .ToListAsync(); + var allExpenses = await _operationalReports.GetAllExpensesAsync(); var expensesByCategory = allExpenses .Where(e => e.ExpenseAccount != null) .GroupBy(e => e.ExpenseAccount!.Name) @@ -2306,7 +1703,7 @@ public class ReportsController : Controller var totalExpenses = allExpenses.Sum(e => e.Amount); // Also include bills paid as expenses - var allBills = await _context.Bills.Where(b => !b.IsDeleted).ToListAsync(); + var allBills = await _operationalReports.GetActiveBillsAsync(); var billsPaid = allBills.Sum(b => b.AmountPaid); totalExpenses += billsPaid; @@ -2400,11 +1797,9 @@ public class ReportsController : Controller }).ToList(); // Open AP bills - var openBills = await _context.Bills - .Include(b => b.Vendor) - .Where(b => !b.IsDeleted && b.AmountPaid < b.Total - && b.Status != BillStatus.Voided) - .ToListAsync(); + var openBills = (await _operationalReports.GetActiveBillsAsync()) + .Where(b => b.AmountPaid < b.Total) + .ToList(); var apItems = openBills.Select(b => new CashFlowApItem { @@ -2415,13 +1810,11 @@ public class ReportsController : Controller }).ToList(); // Active job pipeline (non-terminal jobs not yet invoiced) - var activeJobs = await _context.Jobs - .Include(j => j.Customer) - .Include(j => j.JobStatus) - .Where(j => !j.IsDeleted && j.JobStatus != null && !j.JobStatus.IsTerminalStatus) + var activeJobs = (await _unitOfWork.Jobs.GetBoardJobsAsync()) + .Where(j => j.JobStatus != null && !j.JobStatus.IsTerminalStatus) .OrderByDescending(j => j.FinalPrice > 0 ? j.FinalPrice : j.QuotedPrice) .Take(30) - .ToListAsync(); + .ToList(); var jobItems = activeJobs.Select(j => new CashFlowJobItem { @@ -2484,12 +1877,9 @@ public class ReportsController : Controller var startOfThisMonth = new DateTime(today.Year, today.Month, 1); var startOfLastMonth = startOfThisMonth.AddMonths(-1); - // Recent bills (last 90 days) - var recentBills = await _context.Bills - .Include(b => b.Vendor) - .Where(b => !b.IsDeleted && b.BillDate >= ninetyDaysAgo) - .OrderByDescending(b => b.BillDate) - .ToListAsync(); + // All active bills — used for both recent-bill candidates and all-time vendor history + var allBills = await _operationalReports.GetActiveBillsAsync(); + var recentBills = allBills.Where(b => b.BillDate >= ninetyDaysAgo).OrderByDescending(b => b.BillDate).ToList(); var billSummaries = recentBills.Select(b => new AnomalyBillSummary { @@ -2501,12 +1891,6 @@ public class ReportsController : Controller VendorInvoiceNumber = b.VendorInvoiceNumber }).ToList(); - // Vendor history (all time, for averages) - var allBills = await _context.Bills - .Include(b => b.Vendor) - .Where(b => !b.IsDeleted && b.Status != BillStatus.Voided) - .ToListAsync(); - var vendorHistory = allBills .Where(b => b.Vendor != null) .GroupBy(b => b.Vendor!.CompanyName) @@ -2525,10 +1909,7 @@ public class ReportsController : Controller }).ToList(); // Account spend trends (bills + expenses by account this month vs historical avg) - var allExpenses = await _context.Expenses - .Include(e => e.ExpenseAccount) - .Where(e => !e.IsDeleted) - .ToListAsync(); + var allExpenses = await _operationalReports.GetAllExpensesAsync(); var accountTrends = allExpenses .Where(e => e.ExpenseAccount != null) @@ -2581,7 +1962,7 @@ public class ReportsController : Controller var companyIdClaim = User.FindFirst("CompanyId")?.Value; if (companyIdClaim != null && int.TryParse(companyIdClaim, out int companyId)) { - var company = await _context.Companies.FirstOrDefaultAsync(c => c.Id == companyId); + var company = await _unitOfWork.Companies.GetByIdAsync(companyId); return company?.CompanyName ?? "Your Company"; } return "Your Company"; diff --git a/src/PowderCoating.Web/Controllers/RevenueController.cs b/src/PowderCoating.Web/Controllers/RevenueController.cs index 76209ce..04fe5ea 100644 --- a/src/PowderCoating.Web/Controllers/RevenueController.cs +++ b/src/PowderCoating.Web/Controllers/RevenueController.cs @@ -13,6 +13,7 @@ namespace PowderCoating.Web.Controllers; /// directly (bypassing the UoW) because it queries across company boundaries and /// joins plan configs — a pattern that would require multiple unrelated repositories. /// +// Intentional exception: cross-tenant MRR/ARR metrics joining Company + SubscriptionPlanConfig; same pattern as CompanyHealthController. See docs/DATA_ACCESS_ARCHITECTURE.md — Permanent Exceptions. [Authorize(Policy = AppConstants.Policies.SuperAdminOnly)] public class RevenueController : Controller { diff --git a/src/PowderCoating.Web/Controllers/SetupWizardController.cs b/src/PowderCoating.Web/Controllers/SetupWizardController.cs index ef10519..24035db 100644 --- a/src/PowderCoating.Web/Controllers/SetupWizardController.cs +++ b/src/PowderCoating.Web/Controllers/SetupWizardController.cs @@ -8,7 +8,6 @@ using PowderCoating.Application.Services; using PowderCoating.Core.Entities; using PowderCoating.Core.Enums; using PowderCoating.Core.Interfaces; -using PowderCoating.Infrastructure.Data; using PowderCoating.Shared.Constants; using System.Text.Json; @@ -20,7 +19,6 @@ public class SetupWizardController : Controller private readonly IUnitOfWork _unitOfWork; private readonly ITenantContext _tenantContext; private readonly UserManager _userManager; - private readonly ApplicationDbContext _context; private readonly ISeedDataService _seedDataService; private readonly ILogger _logger; @@ -28,14 +26,12 @@ public class SetupWizardController : Controller IUnitOfWork unitOfWork, ITenantContext tenantContext, UserManager userManager, - ApplicationDbContext context, ISeedDataService seedDataService, ILogger logger) { _unitOfWork = unitOfWork; _tenantContext = tenantContext; _userManager = userManager; - _context = context; _seedDataService = seedDataService; _logger = logger; } @@ -70,14 +66,14 @@ public class SetupWizardController : Controller if (company.Preferences == null) { company.Preferences = new CompanyPreferences { CompanyId = companyId }; - _context.Set().Add(company.Preferences); + await _unitOfWork.CompanyPreferences.AddAsync(company.Preferences); await _unitOfWork.CompleteAsync(); } if (company.OperatingCosts == null) { company.OperatingCosts = new CompanyOperatingCosts { CompanyId = companyId }; - _context.Set().Add(company.OperatingCosts); + await _unitOfWork.CompanyOperatingCosts.AddAsync(company.OperatingCosts); await _unitOfWork.CompleteAsync(); } diff --git a/src/PowderCoating.Web/Controllers/SmsConsentAuditController.cs b/src/PowderCoating.Web/Controllers/SmsConsentAuditController.cs index 8916709..e969093 100644 --- a/src/PowderCoating.Web/Controllers/SmsConsentAuditController.cs +++ b/src/PowderCoating.Web/Controllers/SmsConsentAuditController.cs @@ -1,7 +1,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using PowderCoating.Infrastructure.Data; +using PowderCoating.Core.Interfaces; using PowderCoating.Shared.Constants; using System.Text; @@ -15,12 +14,12 @@ namespace PowderCoating.Web.Controllers; [Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)] public class SmsConsentAuditController : Controller { - private readonly ApplicationDbContext _context; + private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; - public SmsConsentAuditController(ApplicationDbContext context, ILogger logger) + public SmsConsentAuditController(IUnitOfWork unitOfWork, ILogger logger) { - _context = context; + _unitOfWork = unitOfWork; _logger = logger; } @@ -31,14 +30,12 @@ public class SmsConsentAuditController : Controller { try { - var query = _context.Customers - .AsNoTracking() - .Where(c => !c.IsDeleted); + var allCustomers = await _unitOfWork.Customers.GetAllAsync(); if (!string.IsNullOrWhiteSpace(search)) { var s = search.Trim().ToLower(); - query = query.Where(c => + allCustomers = allCustomers.Where(c => (c.ContactFirstName != null && c.ContactFirstName.ToLower().Contains(s)) || (c.ContactLastName != null && c.ContactLastName.ToLower().Contains(s)) || (c.CompanyName != null && c.CompanyName.ToLower().Contains(s)) || @@ -46,43 +43,25 @@ public class SmsConsentAuditController : Controller (c.Phone != null && c.Phone.Contains(s))); } - var customers = await query - .Select(c => new - { - c.Id, - c.CompanyName, - c.ContactFirstName, - c.ContactLastName, - c.IsCommercial, - c.Phone, - c.MobilePhone, - c.NotifyBySms, - c.SmsConsentedAt, - c.SmsConsentMethod, - c.SmsOptedOutAt - }) + var allRows = allCustomers .OrderBy(c => c.CompanyName ?? c.ContactLastName ?? c.ContactFirstName) - .ToListAsync(); + .Select(c => new SmsConsentRow + { + CustomerId = c.Id, + CustomerName = GetDisplayName(c.IsCommercial, c.CompanyName, c.ContactFirstName, c.ContactLastName), + Phone = c.Phone, + MobilePhone = c.MobilePhone, + NotifyBySms = c.NotifyBySms, + ConsentedAt = c.SmsConsentedAt, + ConsentMethod = c.SmsConsentMethod, + OptedOutAt = c.SmsOptedOutAt, + SmsStatus = ResolveSmsStatus(c.NotifyBySms, c.SmsConsentedAt, c.SmsOptedOutAt) + }).ToList(); - var allRows = customers.Select(c => new SmsConsentRow - { - CustomerId = c.Id, - CustomerName = GetDisplayName(c.IsCommercial, c.CompanyName, c.ContactFirstName, c.ContactLastName), - Phone = c.Phone, - MobilePhone = c.MobilePhone, - NotifyBySms = c.NotifyBySms, - ConsentedAt = c.SmsConsentedAt, - ConsentMethod = c.SmsConsentMethod, - OptedOutAt = c.SmsOptedOutAt, - SmsStatus = ResolveSmsStatus(c.NotifyBySms, c.SmsConsentedAt, c.SmsOptedOutAt) - }).ToList(); + var optedIn = allRows.Count(r => r.SmsStatus == "active"); + var optedOut = allRows.Count(r => r.SmsStatus == "opted-out"); + var never = allRows.Count(r => r.SmsStatus == "never"); - // Stat counts across unfiltered set - var optedIn = allRows.Count(r => r.SmsStatus == "active"); - var optedOut = allRows.Count(r => r.SmsStatus == "opted-out"); - var never = allRows.Count(r => r.SmsStatus == "never"); - - // Apply filter var filtered = filter switch { "opted-in" => allRows.Where(r => r.SmsStatus == "active").ToList(), @@ -119,25 +98,9 @@ public class SmsConsentAuditController : Controller { try { - var customers = await _context.Customers - .AsNoTracking() - .Where(c => !c.IsDeleted) - .Select(c => new - { - c.Id, - c.CompanyName, - c.ContactFirstName, - c.ContactLastName, - c.IsCommercial, - c.Phone, - c.MobilePhone, - c.NotifyBySms, - c.SmsConsentedAt, - c.SmsConsentMethod, - c.SmsOptedOutAt - }) + var customers = (await _unitOfWork.Customers.GetAllAsync()) .OrderBy(c => c.CompanyName ?? c.ContactLastName ?? c.ContactFirstName) - .ToListAsync(); + .ToList(); var sb = new StringBuilder(); sb.AppendLine("Customer Name,Phone,Mobile Phone,SMS Status,Consented At (UTC),Consent Method,Opted Out At (UTC)"); diff --git a/src/PowderCoating.Web/Controllers/StripeEventsController.cs b/src/PowderCoating.Web/Controllers/StripeEventsController.cs index 09f9198..91634b0 100644 --- a/src/PowderCoating.Web/Controllers/StripeEventsController.cs +++ b/src/PowderCoating.Web/Controllers/StripeEventsController.cs @@ -14,6 +14,7 @@ namespace PowderCoating.Web.Controllers; /// delivery, and view the raw JSON payload Stripe sent. Restricted to SuperAdmin because the raw /// event payloads may contain sensitive subscription and billing information. /// +// Intentional exception: StripeWebhookEvents is a platform infrastructure table (not a business entity); same reasoning as StripeWebhookController. See docs/DATA_ACCESS_ARCHITECTURE.md — Permanent Exceptions. [Authorize(Policy = AppConstants.Policies.SuperAdminOnly)] public class StripeEventsController : Controller { diff --git a/src/PowderCoating.Web/Controllers/SubscriptionManagementController.cs b/src/PowderCoating.Web/Controllers/SubscriptionManagementController.cs index 2ae9e75..a7f021c 100644 --- a/src/PowderCoating.Web/Controllers/SubscriptionManagementController.cs +++ b/src/PowderCoating.Web/Controllers/SubscriptionManagementController.cs @@ -19,6 +19,7 @@ namespace PowderCoating.Web.Controllers; /// because subscription management is a platform-level concern, not a tenant-domain concern, /// and requires direct access to the Company entity which lives outside the tenant data layer. /// +// Intentional exception: cross-tenant Company management with raw SQL audit log writes that bypass the tenant pipeline; platform-level concern outside IUnitOfWork scope. See docs/DATA_ACCESS_ARCHITECTURE.md — Permanent Exceptions. [Authorize(Policy = AppConstants.Policies.SuperAdminOnly)] public class SubscriptionManagementController : Controller { diff --git a/src/PowderCoating.Web/Controllers/UnsubscribeController.cs b/src/PowderCoating.Web/Controllers/UnsubscribeController.cs index 3a27c7a..88e8585 100644 --- a/src/PowderCoating.Web/Controllers/UnsubscribeController.cs +++ b/src/PowderCoating.Web/Controllers/UnsubscribeController.cs @@ -1,8 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; -using Microsoft.EntityFrameworkCore; -using PowderCoating.Infrastructure.Data; +using PowderCoating.Core.Interfaces; using PowderCoating.Shared.Constants; namespace PowderCoating.Web.Controllers; @@ -15,12 +14,12 @@ namespace PowderCoating.Web.Controllers; [EnableRateLimiting(AppConstants.RateLimitPolicies.Public)] public class UnsubscribeController : Controller { - private readonly ApplicationDbContext _context; + private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; - public UnsubscribeController(ApplicationDbContext context, ILogger logger) + public UnsubscribeController(IUnitOfWork unitOfWork, ILogger logger) { - _context = context; + _unitOfWork = unitOfWork; _logger = logger; } @@ -38,11 +37,10 @@ public class UnsubscribeController : Controller try { - // Bypass global query filters so we can find the customer by token + // ignoreQueryFilters=true so we can find the customer by token // regardless of company context (the user clicking is not authenticated) - var customer = await _context.Customers - .IgnoreQueryFilters() - .FirstOrDefaultAsync(c => c.UnsubscribeToken == token && !c.IsDeleted); + var customer = await _unitOfWork.Customers.FirstOrDefaultAsync( + c => c.UnsubscribeToken == token && !c.IsDeleted, ignoreQueryFilters: true); if (customer == null) { @@ -52,7 +50,6 @@ public class UnsubscribeController : Controller if (!customer.NotifyByEmail) { - // Already unsubscribed — show success page anyway (idempotent) ViewBag.CustomerName = customer.CompanyName ?? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim(); ViewBag.AlreadyUnsubscribed = true; return View("EmailConfirm"); @@ -60,7 +57,7 @@ public class UnsubscribeController : Controller customer.NotifyByEmail = false; customer.UpdatedAt = DateTime.UtcNow; - await _context.SaveChangesAsync(); + await _unitOfWork.CompleteAsync(); _logger.LogInformation("Customer {CustomerId} unsubscribed from email notifications via link", customer.Id); @@ -88,9 +85,8 @@ public class UnsubscribeController : Controller try { - var company = await _context.Companies - .IgnoreQueryFilters() - .FirstOrDefaultAsync(c => c.MarketingUnsubscribeToken == token && !c.IsDeleted); + var company = await _unitOfWork.Companies.FirstOrDefaultAsync( + c => c.MarketingUnsubscribeToken == token && !c.IsDeleted, ignoreQueryFilters: true); if (company == null) { @@ -105,7 +101,7 @@ public class UnsubscribeController : Controller { company.MarketingEmailOptOut = true; company.UpdatedAt = DateTime.UtcNow; - await _context.SaveChangesAsync(); + await _unitOfWork.CompleteAsync(); _logger.LogInformation("Company {CompanyId} opted out of broadcast emails via link", company.Id); } diff --git a/src/PowderCoating.Web/Controllers/UsageQuotaController.cs b/src/PowderCoating.Web/Controllers/UsageQuotaController.cs index beae11a..25a6abe 100644 --- a/src/PowderCoating.Web/Controllers/UsageQuotaController.cs +++ b/src/PowderCoating.Web/Controllers/UsageQuotaController.cs @@ -15,6 +15,7 @@ namespace PowderCoating.Web.Controllers; /// efficient bulk-count GROUP BY queries in parallel rather than loading /// each company's data through separate repository calls. /// +// Intentional exception: cross-tenant bulk GROUP BY quota queries that would require O(n) repository round-trips if routed through IUnitOfWork. See docs/DATA_ACCESS_ARCHITECTURE.md — Permanent Exceptions. [Authorize(Policy = AppConstants.Policies.SuperAdminOnly)] public class UsageQuotaController : Controller { diff --git a/src/PowderCoating.Web/Controllers/UserActivityController.cs b/src/PowderCoating.Web/Controllers/UserActivityController.cs index e4c34cc..b8e27a0 100644 --- a/src/PowderCoating.Web/Controllers/UserActivityController.cs +++ b/src/PowderCoating.Web/Controllers/UserActivityController.cs @@ -8,6 +8,7 @@ using System.Text; namespace PowderCoating.Web.Controllers; +// Intentional exception: queries ASP.NET Identity ApplicationUser across all tenants with Include(u => u.Company); Identity entities live outside IUnitOfWork. See docs/DATA_ACCESS_ARCHITECTURE.md — Permanent Exceptions. [Authorize(Policy = AppConstants.Policies.SuperAdminOnly)] public class UserActivityController : Controller { diff --git a/src/PowderCoating.Web/Controllers/VendorsController.cs b/src/PowderCoating.Web/Controllers/VendorsController.cs index 43a801e..9b8c433 100644 --- a/src/PowderCoating.Web/Controllers/VendorsController.cs +++ b/src/PowderCoating.Web/Controllers/VendorsController.cs @@ -10,7 +10,6 @@ using PowderCoating.Application.DTOs.Vendor; using PowderCoating.Core.Entities; using PowderCoating.Core.Enums; using PowderCoating.Core.Interfaces; -using PowderCoating.Infrastructure.Data; namespace PowderCoating.Web.Controllers; @@ -28,20 +27,17 @@ public class VendorsController : Controller private readonly IMapper _mapper; private readonly UserManager _userManager; private readonly ILogger _logger; - private readonly ApplicationDbContext _context; public VendorsController( IUnitOfWork unitOfWork, IMapper mapper, UserManager userManager, - ILogger logger, - ApplicationDbContext context) + ILogger logger) { _unitOfWork = unitOfWork; _mapper = mapper; _userManager = userManager; _logger = logger; - _context = context; } /// @@ -163,7 +159,7 @@ public class VendorsController : Controller var vendorDto = _mapper.Map(vendor); if (vendor.DefaultExpenseAccountId.HasValue) { - var acct = await _context.Accounts.FindAsync(vendor.DefaultExpenseAccountId.Value); + var acct = await _unitOfWork.Accounts.GetByIdAsync(vendor.DefaultExpenseAccountId.Value); vendorDto.DefaultExpenseAccountName = acct != null ? $"{acct.AccountNumber} – {acct.Name}" : null; } return View(vendorDto); @@ -395,14 +391,13 @@ public class VendorsController : Controller /// private async Task PopulateExpenseAccountsAsync() { - var accounts = await _context.Accounts - .Where(a => !a.IsDeleted && a.IsActive && - (a.AccountType == AccountType.Expense || - a.AccountType == AccountType.CostOfGoods || - a.AccountType == AccountType.Asset)) + var accounts = (await _unitOfWork.Accounts.FindAsync( + a => a.IsActive && (a.AccountType == AccountType.Expense || + a.AccountType == AccountType.CostOfGoods || + a.AccountType == AccountType.Asset))) .OrderBy(a => a.AccountNumber) .Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString())) - .ToListAsync(); + .ToList(); accounts.Insert(0, new SelectListItem("— None —", "")); ViewBag.ExpenseAccounts = accounts; diff --git a/src/PowderCoating.Web/Program.cs b/src/PowderCoating.Web/Program.cs index bbc936a..e1d07b5 100644 --- a/src/PowderCoating.Web/Program.cs +++ b/src/PowderCoating.Web/Program.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using PowderCoating.Core.Entities; using PowderCoating.Core.Interfaces; +using PowderCoating.Core.Interfaces.Services; using PowderCoating.Infrastructure.Data; using PowderCoating.Infrastructure.Repositories; using PowderCoating.Infrastructure.Services; @@ -209,6 +210,11 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -835,6 +841,11 @@ using (var scope = app.Services.CreateScope()) // Fail fast with a clear message rather than a cryptic runtime error later. ValidateRequiredConfiguration(app.Configuration, app.Environment); +// ── Data access architecture enforcement ───────────────────────────────────── +// Throws at startup if any non-exempt controller injects ApplicationDbContext directly. +// This is the Phase 4 gate: the app cannot start with a violation. +EnforceDataAccessArchitecture(); + try { Log.Information("Starting web application"); @@ -894,6 +905,61 @@ static void ValidateRequiredConfiguration(IConfiguration config, IWebHostEnviron } } +// ── Data access architecture enforcement ───────────────────────────────────────── +/// +/// Scans every Controller subclass in the Web assembly at startup and throws if any +/// non-exempt controller declares ApplicationDbContext as a constructor parameter. +/// This enforces the rule defined in docs/DATA_ACCESS_ARCHITECTURE.md — if a developer +/// adds a new controller that injects ApplicationDbContext directly, the app will refuse +/// to start with a clear message naming the violator. +/// +static void EnforceDataAccessArchitecture() +{ + // Controllers in this set are documented permanent exceptions — see DATA_ACCESS_ARCHITECTURE.md. + var permanentExceptions = new HashSet + { + "StripeWebhookController", + "WebhooksController", + "PaymentController", + "RegistrationController", + "DataExportController", + "AccountDataExportController", + "DataPurgeController", + "SystemInfoController", + "SystemLogsController", + "CompanyHealthController", + "PasskeyController", + "AuditLogController", + "UserActivityController", + "EmailBroadcastController", + "RevenueController", + "StripeEventsController", + "SubscriptionManagementController", + "UsageQuotaController", + }; + + var violators = typeof(Program).Assembly.GetTypes() + .Where(t => t.IsClass && !t.IsAbstract + && typeof(Microsoft.AspNetCore.Mvc.Controller).IsAssignableFrom(t) + && !permanentExceptions.Contains(t.Name)) + .Where(t => t.GetConstructors() + .Any(ctor => ctor.GetParameters() + .Any(p => p.ParameterType == typeof(ApplicationDbContext)))) + .Select(t => t.Name) + .OrderBy(n => n) + .ToList(); + + if (violators.Count == 0) return; + + var names = string.Join(", ", violators); + throw new InvalidOperationException( + $"DATA ACCESS VIOLATION — {violators.Count} controller(s) inject ApplicationDbContext directly " + + $"and are not in the permanent exceptions list.\n" + + $"Violators: {names}\n" + + $"Fix: route data access through IUnitOfWork. " + + $"To add a permanent exception, update both the controller comment and docs/DATA_ACCESS_ARCHITECTURE.md."); +} + // ── Serilog DB sink column configuration ───────────────────────────────────────── static ColumnOptions BuildLogColumnOptions() { diff --git a/tests/PowderCoating.UnitTests/DepositsControllerTests.cs b/tests/PowderCoating.UnitTests/DepositsControllerTests.cs index e1b2fa2..a2931bd 100644 --- a/tests/PowderCoating.UnitTests/DepositsControllerTests.cs +++ b/tests/PowderCoating.UnitTests/DepositsControllerTests.cs @@ -290,8 +290,7 @@ public class DepositsControllerTests var controller = new DepositsController( uow, userManager.Object, - Mock.Of>(), - context); + Mock.Of>()); controller.ControllerContext = new ControllerContext {