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 {